Click a point to verify or copy a link to its record
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

StationNetworkLast 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 Location (embedded) Sensor ObservedProperty Datastream central join Observation ObservationBatch MultiObservation refs Thing + Sensor FeatureOfInterest optional AT-URI reference optional / indirect key entity
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.*.

FieldTypeDescription
namestringHuman-readable station name
descriptionstringOptional longer description
locationgeoPointWGS84 coordinates as latE7/lonE7 (integer × 107, ~1 cm precision)
propertiesobjectOpen metadata (manufacturer, deployment date, etc.)

Record key: human-readable slug, e.g. sandy-mills

at://opw.sensorthings.dev/dev.sensorthings.thing/sandy-mills

FieldTypeDescription
namestringInstrument or procedure name
encodingTypestringIANA media type of the metadata field
metadatastringURL to datasheet or SensorML document

Record key: slug, e.g. sandy-mills-level

at://opw.sensorthings.dev/dev.sensorthings.sensor/sandy-mills-level

FieldTypeDescription
namestringProperty name (e.g. “Water Level”)
definitionURIControlled 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.

FieldTypeDescription
thingAT-URIdev.sensorthings.thing
sensorAT-URIdev.sensorthings.sensor
observedPropertyAT-URIdev.sensorthings.observedProperty
unitOfMeasurementobjectname, symbol, definition (QUDT/UCUM URI)
resultScaleFactorintegerDivides integer results: real = value × 10−n
verticalDatumstringFor 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"

FieldTypeDescription
datastreamAT-URI→ parent Datastream (carries unit & scale)
phenomenonTimedatetimeWhen the phenomenon was observed (ISO 8601)
resultunionDiscriminated by $type – see below
resultQualitytokengood, suspect, or missing
Result types
numericResult categoryResult booleanResult arrayResult geoPointResult

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.

FieldTypeDescription
datastreamAT-URI→ parent Datastream
windowStartdatetimeStart of the batch window
windowEnddatetimeEnd of the batch window
observationsarrayArray 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.

FieldTypeDescription
thingAT-URIthing (not a Datastream – each entry carries its own property)
sensorAT-URIsensor that co-produced the results
phenomenonTimedatetimeObservation timestamp
entriesarrayUp 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

OGC SensorThings API OGC SensorThings 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
  1. Signing key – resolve the publisher’s DID document (via plc.directory) to obtain its repository signing key.
  2. 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.
  3. 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.
  4. 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.
  5. 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);