Ambiquality Wiki
Ambiquality is an open-data platform for Indoor Environmental Quality (IEQ) monitoring. Sensors deployed in rooms report measurements of indoor environmental parameters — CO₂, temperature, relative humidity, particulate matter, VOCs, sound pressure level and illuminance — and the platform republishes them as linked open data under CC BY 4.0.
It was built as a bachelor thesis at the Prague University of Economics and Business (VŠE), author Vilém Charwot.
Who this wiki is for
- Sensor operators — people who have registered a building, room and sensor in the admin app and now need to make their device send data. Start with Sending measurements.
- Data consumers / developers — see how the data is published (observations API, DCAT-AP catalog, monthly archives).
The data flow in one paragraph
You register a building → room → sensor in the operator app and receive a one-time sensor key. Your device then POSTs batches of readings to the Ingestion API, which validates each reading and durably enqueues it. A background worker writes the accepted measurements to the time-series store, and the Public API republishes them as JSON, JSON-LD and CSV, plus a DCAT-AP 3.0 catalogue and monthly downloadable archives.
Measurements are immutable. Once published, a measurement is never silently changed or deleted — a bad reading can only be flagged as invalid. Send carefully.
Sending measurements
This guide walks a sensor operator through making a registered device submit measurements to the Ambiquality Ingestion API.
The interactive, always-current contract for the single endpoint below is the API reference (Scalar). This page is the narrative version: the why, the rules, and copy-pasteable examples.
1. Prerequisites
Before a device can send anything you must, in the operator app:
- Register a building, then a room inside it, then a sensor in that room.
- Declare, on the sensor, which parameters it measures (e.g.
co2,temperature). A sensor may only report parameters it has declared. - On creation the app shows the sensor's secret key exactly once — it looks like
amq_sk_…. Copy it then; it is never shown again and cannot be recovered. If you lose it, rotate the key on the sensor (a new key invalidates the old one).
The key authenticates the device. Treat it like a password: keep it on the device, never commit it to source control, never put it in a URL.
2. The endpoint
POST {base}/ingestion/v1/measurements
X-Sensor-Key: amq_sk_your_key_here
Content-Type: application/json
{base}is the platform host (production:https://data.ambiquality.org).- The secret key travels in the
X-Sensor-Keyheader, never in the body or the URL. - The body is a batch of readings from one sensor.
3. The request body
A sensor sends every quantity it measured at that moment together, in one batch:
{
"sensorId": "33333333-3333-3333-3333-333333333333",
"readings": [
{ "parameterCode": "co2", "value": 812, "unit": "ppm" },
{ "parameterCode": "temperature", "value": 21.4, "unit": "°C" }
]
}
Rules for each reading:
| Field | Rule |
|---|---|
parameterCode | Must be one the sensor declared it measures. Otherwise the batch is rejected (parameter-not-declared). |
value | A number. Must fall inside the parameter's permitted range, else value-out-of-range. |
unit | Must equal the canonical unit for that parameter, else unit-mismatch. See Parameters & units. |
Two principles worth internalising:
- The batch is all-or-nothing. If any reading is invalid, the whole request is rejected and nothing is stored. Fix the reading and resend the batch.
- You do not send a timestamp. The server stamps the ingestion time from its own trusted clock at the moment it accepts the batch, so an unsynchronised device clock can never skew the recorded time. (All readings in one batch share that single instant.)
4. How often to send — the rate limit
Each sensor declares a reporting interval (its measurement_frequency_seconds,
floored at 5 minutes). The API allows one batch per interval per sensor. Send faster
and you get 429 Too Many Requests with a Retry-After header telling you how
many seconds to wait. A well-behaved device reads Retry-After and backs off exactly
that long.
So: bundle everything measured in a cycle into one batch per cycle, don't fire a request per parameter.
5. Responses
| Status | Meaning | What to do |
|---|---|---|
| 202 Accepted | Batch durably enqueued (not yet written to the DB — a worker materialises it shortly). The body echoes receivedAt and the assigned measurement id per reading. | Done. Optionally log the ids. |
| 401 Unauthorized | The X-Sensor-Key is missing or wrong. | Check the header / rotate the key. |
| 403 Forbidden | The sensor exists but is not active (e.g. in maintenance or retired). | Re-activate it in the operator app. |
| 422 Unprocessable Entity | A reading failed validation — empty batch, duplicate parameter, undeclared parameter, value out of range, or unit mismatch. The type/title say which. | Fix the offending reading and resend. |
| 429 Too Many Requests | You exceeded the sensor's reporting interval. | Wait Retry-After seconds, then resend. |
| 503 Service Unavailable | The durable queue could not accept the batch (nothing was stored). | Retry with backoff; the platform fsyncs every enqueue, so a 2xx is a real durability guarantee and a 503 means not stored. |
Errors use RFC 9457 problem+json with a stable
type of the form urn:ambiquality:ingestion:<reason> — branch on that, not on the
status code alone.
6. Examples
curl
curl -sS -X POST "https://data.ambiquality.org/ingestion/v1/measurements" \
-H "X-Sensor-Key: $AMQ_SENSOR_KEY" \
-H "Content-Type: application/json" \
-d '{
"sensorId": "33333333-3333-3333-3333-333333333333",
"readings": [
{ "parameterCode": "co2", "value": 812, "unit": "ppm" },
{ "parameterCode": "temperature", "value": 21.4, "unit": "°C" }
]
}'
Python
import os, requests
resp = requests.post(
"https://data.ambiquality.org/ingestion/v1/measurements",
headers={"X-Sensor-Key": os.environ["AMQ_SENSOR_KEY"]},
json={
"sensorId": "33333333-3333-3333-3333-333333333333",
"readings": [
{"parameterCode": "co2", "value": 812, "unit": "ppm"},
{"parameterCode": "temperature", "value": 21.4, "unit": "°C"},
],
},
timeout=10,
)
if resp.status_code == 202:
print("accepted:", resp.json()["receivedAt"])
elif resp.status_code == 429:
retry = int(resp.headers.get("Retry-After", "300"))
print(f"rate limited, retry in {retry}s")
else:
print("rejected:", resp.status_code, resp.json())
7. Next
- Parameters & units — the canonical units you must send.
- API reference — the live, machine-readable contract.
- How the data is published — where your measurements end up.
Parameters & units
Every reading you send must use the canonical unit for its parameter, and its value
must fall within the permitted range. A different unit rejects the batch
(unit-mismatch); a value outside the range rejects it (value-out-of-range).
These are the built-in parameters seeded by the platform. (Operators of an instance may add extension parameters with their own canonical unit and range; ask your platform administrator if you need one that isn't listed.)
| Parameter code | Quantity | Canonical unit | Permitted range |
|---|---|---|---|
co2 | Carbon dioxide | ppm | 0 – 50 000 |
eco2 | Equivalent CO₂ | ppm | 0 – 65 000 |
co | Carbon monoxide | ppm | 0 – 2 000 |
o3 | Ozone | µg/m³ | 0 – 500 |
no2 | Nitrogen dioxide | µg/m³ | 0 – 500 |
so2 | Sulphur dioxide | µg/m³ | 0 – 500 |
voc | Volatile organic compounds | ppb | 0 – 60 000 |
pm1 | Particulate matter ≤ 1 µm | µg/m³ | 0 – 500 |
pm2_5 | Particulate matter ≤ 2.5 µm | µg/m³ | 0 – 500 |
pm4 | Particulate matter ≤ 4 µm | µg/m³ | 0 – 1 000 |
pm10 | Particulate matter ≤ 10 µm | µg/m³ | 0 – 1 000 |
temperature | Air temperature | °C | −40 – 85 |
humidity | Relative humidity | % | 0 – 100 |
air_velocity | Air velocity | m/s | 0 – 10 |
pressure | Atmospheric pressure | Pa | 85 000 – 110 000 |
illuminance | Illuminance | lx | 0 – 100 000 |
cct | Correlated colour temperature | K | 1 000 – 20 000 |
laeq | A-weighted equivalent sound level | dB(A) | 0 – 140 |
Notes
- The unit string must match exactly, including the casing and the symbols
(
µg/m³,dB(A),°C). Send the unit string from the table verbatim. - A sensor may only report parameters it declared at registration. Declaring
co2andtemperature, then sendinghumidity, rejects the batch (parameter-not-declared) — add the parameter to the sensor first. - Ranges are inclusive sanity bounds, not calibration limits. They exist to catch obviously broken readings (a stuck probe, a wrong unit), not to grade air quality.
The authoritative, machine-readable list for a running instance is the
ieq.parameter_ranges table; the table above is the seeded default.
API reference
The platform ships an interactive, always-current OpenAPI reference rendered with Scalar for each service. These are generated from the running code, so they never drift from the real contract.
Ingestion API (sending data)
Read-only reference. The ingestion reference has its "Test Request" button disabled on purpose — you cannot POST real measurements from the docs UI. Submitting data requires a sensor key and should go through your device. Use the reference to read the exact schema and to copy a code sample, then send from the device. The narrative walkthrough is Sending measurements.
- Production: https://data.ambiquality.org/ingestion/scalar
- Local dev stack (Caddy on :8080): http://localhost:8080/ingestion/scalar
It documents the one endpoint you need: POST /v1/measurements.
Public API (reading data)
The read side — observations (JSON / JSON-LD / CSV), the building/room/sensor catalog, the DCAT-AP 3.0 dataset description and monthly archives — has its own reference:
- Production: https://data.ambiquality.org/public/scalar
- Local dev stack: http://localhost:8080/public/scalar
Raw OpenAPI documents
Each Scalar page is backed by a machine-readable document you can feed to a code
generator (the URL is shown in the Scalar UI), e.g. …/ingestion/openapi/v1.json.
How the data is published
Once your measurements are accepted they become open data. Here is where they end up and how a consumer reaches them — all under CC BY 4.0.
Live observations
The Public API serves observations with filtering and keyset pagination, in three representations via content negotiation:
application/json— plain JSON,application/ld+json— JSON-LD (linked data, SOSA/SSN + QUDT vocabularies),text/csv— CSV with a CSVW tabular schema.
Browse it in the Public API reference.
DCAT-AP catalogue
GET /public/v1/catalog returns a DCAT-AP 3.0
description of the platform's data:
- a continuous live dataset (the queryable API above), and
- a
dcat:DatasetSerieswhose members are one dataset per calendar month, each carrying its downloadable archive distributions.
Monthly archives
For bulk download, the platform publishes one gzip-compressed file per calendar month
per format (CSV and JSON-LD) — a single file, never a multi-file zip container. Each is
listed as a dcat:Distribution of that month's dataset in the catalogue, with its byte
size and record count.
Immutability
Published measurements are never silently changed or deleted. A reading later found to be wrong is flagged invalid (and excluded from default reads), but the historical record is preserved. This is why the ingestion path validates strictly up front — see Sending measurements.