What am I looking at?
Each station's records, as published to the AT Protocol – the same data shown on the map and charts, in its underlying form. Pick a station, then one of its datastreams, to drill down.
- Station – a monitoring location.
- Datastream – one thing measured over time at that station (e.g. water level), with the sensor, unit and observed property shown as attributes.
- Recent readings – the daily batches of measurements; Verify cryptographically checks any record against the PDS (see the Info tab).
Network freshness
Stalest stations
| Station | Network | Last reading |
|---|
Relay Federation Status
Meitheal: SensorThings on ATProto
Environmental sensor data, published as structured, cryptographically signed records on the AT Protocol.
Meitheal (pronounced “meh-hul”) stands for Monitoring Environmental Indicators Through Hyperlocal, Equitable, Attested Lexicons. It is also an Irish word that describes neighbours coming together to help each other, sharing labour.
What & Why
If you're familiar with ATProto: Meitheal stores environmental sensor observations using lexicons that encode the OGC SensorThings standard.
Otherwise: Meitheal stores, redistributes, visualises, and provides time-series analysis of environmental sensor data such as water levels, flooding, ocean conditions, and air quality. This data has been published to an ATProto Personal Data Server (PDS) using the OGC SensorThings entity model encoded as ATProto Lexicons.
Each sensor reading is a signed record. The data is publicly addressable, verifiable, and replicable. It can be retrieved and its integrity verified by anyone. The sensor data collected by the platform fan out into the wider ATProto system, providing redundancy and resilience against interference.
Why ATProto for sensor data?
- Signed and verifiable
- Every record is cryptographically signed by the publisher. Anyone can check that the data is unaltered, without having to trust the server that delivered it.
- Portable identity
- Each data source has a DID (Decentralised Identifier), a permanent ID that is separate from where the data is hosted. If a publisher moves its sensor data to a different server, every link to it keeps working.
- Anyone can keep a copy
- Because every record is published to a public firehose, anyone can subscribe and keep a complete, up-to-date copy. The data does not depend on a single server or backup.
- One shared format
- The
dev.sensorthings.*Lexicons spell out exactly how each record must be structured. Any server that publishes records in this format joins the network, with no central sign-up required.
Glossary
- DID – Decentralised Identifier: a permanent ID for a publisher. It does not change when the data moves to a different server, so links to the data do not break.
- PDS – Personal Data Server: the server that stores a publisher’s records and serves them to the public.
- Firehose – a live stream of every new record as it is published. Anyone can subscribe and keep their own copy.
- Lexicon – a schema: a machine-readable definition of how a record must be structured, so any system can read it.
Entity Model
The data follow the OGC SensorThings API entity model. Six entity types describe what is being measured, by what, where, and when:
Thing
A physical station or platform hosting sensors. Contains a name, location (lat/lon), and open metadata. Example: OPW gauge 26021 on the River Suir at Clonmel.
Sensor
An instrument or procedure that produces observations. Links to a datasheet or SensorML description. Example: Vaisala WXT536 weather transmitter.
ObservedProperty
The phenomenon being measured. Linked to a controlled vocabulary URI (CF Standard Names, QUDT). Example: air_temperature, PM2.5 concentration.
Datastream central join
The central entity: links one Thing + one Sensor + one ObservedProperty. Contains the unit of measurement and a scale factor for decoding integer results. All observations belong to exactly one Datastream.
Observation
A single measurement: a timestamp, a result value, and a reference to its parent Datastream. This is the high-volume record type. Example: 7.34 °C at 10:15 UTC.
FeatureOfInterest
The real-world feature being observed. Often implicit (the Thing’s location). Explicit when the observation target differs from the sensor location. Example: a river reach downstream of the gauge.
Lexicons
Each entity type is an ATProto Lexicon – a schema that defines the record structure and how records reference each other via AT-URIs. The namespace is dev.sensorthings.*.
resultScaleFactor (e.g. 3), and the Observation stores an integer (e.g. 2134). The real value is 2134 × 10−3 = 2.134. This is the same pattern used by e.g. Protocol Buffers.
| Field | Type | Description |
|---|---|---|
name | string | Human-readable station name |
description | string | Optional longer description |
location | geoPoint | WGS84 coordinates as latE7/lonE7 (integer × 107, ~1 cm precision) |
properties | object | Open metadata (manufacturer, deployment date, etc.) |
Record key: human-readable slug, e.g. sandy-mills
at://opw.sensorthings.dev/dev.sensorthings.thing/sandy-mills
| Field | Type | Description |
|---|---|---|
name | string | Instrument or procedure name |
encodingType | string | IANA media type of the metadata field |
metadata | string | URL to datasheet or SensorML document |
Record key: slug, e.g. sandy-mills-level
at://opw.sensorthings.dev/dev.sensorthings.sensor/sandy-mills-level
| Field | Type | Description |
|---|---|---|
name | string | Property name (e.g. “Water Level”) |
definition | URI | Controlled vocabulary reference – CF Standard Names, QUDT, NERC P07 |
Record key: slug, e.g. water-level
at://opw.sensorthings.dev/dev.sensorthings.observedProperty/water-level
→ definition: http://vocab.nerc.ac.uk/collection/P07/current/CFSN0052/
The Datastream ties everything together. It references a Thing, Sensor, and ObservedProperty, and contains the unit and scale factor needed to interpret observation values.
| Field | Type | Description |
|---|---|---|
thing | AT-URI | → dev.sensorthings.thing |
sensor | AT-URI | → dev.sensorthings.sensor |
observedProperty | AT-URI | → dev.sensorthings.observedProperty |
unitOfMeasurement | object | name, symbol, definition (QUDT/UCUM URI) |
resultScaleFactor | integer | Divides integer results: real = value × 10−n |
verticalDatum | string | For water level: datum URI or label (e.g. Malin Head OD) |
Record key: descriptive slug, e.g. sandy-mills-level-od
at://opw.sensorthings.dev/dev.sensorthings.datastream/sandy-mills-level-od
→ thing: at://…/thing/sandy-mills
→ sensor: at://…/sensor/sandy-mills-level
→ observedProperty: at://…/observedProperty/water-level
→ unitOfMeasurement: { name: "metre", symbol: "m" }
→ resultScaleFactor: 3
→ verticalDatum: "http://…/EPSG/0/5731"
| Field | Type | Description |
|---|---|---|
datastream | AT-URI | → parent Datastream (contains unit & scale) |
phenomenonTime | datetime | When the phenomenon was observed (ISO 8601) |
result | union | Discriminated by $type – see below |
resultQuality | token | good, suspect, or missing |
Result types
Most observations use numericResult: a single scaled integer. Decode it using the parent Datastream’s resultScaleFactor.
// Water level observation: 30756 ÷ 10³ = 30.756 m (Malin Head OD)
{
"datastream": "at://…/datastream/sandy-mills-level-od",
"phenomenonTime": "2026-02-08T16:00:00Z",
"result": { "$type": "…#numericResult", "value": 30756 }
}
Packs up to 1,440 observations for a single Datastream into one record, covering a time window (typically one day). Reduces commit overhead for high-frequency or historical data.
| Field | Type | Description |
|---|---|---|
datastream | AT-URI | → parent Datastream |
windowStart | datetime | Start of the batch window |
windowEnd | datetime | End of the batch window |
observations | array | Array of { t, result, q } entries |
Record key: {datastream-slug}:{compact-date}, e.g. sandy-mills-level-od:20260210T000000Z
Bundles multiple co-produced results from a single act of sensing. Used when measurements have no independent existence – e.g. wave statistics (Hs, Hmax, Tp, mean period, mean direction) computed from the same accelerometer burst.
| Field | Type | Description |
|---|---|---|
thing | AT-URI | → thing (not a Datastream – each entry contains its own property) |
sensor | AT-URI | → sensor that co-produced the results |
phenomenonTime | datetime | Observation timestamp |
entries | array | Up to 32 entries, each with its own observedProperty, unit, scaleFactor, and result |
Used by the Marine Institute buoy network for wave statistics.
How records reference each other
Records point to each other using AT-URIs – stable, globally unique identifiers of the form:
at://{DID or handle}/{collection NSID}/{record key}
For example, an Observation references its Datastream by AT-URI. The Datastream in turn references its Thing, Sensor, and ObservedProperty by AT-URI. You can walk the entire reference graph from any record – try it in the Records browser.
Public monitoring networks across Ireland, the UK, Europe, North America and Africa publish here, spanning water levels, flooding, tides, ocean and weather conditions, air quality and bike-share availability:
OPW Water Level
Hydrology- Source
- waterlevel.ie
- Stations
- 458 stations (all active)
- Cadence
- Every 15 minutes
- Parameters
- Water level (local gauge zero & Ordnance Datum Malin Head)
- Handle
opw.sensorthings.dev
Marine Buoys
Oceanography- Source
- ERDDAP (Marine Institute)
- Stations
- 5 buoys (M2–M6)
- Cadence
- Hourly
- Parameters
- Atmospheric pressure, air & sea temp, humidity, wind, waves (Hs, Hmax, Tp, period, direction)
- Handle
marine.sensorthings.dev
EPA Air Quality
Air Quality- Source
- airquality.ie
- Stations
- 73 monitors
- Cadence
- Hourly
- Parameters
- PM10, PM2.5, NO2, O3, SO2, CO
- Handle
epa.sensorthings.dev
Met Eireann
Weather- Source
- met.ie Open Data (CC BY 4.0)
- Stations
- ~25 synoptic stations
- Cadence
- Hourly
- Parameters
- Temperature, humidity, pressure, wind speed/direction, rainfall, present weather
- Handle
met.sensorthings.dev
EA Flood Monitoring
Flooding- Source
- Environment Agency Real Time Flood Monitoring (OGL v3)
- Stations
- ~4,864 across England
- Cadence
- Every 15 minutes
- Parameters
- Water level (stage), tidal & groundwater level, river flow, rainfall, wind speed, water temperature
- Retention
- 30-day rolling window (observationBatch)
- Handle
ea.sensorthings.dev
Irish Tide Gauges
Tides- Source
- ERDDAP (Marine Institute) – Irish National Tide Gauge Network (CC BY 4.0)
- Stations
- 24 coastal gauges
- Cadence
- Every 15 minutes
- Parameters
- Tidal level (Ordnance Datum Malin Head & Lowest Astronomical Tide)
- Handle
tides.sensorthings.dev
SEPA (Scotland)
Hydrology- Source
- SEPA Water Levels (KISTERS KiWIS API, OGL v3)
- Stations
- 639 stations
- Cadence
- Every 15 minutes
- Parameters
- River & loch level, river flow, tidal level, rainfall
- Handle
sepa.sensorthings.dev
UK Air Quality (OpenAQ)
Air Quality- Source
- OpenAQ v3 API – EEA national network & London Air Quality Network (ODC-BY / OGL)
- Stations
- ~297 monitors (currently reporting)
- Cadence
- Hourly
- Parameters
- PM10, PM2.5, NO2, O3, SO2, CO
- Handle
openaq.sensorthings.dev
European Air Quality (EEA)
Air Quality- Source
- European Environment Agency Air Quality Download Service, the up-to-date (unverified) dataset of the e-reporting network. Ireland and the UK are excluded (covered by EPA and OpenAQ above).
- Stations
- ~5,070 stations across 36 European countries
- Cadence
- Hourly
- Parameters
- SO2, NO, NO2, NOx, O3, CO, PM10, PM2.5
- Handle
airquality-eu.sensorthings.dev
Dublinbikes
Mobility- Source
- Smart Dublin dublinbikes API (JCDecaux, CC BY 4.0)
- Stations
- 114 docking stations
- Cadence
- Every 5 minutes
- Parameters
- Bikes & docks available, dock capacity, station status (renting, returning, installed)
- Handle
dublinbikes.sensorthings.dev
AirNow (US EPA)
Air Quality- Source
- US EPA AirNow bulk hourly files, courtesy of the reporting agencies and the AirNow programme. Preliminary data: not fully verified or validated, and subject to revision.
- Stations
- ~2,300 monitors across the US, Canada and Mexico
- Cadence
- Hourly, with a 24-hour correction window
- Parameters
- O3, PM2.5, PM10, NO2, SO2, CO
- Handle
airnow.sensorthings.dev
WIS2 Africa (WMO)
Weather- Source
- WMO WIS 2.0 surface synoptic observations from African national met services, consumed over anonymous MQTT from the Global Brokers. WMO core data, free and unrestricted under Resolution 1.
- Stations
- ~32 national met services (stations named by WIGOS identifier)
- Cadence
- Hourly (push-based over MQTT)
- Parameters
- Air temperature, dew point, relative humidity, station & MSL pressure, wind speed/direction, precipitation totals
- Handle
wis2africa.sensorthings.dev
AirQo
Air Quality- Source
- AirQo (CC BY 4.0 with a non-commercial rider)
- Stations
- 326 registered sites across 14 African countries
- Cadence
- Hourly (machine-learning calibrated)
- Parameters
- PM2.5, PM10
- Handle
airqo.sensorthings.dev
Consuming the Data
You don’t need this visualisation frontend to access the data. It is all publicly available on the PDS.
Browse records (REST)
List all records of a given type using the standard ATProto listRecords endpoint:
GET https://pds.sensorthings.dev/xrpc/
com.atproto.repo.listRecords
?repo=opw.sensorthings.dev
&collection=dev.sensorthings.thing
Fetch a single record with getRecord:
GET …/com.atproto.repo.getRecord
?repo=opw.sensorthings.dev
&collection=dev.sensorthings.thing
&rkey=sandy-mills
Subscribe to the firehose (WebSocket)
Receive all new commits in real time – the same mechanism this app uses:
wss://pds.sensorthings.dev/xrpc/
com.atproto.sync.subscribeRepos
Events arrive as CBOR-encoded commit frames. Filter for dev.sensorthings.* collections to process only sensor data. No authentication required.
Bulk export & analysis (Python)
Download a whole repository as a .car archive (Your data, or com.atproto.sync.getRepo) and flatten it into tidy tables with the atproto-sensorthings package: one row per station, datastream and reading, ready for pandas / geopandas. Scaled integers, the result union and geoPoint positions are decoded for you, and it works across every network’s export.
uv tool install atproto-sensorthings # or: pip install atproto-sensorthings
atproto-sensorthings summary export.car
atproto-sensorthings flatten export.car --format geojson
CSV needs only the base install; GeoJSON / GeoParquet add the [geo] / [parquet] extras. Source on GitHub.
For worked examples against the live deployment – cross-network queries, aggregation, the live feed, record verification – view the demo notebook, or download it (Jupyter, ~4 MB).
SensorThings Query API
The same data is also served through a read-only OGC SensorThings API v1.1 surface at /v1.1, so standard GIS clients (QGIS, FROST, …) can consume it directly. One observed-property type is shared across publishers, so a single query spans every network at once.
Try the queries below – each runs live against this deployment.
1. Capabilities
The service root lists the entity sets and advertises the OGC requirement classes implemented. (GET /v1.1/$metadata returns the OData entity model.)
GET /v1.1/
2. Every sensor type
The observed properties – the phenomena measured across all networks – each with a CF / NERC vocabulary definition. The same phenomenon appears once per publisher; the next query unifies them by name.
GET /v1.1/ObservedProperties
3. One type, every network
Filter the datastreams to a single type and they come back as mappable points with their latest value – from every publisher that measures it. Each result’s properties.network is the source DID, so the cross-network span is visible.
GET /v1.1/Datastreams
?$filter=ObservedProperty/name eq 'Water Level'
&$top=5
4. Aggregate over a time window
Reduce each station’s readings over a window to a single value – here the mean water level over the last 24 hours, across every network. Add &$resultFormat=GeoJSON for mappable points. The reducer can be average, sum, min, max, count, or last/first for the most/least recent value in the window. This uses OData’s $apply – an extension beyond OGC SensorThings 1.1 (not supported by FROST), offered here for aggregation.
GET /v1.1/Observations
?$apply=filter(ObservedProperty/name eq 'Water Level'
and phenomenonTime ge now() sub duration'P1D')
/groupby((Datastream/@iot.id),
aggregate(result with average as avg))
5. Summarise a type over time, across networks
Bucket every network’s readings of one type by the hour (or date(phenomenonTime) for the day) and reduce each bucket to its mean and min–max range – the cross-network band the chart’s Type summary draws. The optional (unitOfMeasurement/symbol, verticalDatum) keys split the band into comparable groups, since a cross-datum mean is meaningless. Rows come back time-ordered (no geometry, so no GeoJSON).
GET /v1.1/Observations
?$apply=filter(ObservedProperty/name eq 'Water Level'
and phenomenonTime ge now() sub duration'P7D')
/groupby((hour(phenomenonTime),
Datastream/unitOfMeasurement/symbol,verticalDatum),
aggregate(result with average as mean,
result with min as lo,
result with max as hi,$count as n))
More
The query surface also covers single-station time series, spatial (bounding-box) filters, value-range and ordering, a type-free recent-window slice, and a $resultFormat=GeoJSON output for web maps.
Live Feed
Beyond the pull API, a WebSocket at /v1.1/subscribe pushes SensorThings Observations as they land on the firehose – the same JSON /Observations returns. It is live-only (no replay): on connect you receive readings from that moment on, so catch up first through the pull API with a phenomenonTime window. A consumer that cannot keep up is disconnected (close code 1013) rather than buffered without bound.
Subscribe, optionally sliced
Connect for every new Observation, or scope the stream with the same $filter the pull /Observations accepts. The filter is validated on connect and evaluated per event, so you receive only the readings that match.
wss://sensorthings.dev/v1.1/subscribe
wss://sensorthings.dev/v1.1/subscribe?$filter=ObservedProperty/name eq 'Water Level' and result gt 3
Named feeds
A named feed is computed, not just sliced. currently-raining pushes rainfall observations with a parameters.rainfall block (raining, rate in mm/h, intensity band), and one closing message on the wet-to-dry transition so a consumer sees rain stop.
wss://sensorthings.dev/v1.1/subscribe?feed=currently-raining
Phenomenon feeds
?feed=<phenomenon> pushes one measured quantity across every network at once, regardless of each network’s naming or units – shorthand for the observed-property-definition $filter. Combining ?feed= with an explicit $filter is an error.
wss://sensorthings.dev/v1.1/subscribe?feed=rainfall
wss://sensorthings.dev/v1.1/subscribe?feed=water-level
Discover the feed names
The named feeds and every phenomenon slice, each with a label and category – the full set of values ?feed= accepts.
GET /v1.1/feeds
Verifying Records
Every record is part of a signed Merkle tree in its publisher’s repository. Anyone can therefore confirm – without trusting this app or the network it travelled over – that a given reading was published by the claimed account and has not been altered since. The Verify buttons in the Records and Time Series tabs run the checks below on the server; the scripts that follow reproduce them locally on your own machine.
What is checked
- Signing key – resolve the publisher’s DID document (via
plc.directory) to obtain its repository signing key. - Proof – fetch the record with
com.atproto.sync.getRecord, which returns a CAR file holding the signed commit, the Merkle path to the record, and the record itself. - Record hash – the record’s bytes must hash to the content identifier (CID) the tree points at, so the bytes are exactly what was committed.
- Merkle inclusion – walking the tree from the signed commit to the record’s key reaches that CID, and every node on the path hashes to the CID that led to it.
- Commit signature – the commit, re-encoded without its signature, verifies against the signing key.
A reading in the Time Series chart is one row of a daily observationBatch record, so verifying a reading verifies that batch: change any value and the batch’s hash no longer matches the proof. These checks establish authenticity, integrity and inclusion. They do not assert that a sensor’s value is physically correct (the operator vouches for that), and they verify the repository’s current state at the time of the check.
Verify it yourself: Python
# pip install atproto
import hashlib, httpx, libipld
from atproto_core.did_doc import DidDocument
from atproto_crypto.verify import verify_signature
did = "did:plc:fiahvmd5lfxjjvfvefxfz5ac"
coll = "dev.sensorthings.thing"
rkey = "epa-92"
pds = "https://pds.sensorthings.dev"
# 1. Signing key from the DID document
doc = httpx.get(f"https://plc.directory/{did}").json()
key = DidDocument.from_dict(doc).get_did_key()
# 2. Proof CAR: signed commit + Merkle path + record
car = httpx.get(f"{pds}/xrpc/com.atproto.sync.getRecord",
params={"did": did, "collection": coll,
"rkey": rkey}).content
header, blocks = libipld.decode_car(car)
commit = blocks[header["roots"][0]]
# 3. Commit signature over the commit without its sig
unsigned = {k: v for k, v in commit.items() if k != "sig"}
assert verify_signature(
key, libipld.encode_dag_cbor(unsigned), commit["sig"])
def cid(block):
h = hashlib.sha256(libipld.encode_dag_cbor(block)).digest()
return b"\x01\x71\x12\x20" + h
# 4. Walk the tree to the record, checking each node's hash
def find(node_cid, target, prev=b""):
node = blocks[node_cid]
assert cid(node) == node_cid
sub = node["l"]
for e in node["e"]:
k = prev[:e["p"]] + e["k"]
prev = k
if target == k:
return e["v"]
if target < k:
return find(sub, target) if sub else None
sub = e["t"]
return find(sub, target) if sub else None
value_cid = find(commit["data"], f"{coll}/{rkey}".encode())
assert value_cid and cid(blocks[value_cid]) == value_cid
print("verified", coll, rkey)
Verify it yourself: JavaScript (Node)
// npm i @atproto/repo
import { verifyRecords } from "@atproto/repo";
const did = "did:plc:fiahvmd5lfxjjvfvefxfz5ac";
const coll = "dev.sensorthings.thing";
const rkey = "epa-92";
const pds = "https://pds.sensorthings.dev";
// Signing key from the DID document
const doc = await (
await fetch(`https://plc.directory/${did}`)
).json();
const vm = doc.verificationMethod.find(
(m) => m.id.endsWith("#atproto"));
const didKey = `did:key:${vm.publicKeyMultibase}`;
// Proof CAR from getRecord
const url = `${pds}/xrpc/com.atproto.sync.getRecord`
+ `?did=${did}&collection=${coll}&rkey=${rkey}`;
const car = new Uint8Array(
await (await fetch(url)).arrayBuffer());
// Verifies Merkle inclusion AND the commit signature
// against the DID's key; throws if either fails,
// otherwise returns the records in the proof.
const [rec] = await verifyRecords(car, did, didKey);
console.log("verified", rec.collection, rec.rkey);