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:

  1. Register a building, then a room inside it, then a sensor in that room.
  2. Declare, on the sensor, which parameters it measures (e.g. co2, temperature). A sensor may only report parameters it has declared.
  3. 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-Key header, 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:

FieldRule
parameterCodeMust be one the sensor declared it measures. Otherwise the batch is rejected (parameter-not-declared).
valueA number. Must fall inside the parameter's permitted range, else value-out-of-range.
unitMust 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

StatusMeaningWhat to do
202 AcceptedBatch 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 UnauthorizedThe X-Sensor-Key is missing or wrong.Check the header / rotate the key.
403 ForbiddenThe sensor exists but is not active (e.g. in maintenance or retired).Re-activate it in the operator app.
422 Unprocessable EntityA 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 RequestsYou exceeded the sensor's reporting interval.Wait Retry-After seconds, then resend.
503 Service UnavailableThe 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

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 codeQuantityCanonical unitPermitted range
co2Carbon dioxideppm0 – 50 000
eco2Equivalent CO₂ppm0 – 65 000
coCarbon monoxideppm0 – 2 000
o3Ozoneµg/m³0 – 500
no2Nitrogen dioxideµg/m³0 – 500
so2Sulphur dioxideµg/m³0 – 500
vocVolatile organic compoundsppb0 – 60 000
pm1Particulate matter ≤ 1 µmµg/m³0 – 500
pm2_5Particulate matter ≤ 2.5 µmµg/m³0 – 500
pm4Particulate matter ≤ 4 µmµg/m³0 – 1 000
pm10Particulate matter ≤ 10 µmµg/m³0 – 1 000
temperatureAir temperature°C−40 – 85
humidityRelative humidity%0 – 100
air_velocityAir velocitym/s0 – 10
pressureAtmospheric pressurePa85 000 – 110 000
illuminanceIlluminancelx0 – 100 000
cctCorrelated colour temperatureK1 000 – 20 000
laeqA-weighted equivalent sound leveldB(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 co2 and temperature, then sending humidity, 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.

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:

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:DatasetSeries whose 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.