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 in.
- 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
SensorThings on ATProto
Environmental sensor data from Irish, UK and European monitoring networks, published as structured, cryptographically signed records on the AT Protocol.
What & Why
This application visualises environmental sensor data – water levels, flooding, ocean conditions, air quality – that 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 in a Merkle tree. The data is publicly addressable, verifiable, and replicable by anyone who subscribes to the PDS firehose, without an API key.
Why ATProto for sensor data?
- Signed & content-addressed
- Every commit is signed by the publisher’s key pair. Consumers can verify data integrity without trusting the transport.
- Portable identity
- Each data source has a DID (Decentralised Identifier) that survives server migration. If the PDS moves, all references remain valid.
- Replication by default
- Any firehose subscriber automatically receives and can mirror the full dataset. No backup infrastructure is required.
- Schema as contract
- The
dev.sensorthings.*Lexicons define the data model. Any PDS publishing conformant records participates in the federation.
Entity Model
The data follows 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. Carries 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 pivotal entity: links one Thing + one Sensor + one ObservedProperty. Carries 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 Protocol Buffers and financial systems.
| 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 carries 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 (carries 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 carries 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.
Networks
Nine environmental monitoring networks across Ireland, the UK and the rest of Europe are currently publishing:
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
IOSB Air Quality (EEA)
Air Quality- Source
- Fraunhofer IOSB FROST demonstrator of the EEA e-reporting network. Ireland and the UK are excluded (covered by EPA and OpenAQ above).
- Stations
- ~3,600 stations across 37 European countries
- Cadence
- Hourly
- Parameters
- SO2, NO, NO2, NOx, O3, CO, CO2, PM10, PM2.5
- Handle
airquality-eu.sensorthings.dev
Consuming the Data
You don’t need this visualisation app 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.
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 on your own machine.
What gets 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);