# Bucharest air quality (InfoAer) > Current readings from InfoAer, the municipal air-quality sensor network Primăria Municipiului București operates across Bucharest. The source publishes each sensor's current pollutant readings as a small HTML fragment; this connector reshapes that into long format — one row per (sensor, reading_time, pollutant) — so pollutants can be filtered, aggregated and compared directly. - Dataset id: `infoaer-bucharest-air` - Update cadence: hourly - Upstream source: https://infoaer.pmb.ro/ - Tags: air-quality, environment, sensors, pollution, romania, bucharest, infoaer, pmb - Current version: ingest `01M54Q0EC4PB8A19JPMPTXZ8A8`, snapshot 2026-10-17, generated 2026-10-17T10:38:32.876Z ## Licence and attribution - Licence: Romanian official acts and public-interest information — outside copyright (`ro-official-acts-public-domain`) - Licence text: https://legislatie.just.ro/Public/DetaliiDocument/7816 - Required attribution: Date despre calitatea aerului publicate de Primăria Municipiului București — InfoAer — infoaer.pmb.ro - Notes: Legea 8/1996 privind dreptul de autor, art. 9, excludes from copyright both official texts of an administrative nature and simple facts and data. The sensor readings republished here are bare factual measurements — pollutant concentrations captured by municipal monitoring hardware — and the sensor catalogue is the official record of that public monitoring network's own devices. They are also public-interest information under Legea 544/2001 and re-usable public-sector information under Legea 179/2022, which transposes the EU Open Data Directive. HOWEVER, and a reuser must weigh this: no explicit reuse licence is published anywhere on infoaer.pmb.ro — checked directly on 2026-09-01/02: the site carries no footer copyright line, no terms page and no legal link, and its "Legislație" page lists only national/EU air-quality law, not site terms of use. This dataset therefore rests on the legal position above rather than on any grant from Primăria Municipiului București, and anyone republishing it should make their own assessment. This is a DERIVED work, not a republication of the site: `sensors` carries only each device's id, name, network type, coordinates and icon path, and `readings` carries only the pollutant readings reshaped out of the per-sensor popup HTML into long format — no page markup, images, or any other part of the site is republished. `robots.txt` on this domain has an empty `Disallow` — nothing on the site is off-limits to automated access. Finally, and most important for how this dataset should be used: these are municipal INDICATIVE sensors (a mix of Clarity, low-cost "CalitateAer", Bettair and MQTT devices), NOT the national regulatory reference network (RNMCA / calitateaer.ro). Do not present a value from this dataset as an official air-quality compliance measurement, and do not use it in place of a certified RNMCA reference station where the distinction matters (e.g. a legal exceedance finding). Reproduce the attribution line verbatim anywhere you republish this data. ## Manifest - Start here for how this directory works: https://data.bunnytech.app/llms.txt - This dataset: https://data-directory.fsn1.your-objectstorage.com/public/infoaer-bucharest-air/manifest.json - All datasets: https://data-directory.fsn1.your-objectstorage.com/public/index.json - Human page: https://data.bunnytech.app/datasets/infoaer-bucharest-air The manifest is the contract: file URLs, row counts, column types, min/max statistics and example queries all live there. Re-read it to discover a new version. ## Fastest path — Parquet exports Immutable, physically sorted snapshot exports. No catalog fetch, best row-group pruning, safe to cache forever. Register one view per table: ```sql INSTALL httpfs; LOAD httpfs; CREATE OR REPLACE VIEW sensors AS SELECT * FROM read_parquet('https://data-directory.fsn1.your-objectstorage.com/public/infoaer-bucharest-air/v/01M54Q0EC4PB8A19JPMPTXZ8A8/sensors.parquet'); CREATE OR REPLACE VIEW readings AS SELECT * FROM read_parquet('https://data-directory.fsn1.your-objectstorage.com/public/infoaer-bucharest-air/v/01M54Q0EC4PB8A19JPMPTXZ8A8/readings.parquet'); ``` Example queries: -- Current PM2.5 reading at every sensor, most recent first: pollutant leads the sort, so this filters to one pollutant's row groups before ordering by time. ```sql ATTACH IF NOT EXISTS 'ducklake:https://data-directory.fsn1.your-objectstorage.com/catalog/catalog.ducklake' AS "datadirectory" (READ_ONLY); SELECT s.name, r.reading_time, r.value, r.unit, r.quality_label FROM datadirectory.infoaer_bucharest_air.readings r JOIN datadirectory.infoaer_bucharest_air.sensors s USING (sensor_id) WHERE r.pollutant = 'pm_2_5' ORDER BY r.reading_time DESC LIMIT 20 ``` -- City-wide average NO2 over the last 24 hours ```sql ATTACH IF NOT EXISTS 'ducklake:https://data-directory.fsn1.your-objectstorage.com/catalog/catalog.ducklake' AS "datadirectory" (READ_ONLY); SELECT date_trunc('hour', reading_time) AS hour, avg(value) AS avg_no2 FROM datadirectory.infoaer_bucharest_air.readings WHERE pollutant = 'no2' AND reading_time >= now() - INTERVAL 24 HOUR GROUP BY 1 ORDER BY 1 ``` ## Live lake — DuckLake catalog with time travel Attach the published catalog read-only over plain https. This works in native DuckDB AND in duckdb-wasm in the browser — the reader catalog resolves its data files through public https URLs, so no credentials and no backend are involved. ```sql INSTALL ducklake; LOAD ducklake; ATTACH 'ducklake:https://data-directory.fsn1.your-objectstorage.com/catalog/infoaer-bucharest-air/catalog.ducklake' AS infoaer_bucharest_air (READ_ONLY); SELECT * FROM infoaer_bucharest_air.main.sensors LIMIT 5; USE infoaer_bucharest_air; ``` - Latest catalog: https://data-directory.fsn1.your-objectstorage.com/catalog/infoaer-bucharest-air/catalog.ducklake - This exact version: https://data-directory.fsn1.your-objectstorage.com/catalog/infoaer-bucharest-air/v/01M54Q0EC4PB8A19JPMPTXZ8A8/catalog.ducklake (sha256 bf49cb5b80e24f27a9330b3dfb397bd8ffa7f9a36776c99c48a1250512413625) - Written by DuckDB v1.5.5 - Time travel: this ingest is snapshot 29 — e.g. `SELECT * FROM infoaer_bucharest_air.sensors AT (VERSION => 29)` ## Wholesale — S3 access For bulk downloads, read the bucket prefix directly over S3 with httpfs: ```sql INSTALL httpfs; LOAD httpfs; CREATE SECRET hetzner ( TYPE s3, ENDPOINT 'fsn1.your-objectstorage.com', URL_STYLE 'path', KEY_ID '', SECRET '' ); SELECT * FROM read_parquet('s3://data-directory/public/infoaer-bucharest-air/v/01M54Q0EC4PB8A19JPMPTXZ8A8/**/*.parquet'); ``` ## Schema ### sensors One row per air-quality sensor InfoAer lists for Bucharest, with its location and network identity. Upserted: first_seen_date/last_seen_date widen over time, everything else reflects the most recent poll. - 114 rows · 12 KB · write mode `upsert` - Physically sorted by: `sensor_id` - Primary key: `sensor_id` - Text search columns: `name` - Files: 1 - https://data-directory.fsn1.your-objectstorage.com/public/infoaer-bucharest-air/v/01M54Q0EC4PB8A19JPMPTXZ8A8/sensors.parquet (114 rows) Columns: - `sensor_id` BIGINT NOT NULL — InfoAer's own numeric sensor id ("Id" in the listing response). Stable for the life of the device; joins to readings.sensor_id. (range: "1" … "99") (~115 distinct) - `name` VARCHAR — Sensor location name as published by InfoAer (e.g. a school, hospital or landmark near the installation). Null when the source sent nothing usable. - `name_lc` VARCHAR — name, lowercased and accent-folded. The prefix-search column — see searchColumns. - `sensor_type` VARCHAR — The sensor hardware/network family InfoAer reports this device under (observed values: "Clarity", "CalitateAer", "Bettair", "MQTT") — which vendor or protocol powers this installation, not an air-quality classification. Also selects which pin icon is shown and is required to look up this sensor's own popup/readings endpoint. - `latitude` DOUBLE [degrees] — Sensor location, WGS 84 latitude. Null when InfoAer's own coordinate for this sensor fell outside a generous Bucharest bounding box (see BUCHAREST_BBOX in feed.ts) — a small number of devices report an unset-GPS sentinel of (0, 0). The row itself is still published; only the untrustworthy coordinate pair is nulled. - `longitude` DOUBLE [degrees] — Sensor location, WGS 84 longitude. Nulled under the same bounding-box check as latitude — always null exactly when latitude is null. - `icon_path` VARCHAR — Map pin icon path as published by InfoAer, relative to https://infoaer.pmb.ro (e.g. "/modules/infoaer/images/pins/Clarity-2.svg"). Cosmetic; not resolved to an absolute URL. - `first_seen_date` DATE NOT NULL — The first run day (UTC) on which this connector saw this sensor. Maintained automatically by the upsert loader — never overwritten, only widened. - `last_seen_date` DATE NOT NULL — The most recent run day (UTC) on which InfoAer still listed this sensor. ### readings One row per (sensor, reading_time, pollutant): a current pollutant reading InfoAer reports for one sensor, as of that sensor's own last-updated time. Reshaped from the per-sensor popup endpoint's HTML reading list into long format so pollutants can be filtered, grouped and compared directly instead of one column family per pollutant. A reading is never revised once published — the next poll of the same sensor arrives with a later reading_time (or the identical one, if the sensor has not updated), never a correction. - 7,421 rows · 26 KB · write mode `append` - Physically sorted by: `pollutant`, `reading_time`, `sensor_id` - Primary key: `sensor_id`, `reading_time`, `pollutant` - Files: 1 - https://data-directory.fsn1.your-objectstorage.com/public/infoaer-bucharest-air/v/01M54Q0EC4PB8A19JPMPTXZ8A8/readings.parquet (7,421 rows) Columns: - `sensor_id` BIGINT NOT NULL — The InfoAer sensor id this reading came from. Joins to sensors.sensor_id. (range: "1" … "99") (~115 distinct) - `reading_time` TIMESTAMPTZ NOT NULL — The instant this reading is for — the timestamp InfoAer's own popup published alongside it (Europe/Bucharest local time on the source, converted to UTC here), not the time this connector happened to poll the API. Two sensors polled in the same run can carry different reading_time values when one of them has not updated in a while. (range: "2023-11-14 14:00:00+00" … "2026-10-17 10:30:00+00") (~151 distinct) - `pollutant` VARCHAR NOT NULL — Canonical pollutant code, slugified from the source's own display label (e.g. "PM 2.5" -> "pm_2_5", "Index VOC" -> "index_voc") — never a hand-maintained lookup table, so a label InfoAer adds tomorrow gets a code automatically. See pollutant_label for the label exactly as published. (range: "h2s" … "zgomot") (~14 distinct) - `pollutant_label` VARCHAR — Pollutant name exactly as InfoAer's popup displays it (e.g. "PM 2.5", "NO2", "Temperatura", "Index VOC") — verbatim, not a description of it. - `unit` VARCHAR — Unit of value, parsed out of the same text as the number (e.g. "µg/m³", "°C", "%", "hPa", "dBA"). Null for a handful of unitless index readings InfoAer itself publishes with no unit (e.g. "Index VOC", "Index NOx"). - `value` DECIMAL(10,4) — The reading's numeric value, in `unit`. Nullable in principle like every non-identity column, but in practice always populated — a pollutant line whose number could not be parsed is not published as a row at all (see transform.ts), the same way a wholly unreadable popup is skipped rather than published with nulls. - `quality_label` VARCHAR — The Romanian air-quality category badge InfoAer shows next to this specific reading (observed values: "Bun", "Acceptabil", "Moderat", "Rău"), when it published one. Only the pollutants InfoAer itself classifies get a badge — most auxiliary readings (temperature, humidity, pressure, noise, VOC/NOx indices) do not, and this is null for those. ## Performance notes - There are no indexes in a lakehouse. The physical sort order plus Parquet row-group min/max statistics ARE the index. Predicates on the leading sort columns prune whole row groups; everything else is a full column scan. - `sensors` prunes on `sensor_id`. - `readings` prunes on `pollutant` → `reading_time` → `sensor_id`. - `ILIKE '%needle%'` cannot prune: it decompresses that whole column. Prefer prefix predicates on the lowercased search companion columns where they exist. - Every file URL under `/v//` is immutable. Cache it as aggressively as you like. - Files are never rewritten in place — a new ingest writes a new version path. Re-read the manifest to pick up updates rather than polling the file URLs. - Pull only the columns you need. Parquet is columnar and the reader range-GETs what it reads. ## Do not - Do not scrape https://data.bunnytech.app HTML. Every number on those pages comes from the manifest above. - Do not hit the upstream source (https://infoaer.pmb.ro/) in bulk. This mirror exists so you do not have to. - Do not read DuckLake internal data files with a plain Parquet reader. They come with positional delete files, and ignoring those silently returns deleted rows. Use the ducklake extension, or use the published exports above. - Do not republish without the attribution line, and do not imply endorsement.