This is the 2026 Geonovum testbed sensordata report. It contains the results of Geonovum's investigation into OGC sensordata standards. This was done with market participation. Using a public tender process, five parties were selected to perform 6 implementation topics:
Fraunhofer IOSB: Hosting a SensorThings API server
Collaborall: Hosting a SensorThings API server, and Connecting sensors to a SensorThings API server
Clappform: Connecting sensors to a SensorThings API server
Geo Insights: Connecting sensors to a SensorThings API server
TUDelft: Connecting sensors to a SensorThings API server
This report contains their detailed findings.
Geonovum's conclusion is that the SensorThings API is a strong candidate for a
mandatory standard, but not on its own. The testbed showed that the entity model
is easy to implement, including in domains it had never been applied to before,
and that it delivers interoperability in practice: one participant connected to
another's server in a matter of days, with no documentation, no schema and no
contact with the other party's engineers. Conforming to the standard turned out
to be by far the cheapest part of the work, because harmonisation only has to be
solved once, at the point of ingestion.
What the standard does not give you is shared meaning. Two conforming
implementations in the same domain can still fail to understand each other —
the testbed found four different definitions of "CO₂ level" on a single valid
server. Closing that gap requires a national profile that pins down vocabularies
and units, conventions for how domains are modelled, a centralised
interoperability test suite, and best practices for implementation as well as
governance. The implementation practices matter because the most damaging
failures found here were silent ones: a dead sensor that is invisible rather
than stale, and a poll cursor that discarded every real measurement while the
stream still looked healthy. The governance side matters because onboarding
sensor networks took more calendar time than writing code. Of the two versions,
Geonovum regards 2.0 as the better candidate to mandate: it resolves a
significant number of the issues found here, and the step up from 1.1 is small
enough not to burden implementers.
Status van dit document
Dit is de definitieve versie van dit document. Wijzigingen naar aanleiding van consultaties zijn doorgevoerd.
1. Introduction
This final report presents the results of the Geonovum sensor data testbed focused on the implementation and adoption of the OGC SensorThings API standard. The testbed was initiated in response to the growing importance of sensor data in the Dutch public sector, where smart cities, citizen science initiatives, environmental monitoring, and digital twin applications are generating increasing volumes of sensor observations. As these data sources become more diverse and more widely used, the need for a uniform and interoperable approach to publishing, accessing, and reusing sensor data has become more urgent.
The goal of the testbed was to stimulate the practical adoption of the OGC SensorThings API standard and to better understand the main obstacles to broader implementation. By working with market parties and public-sector use cases, Geonovum aimed to gather practical insights into how sensor data can be made more interoperable, reusable, and easier to integrate into applications and infrastructures. The longer-term ambition is to support the further development of the Dutch public sector spatial data infrastructure and to assess whether stronger standardization of sensor data exchange is desirable.
The scope of the testbed focused on the implementation of the OGC SensorThings API standard in a realistic context. A central use case was provided to support this work: the wastewater cleaning digital twin of Water Authority Brabantse Delta. In this use case, a physical test setup and existing digital infrastructure are used to support real-time process control, monitoring, and optimization of wastewater treatment. The setup includes several sensors, such as pressure and flow sensors, as well as pump-related measurements. The use case demonstrates how sensor data can support operational optimization, load balancing, predictive maintenance, and analysis in a digital twin environment.
To explore this scope, the testbed was organized around two implementation topics.
The first implementation topic focused on hosting a SensorThings API server. The goal of this topic was to establish a central server within the testbed where sensor data could be collected, stored, and made available for querying and analysis. This required the implementation of a data model capable of accommodating all participating sensors, based on the Observations, Measurements and Sampling (OMS) standard. Participants working on this topic were asked to configure and host a SensorThings API server, document the modelling and implementation approach, publicly demonstrate relevant queries, and record lessons learned. The intention was not only to create a working server, but also to generate reusable knowledge on how such an implementation can be set up generically for other use cases.
The second implementation topic focused on connecting sensors to the SensorThings API server. The goal here was to demonstrate how existing sensors can be adapted or connected so that their observations can be sent to a central SensorThings API endpoint. This topic emphasized interoperability in practice: participants were free to choose their own technical approach, such as direct connections from sensors or the use of intermediary translation layers. The tasks included connecting one or more sensors, validating the reliability of the solution, documenting a reproducible approach, publicly demonstrating the implementation, and capturing lessons learned. This topic was designed to provide insight into the practical effort and technical choices involved in exposing sensor data through a common standard.
Together, these two implementation topics addressed both sides of the interoperability challenge: the establishment of a standards-based central data service and the integration of heterogeneous real-world sensors into that service. This report brings together the findings, implementations, and lessons learned from the testbed, with the aim of informing future adoption and further development of interoperable sensor data infrastructures in the Dutch public sector.
1.1 Reading guide
The rest of this document contains the results of all three research topics, written by the participants. It ends with the Conclusion chapter, which describes the main findings and the way forward as perceived by Geonovum.
2. Topic 1 Fraunhofer: Hosting a SensorThings API server
Dit onderdeel is niet-normatief.
2.1 The OGC SensorThings API
The OGC SensorThings API is an implementation Data Model of OMS, combined with a Request/Response and a Publish/Subscribe API.
It is designed to enable the management and sharing of sensor data, allowing users to approach the data in the way that fits their use case.
The API consists of a data model based on the OGC/ISO Observations and Measurements conceptual model, a REST-over-HTTP part for managing the data and accessing it using requests, and an MQTT part that enables push-notifications when data changes.
2.2 FROST-Server
FROST-Server is the reference implementation of the SensorThings API.
It is licensed using the LGPL 3.0 license and implements all parts of the spec.
It is a Java-EE application and has Docker images available for easy installation.
2.2.1 Deploying FROST-Server using Docker Compose
For this testbed a FROST-Server instance was deployed on the cloud environment of Brabandse Delta.
This cloud environment uses Portainer to manage a Docker environment, and uses NGINX as a reverse proxy to route HTTP requests to the correct docker container.
When running in a container environment, FROST-Server consists of separate containers for the HTTP and MQTT parts of the API.
These containers have to state and can be duplicated as required to satisfy the load put on the system.
The separate FROST-Server containers communicate with each other using a message bus, so that when an update is made through one container, the other containers are notified so they can send push notifications to any users that have subscriptions on the changed entities.
The data that is managed by the system is stored on a PostgreSQL database server with PostGIS extensions.
This database server writes the data to a persistent volume, which is the only part of the system that is persitent over restarts.
In a docker environment the deployment of a composite system with sub-systems is configured in a docker-compose.yaml file.
A basic docker-compose file consists of a name, a set of services and a set of persistent volumes.
The name is used as a prefix to name containers.
The volumes are the file systems that persist across restarts of the services.
The services specify which containers are generated, and which settings are used for these containers.
The basic structure of a docker-compose file for FROST-Server is shown in the following example:
Each service lists an image that is used as a basis for the containers of that service, the ports that are exposed to the outside, the persistent volumed mapped into the containers and environment variables.
A ports definition is only needed for services that need to be directly accessible from outside of the cluster.
All services in the same docker-compose file can access each other directly using the service name.
Therefore, the only service that needs a portmapping is our NGINX service.
The configuration of NGINX must be done using a confguration file, that is placed next to the docker-compose.yaml file and mapped into the appropriate place in the container, using the read-only (:ro) mode to ensure it can not be changed.
All configuration of FROST-Server is done using environment variables, listed in the environment section of the service.
There are many settings, listed in the documentation.
FROST-Server lists each configuration setting in its log files and on the console, when it first uses a setting.
The environment variables can use placeholders that in turn are defined in a separate .env file.
This is useful for values that appear multiple times in the file, or for passwords or other secrets that should not appear as literals in the file.
The bus_mqttBroker and persistence_db_url variables reference other services in the same docker-compose file by simply using the names of these services.
The entire set of services defined in the docker-compose file can be started with the single command:
docker compose -f docker-compose.yaml up
To stop all services in one go, the following command can be used:
docker compose -f docker-compose.yaml down
2.2.2 Authentication & Authorisation
When no authentication module is enabled, anyone can create, edit and delete all entities on the server.
Usually this is not desired, and thus an auth* provider should be enabled.
FROST-Server comes with two auth providers out of the box: Basic Auth and KeyCloak auth.
The Basic Auth provider uses internal user and userrole tables, that can be in the same database as the other data, or in a different database. A user uses the HTTP Basic Auth method to authenticate directly to FROST.
Users have to be added directly in the database using SQL queries.
The KeyCloak Auth provider uses an external authentication server to handle user authentication using OpenID-Connect.
To enable basic auth, the following set of enviroment variables can be added to both the frosthttp and frostmqtt services in the docker-compose file:
The MQTT API is availale over WebSockets at: wss://sta.wbd-rd.nl/mqtt
2.3 The SensorThings API Data model and extensions
The SensorThings API version 1.1 defines a core data model that is based on O&M version 2.
This core data model can easily be extended with additional classes and attributes.
2.3.1 Core Data Model
The core data model of version 1.1 of the SensorThings API consists of 9 classes, 8 relations between those classes.
The following diagram shows the UML representation of the data model:
A detailed description of each class and its properties and relations can be found in the specification: OGC 18-088.
2.3.2 Extension: Tasking
The standard tasking extension OGC 17-079r1 can be used to model Actuators, their tasking capabilities, and the task send to the actuators.
The extension adds three extra classes to the SensorThings data model, shown in the figure below in orange.
Figuur 3SensorThings API Tasking data model extension.
In FROST-Server the tasking data model extension can be enabled using the environment variable plugins_actuation_enable=true.
2.3.3 Extension: OpenCitySense
To make the complexities of sensor management more manageable, Fraunhofer IOSB started an internal research project to design the concept for a sensor management system, based on the OGC SensorThings API, and create an implementation of this system: OpenCitySense.
The main reason that sensor management is complex is that there are many different types of sensors, from many different vendors, using many different communication protocols.
The result is that a sensor management system consists of many different sub-systems, and these sub-systems all store a part of the management data of each sensor that they (partially) manage.
The management data of each sensor is thus spread out over many systems, and that makes coordination of this data and control over these subsystems problematic.
Since the SensorThings API version 1.1 is extendible by design, and already comes with several extensions, the architecture can be greatly simplified by using the SensorThings API service as the central data store for all sensor related data (figure below).
This means that all components communicate through a single service, reducing the number of interconnects between components and reducing the spread of primary information across components.
The standard tasking extension can be used to coordinate management actions between components, such as signalling to a connector that a sensor needs to be on-boarded, that a configuration needs to be changed, or that a sensor needs to be off-boarded.
The connector concept can then be extended to not just be a one-way ETL process, but to take an active role in the sensor registration process on the LoRaWAN stack.
It can receive information about new or updated sensors from the SensorThings service, and automatically take all required registration actions in the communication infrastructure.
Besides greatly simplifying the architecture, a second major advantage to using the SensorThing API service for all data storage is that it offers a consistent, powerful API for managing relational data.
This makes all data relevant for managing sensors and their data available in a unified, consistent way, and management tools or other clients do not need to implement multiple APIs and access data in multiple services.
While all publicly relevant sensor data and metadata can be stored in the core data model of the SensorThings API, internal management data can be stored in a custom data model extension.
Since this does not alter the core data model of the SensorThings API, clients implementing only the Sensing part will not be affected by this data model extension.
Sensor configuration parameters are modelled using JSON-Schema, and translated by the connector into a form that the sensor understands.
This means that regardless of sensor brand or type, the management GUI can offer a consistent interface for changing sensor settings.
2.3.3.1 Data Model
To allow the representation of device management information, a data model extension has been designed for the data models of the SensorThings API and the tasking extension.
The extended data mode is depicted in the following image.
Connectors and Devices are modelled as Things.
To make it easier to distinguish between different types of Things, a "type" field has been added to the Thing entity type that indicates the type of the thing.
Things of type "Connector" are linked to the Things of the devices they manage, through the ControlledDevices <-> ControllingConnector relation.
This makes it easy to find all the devices controlled by a certain connector, and to find the connector controlling a certain device.
Each Thing can have a DeviceModel, describing the capabilities of the Device or Connector.
A DeviceModel contains the schema for the Configurations of devices of this model, and can link to a Configuration that is the template or default configuration of devices of this model.
DeviceModels can link to a Decoder that can be used to decode and encode data coming from and sent to devices of this model.
DeviceModels link to Sensors that describe the Sensors that a device of the model has.
In turn, Sensors link to the ObservedProperties that a Sensor of this type observes.
Using these two links, a Connector knows which Datastreams to create and which Sensor and ObservedProperty to link, when onboarding a Device.
DeviceModels link to the DeviceModels of the Connectors that they are compatible with.
This allows a user interface to find the DeviceModels that work on a chosen Connector, and allows the Connector to specify additional configuration options it requires on a Device and a DeviceModel.
Configurations describe how a device can be, was or is configured.
The schema for the config is stored in the DeviceModel of the device.
The status field of a configuration indicates the current status of a sensor (Created, Active, Inactive, Removed) or if the Configuration is a Template.
Configurations have a time field that indicates when this configuration became active.
If a device has multiple configurations there must be only one configuration with status "Active".
The other configurations are historical Configurations or templates.
To allow the secure storage of passwords or API keys, the DeviceSecret class was added to the data model.
The secrets can be secured, both by only giving certain users read-access to these device secrets, and by encrypting the values of the device secrets.
To allow Encryption, a connector has a public/private key pair.
The private key of a connector is not stored in the SensorThings data model, but directly passed to the connector, usually using an environment variable.
The public key of the connector is available in the SensorThings data model and can be used by clients to encrypt passwords before storing them in a DeviceSecret entity.
This way only the connector can decrypt these secrets.
2.3.3.2 Configuration Definitions
A device generally has two types op parameters: Fixed ones that can not be changed during the operation of the device, and dynamic ones that can be changed.
The dynamic parameters are independent of the Connector used to manage the device.
These parameters are specified in the section configuration of the configDefinition attribute of the DeviceModel, and the values for these are stored in the config attribute of the active Configuration entity linked to the device.
The static parameters are (can be) connector-specific.
They are stored in the properties of the device (Thing), as secret linked to the device or in the properties of the DeviceModel.
The definition of these parameters is stored in section configuration of the configDefinition attribute of the DeviceModel of the connector.
The configDefinition of a DeviceModel may thus have three sections:
configDefinition/configuration: Definitions of fields that can be added to te Configuration of devices of this DeviceModel.
These fields should be shown when creating a new device, or when changing the configuration of a device.
configDefinition/device: Definitions of fields that the connector requires in the properties or a DeviceSecret of a Thing that it manages.
These should be shown when creating a new device.
configDefinition/deviceModel: Definitions of fields that the connector requires in the properties of a DeviceModel of devices that it manages.
These should be shown when creating a new DeviceModel, or when linking an existing DeviceModel to the DeviceModel of a Connector.
Using these schema definitions, a user interface can generate all the forms required for onboarding and managing both connectors and devices.
2.3.3.3 Onboarding Workflow
From the point of view of the User Interface the workflow for onboarding a sensor is as follows:
Assuming a suitable DeviceModel already exists for the device to be onboarded, the user interface only needs to create a Thing for the device and then create a Task for the connector to onboard the device.
Most of the work is done by the Connector, as can be seen in the workflow focusing on what the Connector does after the onboarding Task is created:
2.3.4 Extension: Projects
Security is an important aspect of any API, especially when the API is use for both writing and reading.
In many cases, a simple all-or-nothing approach is sufficient, but when multiple groups of users use the same service, yet should not be able to change, or even read, each others data, a more fine-grained approach to access control is required.
FROST-Server comes with a highly-configurable fine-grained access control engine, but setting up the access-control rules can be a daunting task.
To help with setting up access rules, the Projects extension provides a data-model extension and a set of access-control rules that are sufficient for most use cases.
The finer details of the Projects extension can be found in its documentation.
2.3.4.1 Data Model
The image below shows the core STA data model in blue, with the security extension in yellow.
To be able to take user information into account when determining which actions a user can do on individual entities, the user and its roles need to be connected to the data model.
The Projects extension does this by introducing a Project entity type.
Users can have certain Roles in one or more Projects.
The other STA entities can also be connected to one or more Projects, either directly (such as Thing) or indirectly (such as Datasteam and Observation).
2.3.4.2 Access rights
User-entities can be directly linked to Role-entities, or indirectly through UserProjectRole-entities.
If a user is directly linked to a role, the user has that role on all entities of all types.
If a user is indirectly linked to a role, the user has that role only on the entities linked to the project linked to the same UserProjectRole entity.
Projects can be public or non-public.
Public projects, and their related entities, can be read by users that are not related to the project.
Non-public projects can only be read by users that have a direct role in the project.
Things, Locations, Datastreams and FeaturesOfInterest have an additional restricted flag.
If one of these entities is set to be restricted, that entity is not visible to users that are not in the project(s) that the entity is related to, even if (any of) those projects is public.
ObservedProperty entites are shared across Projects and can thus only be edited by users with global create, update or delete rights.
2.4 The SensorThings API version 2 data model
The SensorThings API version 2.0 is currently under voting at the Open Geospatial Consortium.
The data model of this new version has been updated in several ways, based on experience of almost ten years of use since the original SensorThings API version 1.0 was released in 2016.
An updated data model diagram can be found in Figuur 8, which highlights the changes.
In STA version 1.x, the ObservedProperty has an attribute called definition that is meant to point to an external, authoritative source for the ObservedProperty.
This use case, where an entity has an external, authoritative definition exists not just for ObservedProperties, but for almost all entities.
Therefore, in STA 2.0, almost all classes have a definition attribute.
2.4.2 Features, FeatureTypes and ofInterest
One of the most asked questions people have when starting to use STA is: "What should I use as Thing, and what as Feature?"
Unfortunately, for STA 1.x, there was only the pragmatic answer: "The Thing has to be that which you want to attach a time-series to."
This is because a Thing is linked to time series (Datastreams) and FeaturesOfInterest are not.
This limitation breaks the usefulness of the Feature, and made it impossible to properly use the FeatureOfInterest in the way it was intended in the conceptual O&M Model.
In the update of the OGC/ISO Observations and Measurements specification, now called Observations Measurements and Samples (OMS) the concept of Feature and its roles in relation to Observations has be clarified further.
A Feature can have multiple roles when relating to Observations:
UltimateFeatureOfInterest: The largest scale Feature that is represented by an Observation.
ProximateFeatureOfInterest: A smaller, more precise proximate for the ultimate Feature.
An example from the water domain would be that of a River, that has sampling points, where water samples are taken, on which measurements are made_
The River is the Ultimate Feature of Interest.
All measurements made on all samples taken at all sampling points ultimately say something about the water in the river.
The sampling points are Proximate Featurs of Interest.
They are a proxy for the river with a higher spatial detail.
The samples are Proximate Featurs of Interest for the sampling points.
They have a higher temporal detail and can capture processing steps made to the water before Observations are made.
The river, sampling points and samples are just Features.
They only become "ofInterest" in the context of one or more Observations.
This more detailed model has been taken over in STA 2.0 as depicted in Figuur 9:
Features are now just named Feature, and no longer FeatureOfInterest.
Features can now be directly linked to Datastreams, in the roles of Ultimate or ProximateFeatureOfInterest, and to Observations in the role of ProximateFeatureOfInterest.
The relation between Observation and Feature is now optional, since it is only relevant when the Observation was made on a sample.
Features now have one or more FeatureTypes, which makes it possible to quicky find all Features of the same type..
2.4.3 Observation Result Type
The result attribute of an Observation in STA has always been of type ANY, meaning its value can be anything.
A major problem with this is that STA version 1.x has no way to indicate what a client can expect.
It could be a number, a string, an array, or a complex json object.
In version 2.0 this has been improved as depicted in Figuur 10:
Figuur 10ResultType updates in SensorThings API v2.0.
The Datastream now has two fields, resultType and resultEncoding that use the OGC SWE-Common standard to describe the exact schema of the values of the result attribute of the Observations in a Datastream.
Because a complex Observation can involve multiple ObservedProperties, a Datastream can now be linked to multiple ObservedProperties.
The draft specification has examples of Datastreams with simple and complex resultTypes.
This change makes the MultiDatastream extension of STA 1.x obsolete, since the MultiDatastream structure can also be described by the resultType attribute.
2.5 Conclusions
Setting up a FROST-Server with the core SensorThings API data model is trivial.
The hard part is tuning the server to fit the desired use case, since there are many extension options for the data model, and ways to deal with authentication and authorisation.
Testbeds, such as this one, are a good way to gather requirements that are not directly obvious.
Also, the SensorThings API standard is a rather large and complex standard, since the domain of observational data publishing itself is quite complex.
A testbed is a good way for people to experiment, discover limitations, and discuss solutions that others may already have found.
During the testbed many questions were asked and answered.
In quite a few cases (#3, #12, #14) the underlying problem of the question was already recognised by the standards working group, and fixed in version 2.0 of the SensorThings API that is currently under vote, or in a proposed extension.
3. Topic 1 Collaborall: Hosting a SensorThings API server
Dit onderdeel is niet-normatief.
3.1 Content
This chapter describes the work Collaborall carried out for implementation
topic #1 of the Geonovum Testbed Sensordata 2026: hosting a central OGC
SensorThings API server. Part of the work is a from-scratch open-source
server implementation, which is available on:
In summary: Collaborall built and hosts a clean-room OGC SensorThings
API v1.1 server — written directly against the OGC 15-078r6
specification as a PHP / Laravel package on PostgreSQL with TimescaleDB
and PostGIS — rather than deploying an existing implementation. The
server ran throughout the testbed, ingested roughly sixteen million
observations from real and simulated sensors during development, was
exercised by other testbed participants against their own tooling, and
surfaced a series of interoperability findings that only cross-testing
between independent implementations of the standard can reveal.
3.2 Scope
Many public-sector organisations in the Netherlands operate sensors
whose data ends up in vendor-specific silos. The testbed investigates
whether the OGC SensorThings API can serve as the single, standardised
interface for publishing and consuming sensor data. Topic 1 asks a
participant to host a central SensorThings API server that all testbed
sensors can publish to and that any standards-aware client can query.
3.3 Goal
Host a server implementing the latest OGC SensorThings API (v1.1).
Offer an OMS-based data model that accommodates all sensors used in
the testbed, regardless of vendor or protocol.
Make the deployment reproducible by third parties.
Demonstrate use-case relevant queries against the live server.
Record lessons learned about the standard itself.
3.4 Approach
We built and host our own SensorThings API server — a PHP / Laravel
package on PostgreSQL with TimescaleDB (observations hypertable) and
PostGIS (spatial queries) — rather than deploying an existing
implementation. This was a deliberate choice: the testbed's "maximal
learning" objective is served better by an additional, independent
implementation of the standard than by another FROST deployment, and it
surfaced a series of interoperability findings that a
single-implementation ecosystem would never encounter (see Lessons
learned).
The deployment consists of three components:
sta-server — a composer-installable Laravel 12 package
implementing the STA v1.1 core, a minimal Tasking subset
(OGC 17-079r1), and the RQ3 / OpenCitySense device-management
extension.
Host application — a thin Laravel host with an opinionated
PHP-FPM + nginx Docker layout; PostgreSQL 16 with the TimescaleDB and
PostGIS extensions.
Admin/demo portal — a Vue 3 single-page application for device
onboarding, data browsing, and server management, speaking plain STA
REST to the server.
3.4.1 Data model (OMS)
The STA v1.1 entity model is itself an encoding of OGC
Observations, Measurements and Samples: Things, Sensors,
ObservedProperties, Datastreams, Observations and FeaturesOfInterest map
one-to-one onto OMS concepts. The server implements this model with a
few points worth noting:
Observation types — Datastreams carry their OMS observationType
(OM_Measurement, OM_TruthObservation, OM_CountObservation, …)
as OGC-defined URIs, and observation result values are validated
against the declared type.
Unitless streams — for Datastreams without a unit of measurement
(e.g. OM_TruthObservation for passive-infrared detectors) the
unitOfMeasurement sub-keys are null, exactly as the specification
mandates. Our validation was initially stricter than the standard
here; see Lessons learned.
FeatureOfInterest resolution — when an Observation arrives
without an explicit FeatureOfInterest, the server auto-resolves it
from the Thing's Location per the specification.
Time-series storage — Observations live in a TimescaleDB
hypertable partitioned on phenomenonTime, which keeps large
time-range queries fast without deviating from the STA query
interface. phenomenonTime supports both instants and ISO 8601
intervals.
3.4.2 A note on the object-context ambition
Our proposal envisioned linking the OMS FeatureOfInterest to a
hierarchical System Breakdown Structure with IFC-based object
definitions managed in ANT CDE. During the testbed we deliberately
shifted that depth toward the ingest side: full MQTT support (both
server-side publish and connector subscriptions) and a multi-protocol
connector programme (HTTP, MQTT, Modbus, LoRaWAN via KPN Things) —
capabilities the tender lists as optional but which proved to be where
the interoperability lessons are. The ANT platform is capable of the
sensor-to-object mapping — the path would link each FeatureOfInterest
to an object node in the ANT CDE breakdown structure, so observations
inherit the asset context (which pump, which bridge deck) of the object
they measure — but we have not built or tested that path within the
testbed, and we report it here as future work rather than a delivered
feature.
3.5 Implementation
The server implements the eight STA v1.1 core entity sets (Things,
Locations, HistoricalLocations, Sensors, ObservedProperties,
Datastreams, Observations, FeaturesOfInterest) with full CRUD, deep
resource paths (/Datastreams(1)/Observations, including POST via
navigation paths) and address-to-associated-entity navigation. OData
query options are supported on every collection: $filter (comparison,
logical, arithmetic, string and spatial functions, and phenomenonTime
interval literals), $expand with nested $select / $orderby /
$count, $select, $orderby, $top / $skip / $count. Errors are
returned in OGC error envelopes.
On top of the core the server ships:
MQTT publish — entity changes and Task creation are published on
the STA MQTT extension topic shapes (v1.1/Tasks/{capabilityId}), so
connectors react without polling.
Tasking subset (OGC 17-079r1) — TaskingCapabilities and Tasks.
RQ3 / OpenCitySense device-management extension — DeviceModels,
Configurations, DeviceSecrets (stored encrypted; the server holds
ciphertext only) and the ControllingConnector / ControlledDevices
relation on Things, following the OpenCitySense naming one-to-one so
interoperability with FROST-PLUS deployments stays straightforward.
Authentication — Laravel Sanctum bearer tokens with sta.read /
sta.write abilities checked on every route.
Built-in conformance runner — asynchronous self-test runs
triggerable over HTTP (/v1.1/conformance/runs), used during
development to track spec coverage.
Quality evidence: the package carries 925+ Pest tests running against a
real PostgreSQL + TimescaleDB + PostGIS instance (no mocks), static
analysis at PHPStan level max, and formatting/refactoring gates in CI.
Time series — last observations of a datastream, newest first:
GET /v1.1/Datastreams(1)/Observations?$orderby=phenomenonTime desc&$top=100
Threshold — observations above a limit in a time window:
GET /v1.1/Observations?$filter=result gt 20 and phenomenonTime ge 2026-07-01T00:00:00Z&$orderby=phenomenonTime desc
Cross-sensor — all datastreams with their sensor and observed
property in one call:
GET /v1.1/Datastreams?$expand=Sensor($select=name),ObservedProperty($select=name,definition)
Spatial — things located within an area:
GET /v1.1/Locations?$filter=st_within(location, geography'POLYGON ((4.3 51.9, 4.5 51.9, 4.5 52.1, 4.3 52.1, 4.3 51.9))')
Navigation — a thing with its datastreams and latest observation:
GET /v1.1/Things?$expand=Datastreams($expand=Observations($orderby=phenomenonTime desc;$top=1))
Interoperability with other implementations was validated with the
other testbed connectors and participants publishing to and querying
this server, as agreed with Geonovum; the findings are publicly
documented in
discussion #10.
3.7 Reproducibility
The server is a standard composer package: composer require collaborall/testbed-sensordata-server, publish the config, run the
migrations and php artisan sta:install. A self-contained development
environment boots with a single docker compose up -d (PostgreSQL 16 +
TimescaleDB + PostGIS). The repository README documents the full
quick-start, configuration surface and auth bootstrap. A clean-room
installation from the public documentation alone was executed
successfully as part of the delivery.
3.8 Lessons learned
Detailed engineering notes live in the repository
(docs/lessons-learned.md, ADRs). Reported here are our observations
on the standard itself, from the perspective of a party that wrote
an independent implementation of it from the specification:
The standard has no notion of origin or ownership. A Thing is
only a name, a description and a free-form properties map; nothing in
the protocol records which connector or party produced a Datastream
or Observation. For an operator managing many servers and connectors
at once, "where did this data come from?" is not answerable inside
the standard — devices and datastreams can exist as untraceable
orphans. The RQ3 / OpenCitySense ControllingConnector relation
fills the gap, but as an optional extension: enforcing it would make
a server reject spec-conformant entities. We consider provenance a
first-class concern the standard should address. Raised and discussed
with the testbed community in
discussion #16.
The spec constrains objects, not sub-keys — implementers
over-validate. OGC 18-088 makes unitOfMeasurement mandatory as an
object but attaches no cardinality to its sub-keys, and explicitly
requires null values for unitless streams. Our initial validation
required all three sub-keys as strings — stricter than the standard
and a violation of the SHALL for truth-observation streams. The
general rule we distilled: enforce what the multiplicity column and
SHALL/SHOULD notes state; do not invent presence requirements the
standard leaves open.
Value lists are open, not closed enums. The observationType
URIs read like an enumeration but are not one; FROST accepts informal
values where we initially required exact OGC URIs. Where the spec is
silent, matching the de-facto behaviour of the reference
implementation maximises interoperability — which also means the
reference implementation's behaviour has quietly become part of the
standard.
Independent implementations are what make it a standard. The
SensorThings API has had several implementations over the years —
FROST, 52°North's sensorweb-server-sta, GOST, and closed-source
offerings from CubeWerx and SensorUp among them — so ours is by no
means the second implementation of the standard as such. Within this
testbed, however, FROST was the only server in use, and cross-testing
our server against participants' FROST-based tooling surfaced corner
cases a single-implementation ecosystem never hits: nested $select with
@iot.id, string-function argument order (substringof),
phenomenonTime interval literals in $filter, POST via navigation
paths, and entity discovery from the capabilities document. All were
reported, discussed and fixed in the open in
discussion #10.
3.9 Availability and licensing
The server, portal and data remain publicly available until at least
January 2027. The server implementation is open source under
Apache 2.0; this report and all documentation deliverables are
published under CC-BY 4.0. The ANT demo environment is provisioned
for testbed participants until the end of 2026.
Demo environment for participants (12 named users)
Yes
Provisioned until end 2026
Synergy: bi-weekly meetings, open sessions, closing event
Yes
Bi-weekly meetings attended (11:00 on 22-05, 05-06, 19-06, 03-07, 17-07 and 31-07-2026)
Dissemination: Geonovum repo report, DMI PDX, Best Practice Explorer, blog/LinkedIn
Partially at delivery
This report; invitation to the final public session is sent to participants in August 2026
4. Topic 2 Collaborall: Connecting sensors to a SensorThings API server
Dit onderdeel is niet-normatief.
4.1 Content
This chapter describes the work Collaborall carried out for implementation
topic #2 of the Geonovum Testbed Sensordata 2026: connecting existing
sensors to a central OGC SensorThings API server through an intermediary
translation solution. The solution is available on:
In summary: Collaborall built sta-connector, an open-source,
server-agnostic Python service that ingests observations from
heterogeneous sensor protocols — HTTP, MQTT, Modbus TCP and LoRaWAN via
KPN Things — normalises them to OMS Observations and publishes them to
any OGC SensorThings API v1.1 server over HTTP or MQTT. The connector
ran continuously against our central STA server during the testbed and
also acts as the RQ3 / OpenCitySense control-plane party for remote
device onboarding.
4.2 Scope
Public-sector organisations operate many different sensors — different
vendors, different protocols, different payload formats — and most of
them cannot speak SensorThings natively. Topic 2 asks a participant to
connect one or more existing sensors to a central STA server through an
intermediary translation solution, so that heterogeneous sensors become
available behind one interoperable standard.
4.3 Goal
Connect existing sensors of different protocols to a central OGC
SensorThings API server.
Validate that observations arrive accurately and reliably.
Document the solution so a third party can reproduce it and onboard
a new source.
Provide a continuously available public demonstration.
Record lessons learned about the standard itself.
4.4 Approach
We built sta-connector: an on-premise Python ingest-and-tasking
satellite that receives observations from heterogeneous sources, runs them
through a configuration-driven transform pipeline, and publishes them to
any OGC-conformant SensorThings API server. The connector is deliberately
server-agnostic: it talks pure OGC STA and has been exercised against our
own sta-server implementation.
Where our proposal emphasised BIM object-context enrichment, the delivered
connector emphasises protocol breadth and transform generality: HTTP
webhooks, MQTT subscriptions, Modbus polling and LoRaWAN devices via KPN
Things, with a node-graph transform pipeline (byte decoding, unpacking,
unit normalisation) authored in the portal and executed by the connector.
We consider this a strength for the testbed's diversity goal — it answers
"how do you get any existing sensor into STA" rather than one vertical.
The ANT platform is capable of the sensor-to-object (FeatureOfInterest)
mapping our proposal described, but that path was not built or tested
within the testbed and is reported as future work.
Architecturally, each configured source has its own asynchronous
pipeline — ingest → validate → normalise → batch → publish — and the
pipeline is identical regardless of the wire protocol on either end:
switching a source from HTTP-in/HTTP-out to MQTT-in/MQTT-out is
configuration, not code. Around the data plane sit a local admin API
(bearer token + optional mutual TLS) through which the portal manages
sources, a Prometheus metrics and health surface, a bounded quarantine
for rejected payloads, and a tasking subscriber that listens on
v1.1/Tasks/+ (with a polling fallback) and executes the four RQ3
device-management task types (OnboardDevice, OffboardDevice,
UpdateConfiguration, RotateSecret). The connector holds a libsodium
keypair and is the only party able to decrypt the DeviceSecrets the
server stores as ciphertext. It is designed for a customer's trusted
network with outbound traffic only — no inbound firewall holes.
4.4.1 Sources connected
Source
Protocol into the connector
Payload handling
Dragino LHT52 temperature/humidity (real LoRaWAN hardware) via KPN Things
HTTP webhook (raw SenML) and MQTT
SenML decode → transform graph → OMS Observation
Pump pressure (test fleet)
MQTT subscription on the sensor-side broker
JSON decode, unit normalisation
Pump (simulator, test fleet)
Modbus TCP polling (holding registers)
Word/byte-order decode → scaling transform
Pressure / flow (test fleet)
HTTP push
JSON decode, range validation
The originally proposed Brabantse Delta / Node-RED source was not
implemented; as agreed with Geonovum, connectivity was validated with
the other sources and testbed connectors instead.
4.4.2 Configuration model
A source is fully described in YAML: its ingest protocol and parameters,
its transform, its target datastream and its egress protocol. Defaults
(batching, retry schedule) live once and are overridable per source.
Secrets never live in YAML — the configuration only names environment
variables. Transform graphs (byte decoding, unpacking, unit
normalisation) are authored in the portal and executed by the connector.
Sources can also be provisioned at runtime through the admin API, which
is how the portal onboards a new source without YAML edits. The
repository ships an annotated example configuration
(config/connector.example.yaml) that doubles as the template for a new
source.
4.5 Validation of accuracy and reliability
Validation took place through sustained operation and open community
testing rather than a one-off measurement campaign. During development
and operation roughly sixteen million observations were ingested
end-to-end (sensor → connector → server), from real hardware (Dragino
LoRaWAN via KPN Things, Netatmo and Milesight devices used by testbed
participants) and the simulated test fleet. All accuracy and
interoperability issues found — by us and by other participants — were
reported, discussed and fixed in the open in
discussion #10,
which serves as the public record of the validation.
Reliability is designed in rather than bolted on: observations are
validated and normalised before publishing, batched with
exponential-backoff retry on network failures, and rejected payloads land
in an inspectable quarantine instead of being silently dropped. Shutdown
drains in-flight observations rather than cancelling them, a property
pinned down by an integration test. The connector distinguishes between
upstream unreachability (retried) and payload rejection (quarantined), so
no failure mode loses data silently.
4.6 Reproducibility
The repository README documents the full quick-start: install
dependencies with uv sync, point CONNECTOR_CONFIG at a YAML file, set
the bearer token and run. A fully self-contained demo — bundled Mosquitto
broker, the connector, two fake sensors and a subscriber — boots with a
single make demo-up. Onboarding a new source requires copying a source
block in the example YAML or one call to the admin API. A clean-room
installation from the public documentation alone was executed
successfully as part of the delivery.
4.7 Public demonstration
The live demonstration at https://sta-connector.collaborall.net shows
the connector feeding the central STA server: a visitor sees the
connected sources, their health, and observations arriving in the data
browser as they flow from the sensors through the connector into
https://sta-server.collaborall.net/v1.1/. Every observation is
traceable to the source and connector that produced it via the RQ3
ControllingConnector relation. The demonstration remains available
until at least January 2027.
4.8 Lessons learned
Detailed engineering notes live in the repository
(docs/lessons-learned.md, ADRs). Reported here are our observations on
the standard itself, from the perspective of the party operating the
translation layer:
Origin and ownership are invisible to the standard. Once the
connector has published an Observation, nothing in core STA records
that this connector produced it, which transform decoded it, or which
party owns the source. From a client perspective this is our main
worry about operating STA at scale: managing many servers and many
connectors at once requires answering "where did this data come from?"
end to end — for debugging, for deduplication, and for cleaning up
orphaned sources — and the protocol offers no first-class way to do
so. The RQ3 / OpenCitySense ControllingConnector relation is a
workable extension, but it is optional and unenforceable without
breaking spec conformance. We raised this with the testbed community
in discussion #16
and recommend the standard treat provenance as a first-class concept.
The MQTT extension's topic grammar and MQTT wildcards don't
align. STA topics put the entity id inside one level
(Datastreams(7)/Observations), so a subscriber cannot use + to
fan out over ids within that shape — + matches a whole level, not a
substring. Subscription patterns therefore end up broader than the
publish grammar suggests, which the standard could acknowledge.
Tasking over MQTT needs a polling fallback. The
publish-on-POST /Tasks pattern is elegant, but a broker outage would
otherwise silently stall device onboarding; the standard is silent on
delivery guarantees. Our connector falls back to polling
GET /Tasks?$filter=status eq 'pending' — onboarding still works,
with higher latency.
Heterogeneity lives entirely on the ingest side. The STA-facing
half of the connector is small and uniform; nearly all effort went
into the sensor-facing half (payload formats, byte orders, broker
behaviours, platform envelopes — KPN Things delivering raw SenML and
platform-enveloped payloads for the same device). This is an argument
for the standard: once data is in STA shape, every downstream
consumer is identical. The translation problem is real but it only has
to be solved once, at the edge.
4.9 Availability and licensing
The connector and demo remain publicly available until at least January
2027. The connector is open source under Apache 2.0; this report
and all documentation deliverables are published under CC-BY 4.0.
Synergy: bi-weekly meetings, open sessions, closing event
Yes
Bi-weekly meetings attended (11:00 on 22-05, 05-06, 19-06, 03-07, 17-07 and 31-07-2026)
Dissemination: Geonovum repo report, DMI PDX, Best Practice Explorer, blog/LinkedIn
Partially at delivery
This report; invitation to the final public session is sent to participants in August 2026
5. Topic 2 Geo Insights: Connecting sensors to a SensorThings API server
Dit onderdeel is niet-normatief.
5.1 Introduction
Geo Insights participated in this testbed as an Implementation Topic 2 contributor: connecting sensors to a central SensorThings API server. Our use case focused on climate adaptation monitoring at urban living labs, a domain where sensor data is fragmented across fieldlabs with incompatible infrastructure, governance models, and data formats. The OGC SensorThings API standard promised to be the interoperability layer that could make this data accessible and queryable across organizations.
We set out to connect sensors from three fieldlab locations:
The Green Village (TGV), TU Delft campus in Delft: an open innovation fieldlab for sustainable technologies and nature-based solutions, instrumented with indoor climate sensors, weather stations, and soil monitoring equipment.
Diergaarde Blijdorp, Rotterdam: a 28-hectare zoo and urban delta site, instrumented for climate adaptation monitoring including heat stress, water buffering, and water quality.
EUR Campus, Erasmus University Rotterdam: a university campus site with weather stations and soil moisture sensors, intended for climate adaptation monitoring as part of the green-blue infrastructure program.
Our approach was to build a generic, open-source connector service that bridges existing fieldlab sensor infrastructure with the central FROST server, rather than building bespoke point-to-point integrations. The connector simultaneously feeds observations to the testbed's central SensorThings server and to UrbanAdapt, our climate adaptation platform, demonstrating how standardized sensor data can serve both public interoperability and domain-specific applications.
Rather than connecting sensors directly to the SensorThings API, we built an intermediary translation layer. This decision was driven by a practical reality: no sensor speaks SensorThings natively. Every data source we encountered required protocol translation, schema mapping, authentication handling, and error recovery before observations could be pushed to a FROST server.
The connector is a FastAPI service that ingests data from multiple sources, maps readings to the SensorThings entity model (Things, Sensors, ObservedProperties, Datastreams, Observations), and pushes observations to one or more FROST targets. It runs continuously as a deployed service with background polling loops.
In practice we ran three targets concurrently: a FROST v1.1 server (WBD), a
FROST v2.0 demo server (Fraunhofer), and a PHP-based FROST v1.1 server
(CollaborAll). The three implementations behaved differently enough that the
multi-target fan-out became our most productive source of interoperability
findings (see below).
Key architectural decisions:
Multi-source ingestion: each data source has its own adapter and polling loop, accommodating fundamentally different protocols (Kafka with Avro, REST with OAuth2, public JSON endpoints, file-based bridge payloads).
Multi-target fan-out: observations are pushed to multiple FROST servers concurrently (one thread per target via a thread pool), which proved essential for testing interoperability across server implementations and SensorThings API versions. A slow or unresponsive target no longer blocks the others.
Decoupled consume and push: the Kafka consumer hands readings to a bounded async worker queue and manages offset commits independently of the (potentially slow) multi-target FROST push, so the poll loop is never blocked. The worker coalesces queued batches and pushes them in the background. If the queue fills, the consumer falls back to an inline push; no data is ever dropped. This decoupling was the single most effective change for preventing Kafka consumer eviction under sustained load.
Negative caching of failed datastream lookups: when a sensor has no matching datastream on a particular target, the failed lookup is cached with a five-minute TTL. Without this, every push cycle triggered a per-reading HTTP query storm against every target that lacked the datastream, dominating push latency for large coalesced batches.
Backlog-lag guard: the connector monitors the age of the newest Kafka observation. When the lag exceeds a configurable threshold (default one hour), it raises an alert and can optionally skip to the latest offset, preventing unbounded backlog replay from crowding out live data.
Reliability patterns: circuit breakers per target, a dead-letter queue for failed observations, batch chunking, and timestamp-based deduplication. These were not planned from the start but became necessary as we moved from prototype to continuous operation.
Self-healing and observability: a stall watchdog that detects when an ingestion source goes silent and forces a reconnect, best-effort webhook alerting for stalls and authentication failures, a per-source freshness endpoint (returning HTTP 503 when data is stale) that an external uptime monitor can poll, and a liveness probe for the hosting platform (Render). These were added only after continuous operation surfaced recurring, silent stalls that no amount of single-run testing would have revealed.
5.3 Implementation timeline
The project spanned roughly three and a half months, from initial scaffold to production operation and operational hardening. The commit history tells the story of how the implementation evolved:
Date
Milestone
Commits
17 Apr 2026
Project scaffold and MIT license
2
22-26 May
First FROST connection, entity registration validated, bridge payload workflow
2
3 Jul
Enhanced connector with test suite
1
13-14 Jul
Kafka integration with TGV Office Lab via Confluent Cloud; Dockerfile and deployment to Render
9
15 Jul
Major architecture refactor: layered design with custom exceptions, STA domain models, structured logging
1
16 Jul
Ohnics air quality and Levellog groundwater polling sources added
3
17 Jul
Deduplication sprint: 13 commits in one day fixing duplicate entities and observations across restarts. FROST v2.0 support added as third target.
15
20-21 Jul
Performance optimization (push cycle from ~60s to ~6s), circuit breaker, dead-letter queue replay
Multi-target robustness: per-target auth routing and no-auth support, registration resilient to primary-target outages, integer @iot.id coercion for the CollaborAll PHP server, Kafka offset commit deferred until after FROST push, and dead-letter queue memory-safety (streamed replay, size cap, age expiry)
5
3 Aug
Operational hardening under continuous load: root-caused and fixed a recurring silent Kafka stall (consumer eviction), added stall self-healing with a watchdog and webhook alerting, diagnosed and repaired the dead CollaborAll target, recovered from a dead-letter queue that had filled the persistent disk, and shipped write-capability discovery plus a replay-time deduplication probe
7
4 Aug
Performance and resilience sprint: decoupled FROST push from Kafka consume loop via an async worker queue with coalescing, parallelised per-target push (concurrent threads instead of sequential), added negative caching for failed datastream lookups, added backlog-lag guard with optional auto-skip-to-latest, Render liveness probe, v2.0 dataArray batch push, and reordered monitoring push to fire before FROST
5
Two patterns stand out. First, the intense burst on July 17th where 13 commits addressed deduplication issues. Duplicate observations accumulated because the entity cache was cleared on every redeployment, and without startup seeding from FROST, the connector would re-push all historical readings. This required querying the latest observation timestamp per datastream on startup and filtering accordingly. Second, the multi-target hardening on July 22nd revealed that pushing to multiple FROST servers exposed subtle incompatibilities that single-target testing never would have caught.
The August 3rd hardening phase is a third pattern worth calling out: it happened entirely because the connector ran continuously for weeks. Two failure modes only appeared under sustained load. First, the Kafka consumer was being silently evicted from its consumer group whenever a large post-backlog batch took longer to push to three targets than the broker's poll interval allowed; the connector then consumed nothing at all, with no error, until manually restarted. The fix was to lengthen the poll interval and cap the per-cycle batch size so a multi-target push always completes inside the poll window, backed by a watchdog that alerts and reconnects if a source goes silent. Second, the dead-letter queue file grew far past its intended cap and eventually filled the service's persistent disk, causing every subsequent disk write to fail. Neither failure is conceivable in a short-lived test; both are inevitable in production. This is the strongest evidence we have for the claim that production reliability cannot be an afterthought.
The August 4th sprint is a fourth pattern: a targeted performance pass that addressed the root cause the August 3rd fixes had only mitigated. Capping the batch size and lengthening the poll interval reduced consumer evictions but did not eliminate them. The fundamental problem was that a synchronous, sequential multi-target FROST push held the consume loop hostage. The fix was architectural: an async worker queue that decouples consume from push entirely, concurrent per-target threads so a slow target no longer serialises the others, and a negative cache that eliminates the per-reading HTTP lookup storm for sensors that have no datastream on a given target. Together these changes moved the push bottleneck out of the consume loop altogether.
5.4 Fieldlab onboarding experiences
Each data source we connected had a fundamentally different integration path. This diversity is itself a key finding: the SensorThings API standard solves the output side of interoperability, but the input side remains entirely unstandardized.
5.4.1 TGV Office Lab: Kafka and Confluent Cloud
The Green Village's indoor climate sensors (temperature, humidity, CO2, air pressure) publish to a Confluent Cloud Kafka topic using Avro serialization with a Schema Registry. Onboarding required: obtaining Confluent Cloud API credentials, understanding the Avro schema, mapping measurement_id values to SensorThings entities via a device mapping configuration, and handling Avro union types in deserialized records. Once connected, the data flows reliably at 5-minute intervals. This was the most technically demanding integration but also the most robust once established.
5.4.2 Ohnics air quality: public REST API
The Ohnics SamenMeten network exposes outdoor air quality readings (PM2.5 and temperature) for sensors in Delft via a public JSON endpoint. No authentication, no registration, no credentials needed. The connector polls the endpoint every 5 minutes, discovers sensors by name prefix, and dynamically registers SensorThings entities for each discovered sensor. This was by far the easiest onboarding experience and demonstrates the value of publicly accessible sensor APIs.
5.4.3 Levellog / CARS Online: OAuth2 and OData
Groundwater level sensors at TGV installations are managed via the CARS Online platform, which exposes an OData API secured with OAuth2 client credentials. Onboarding required: obtaining OAuth2 credentials, discovering installation UUIDs via a custom discovery script, understanding the proprietary API contract (POST with startDate/endDate/amountOfEntries), and mapping the response to SensorThings entities. Each installation needed to be individually configured with its UUID, name, and coordinates.
5.4.4 Diergaarde Blijdorp: governance delays
Despite being confirmed in our tender as a primary fieldlab location, we have not been able to connect Blijdorp sensors during the testbed period. The challenge was not technical but organizational: coordinating access across institutional boundaries, clarifying data ownership, and obtaining credentials took longer than the testbed timeline allowed. The entity model for Blijdorp was registered as scaffolding in FROST (10 Things with Locations, Sensors, and Datastreams), proving that the SensorThings side was ready, but no live observations have flowed. This is an important lesson for future testbeds: governance and access provisioning can be a larger bottleneck than any technical challenge.
5.4.5 EUR Campus: governance delays
The Erasmus University Rotterdam campus was included in our tender as a fieldlab site with weather stations and soil moisture sensors for climate adaptation monitoring. Like Blijdorp, the connector architecture and entity model are ready to receive the data, but access provisioning and institutional coordination have not been completed within the testbed period. The pattern is the same: the technical side is not the bottleneck.
5.4.6 Key insight
The table below summarizes the contrast:
Source
Protocol
Auth
Discovery
Onboarding effort
TGV Office Lab
Kafka + Avro
API key + secret
Device mapping JSON
High
Ohnics
REST (JSON)
None
Automatic by prefix
Low
Levellog / CARS
OData + OAuth2
Client credentials
Manual UUID discovery
Medium
Blijdorp
Unknown
Pending
Blocked on governance
Not completed
EUR Campus
Unknown
Pending
Blocked on governance
Not completed
5.5 Findings on the SensorThings API standard
5.5.1 What worked well
The SensorThings API entity model proved to be a good fit for climate adaptation sensor data. The separation between Thing (the installation), Sensor (the instrument), ObservedProperty (what is measured), and Datastream (the specific combination) maps naturally to how fieldlabs organize their monitoring. The OData query capabilities ($filter, $expand, $orderby) were powerful for programmatic lookup of existing entities and for seeding deduplication timestamps on startup.
Most importantly, the standard delivers on its core promise: once data is in a SensorThings server, it is genuinely interoperable. Our observations are queryable by anyone with access to the FROST endpoint, using a standard protocol, without needing to know anything about the source infrastructure. This is exactly the kind of interoperability that climate adaptation practitioners need.
5.5.2 Issues at the standard level
These issues were raised and discussed with other participants and server implementers in the testbed GitHub repository. We opened discussions #22, #23, and #24 ourselves; the others were started by fellow participants and reflect shared experiences across the testbed.
Entity modeling is non-trivial (discussion #22). The standard defines the entity types but provides no guidance on naming conventions, Thing granularity, or how to structure properties. Different implementers will model the same sensor installation in different ways, undermining the interoperability the standard aims to provide. For example: should a weather station with five parameters be one Thing with five Datastreams, or five Things? The standard allows both, but only consistent choices enable meaningful cross-dataset queries.
Observation deduplication is left to the client, and it costs a round-trip per write (discussion #23). The standard has no mechanism for idempotent observation submission. (Datastream, phenomenonTime) is a natural key in every deployment we saw, but nothing enforces it, so re-polling, back-filling, or retrying after a timeout can all create duplicates with no server-side protection. The safe-but-slow pattern is to probe (GET …/Observations?$filter=phenomenonTime eq …) before every write; the fast pattern is to write optimistically and treat an HTTP 409 Conflict as "already delivered", but that only deduplicates if the server actually enforces uniqueness and returns 409, which is undocumented and varies by implementation. We settled on the optimistic path plus three safeguards: startup seeding of the latest timestamp per datastream from FROST, deferring the Kafka offset commit until after the push succeeds, and an existence probe confined to the rare dead-letter replay path. This is a common need that should be addressed at the standard or server level, ideally as a discoverable dedupe-on-natural-key capability, so a client can tell whether the cheap path is safe rather than establishing it by trial. To make that concrete, we implemented the client half of exactly this idea: a capability-discovery step that records each server's advertised write capabilities (data-array batch support, client-specified IDs, and any duplicate-handling behavior). Today that information is incomplete and inconsistent across servers, which is precisely why a discoverable, standardized dedupe capability would remove guesswork rather than add to it.
The v1.1 to v2.0 gap is significant (discussion #17). When we added a FROST v2.0 target alongside our v1.1 servers, we discovered that the payload format differs substantially: @iot.id becomes a different field structure, observationType and unitOfMeasurement are replaced by SWE-Common resultType, and collection count fields use different names (@iot.count vs @count). We had to build a payload adaptation layer to support both versions. This transition path needs clear documentation and tooling support if v2.0 adoption is to succeed.
Semantic interoperability stops where syntactic interoperability ends (discussion #24).ObservedProperty.definition is a free URI and unitOfMeasurement is effectively free text. This openness is deliberate (no standard can enumerate every phenomenon in every domain), but it means a consumer cannot reliably answer "give me all CO2 readings in this area" across servers, or even within a single server, without someone hand-curating a mapping. Avoiding exactly that work is why organizations adopt standards in the first place. We addressed it on our own side by pinning every ObservedProperty.definition to a CF Standard Names URI and every unitOfMeasurement.symbol to a UCUM code (Cel, %, [ppm], deg, hPa, km/h, mm, W/m2), never null or a placeholder. But this only produces interoperability if other parties converge on the same authorities. This is the layer a national profile is best placed to pin down, and it requires no change to the specification.
5.5.3 Issues with server implementations
Testing against multiple FROST server instances revealed inconsistencies that single-server testing would have missed. Many of these were tracked in the testbed's shared bug-report thread (discussion #10):
$expand on Observations did not work as expected on one server, requiring individual queries instead of batch expansion (discussion #10).
resultQuality format differed between servers: one accepted a scalar string, another required an array. The same valid SensorThings payload was rejected by one server and accepted by another.
Entity ID types differed: the Collaborall PHP server rejected string @iot.id values and required native integers, while FROST accepted both. The same reference payload had to be coerced per target (discussion #12).
FeatureOfInterest auto-resolution and deep-insert semantics differed, and this silently killed an entire target. The CollaborAll PHP server rejected every observation with an HTTP 409 Conflict because its Things had no linked Location, so the server could not auto-generate a FeatureOfInterest for the observation. On the FROST servers the same entities worked. Worse, the standard remedy (a deep-insert of nested Things when POSTing the Location) was silently ignored by the PHP server, so the Thing and Location existed but were never associated. We only diagnosed this weeks in, because the failure was invisible: registration succeeded, the datastreams existed, and the observations returned 409 (which our optimistic dedup logic treats as "already delivered"). The fix was to explicitly PATCH-link each Thing to its Location on the secondary stacks. This is a sharp illustration that a server accepting your entities is not the same as a server accepting your observations, and that implicit FeatureOfInterest generation is under-specified across implementations (discussion #10).
Batch push behavior (the CreateObservations dataArray extension) varied: a batch of 4,500 observations would succeed on one server but time out on another, requiring us to implement chunking (500 observations per request) and configurable timeouts (discussion #18).
Error response formats for the same problem (e.g., a duplicate entity name) differed between server implementations, making generic error handling more difficult.
These are not failures of the standard itself, but they demonstrate that interoperability at the standard level does not automatically mean interoperability at the implementation level. Conformance testing for SensorThings servers would help.
5.5.4 Source data challenges
The hardest interoperability problem we faced was not on the SensorThings side at all. It was getting access to the data in the first place. Each fieldlab has its own infrastructure, its own governance model, its own authentication mechanism, and its own data format. The SensorThings API standardizes the output, but the input side of the pipeline, where the real complexity lives, remains entirely ad-hoc.
Sensor metadata (make, model, calibration data, installation details) is rarely available in machine-readable form. We often had to discover sensor capabilities empirically by examining the data that arrived, rather than by consulting documentation. This makes entity modeling a trial-and-error process rather than a systematic one.
5.6 Lessons learned
A translation layer is essential. No sensor speaks SensorThings natively. Every implementation needs a bridge, and the complexity of that bridge depends entirely on the source infrastructure, not on the standard.
Multi-target testing reveals what single-target testing hides. Pushing to multiple FROST servers simultaneously exposed payload format differences, timeout behaviors, and error handling inconsistencies that we would never have found with a single target.
Production reliability is not optional. Circuit breakers, dead-letter queues, batch chunking, and deduplication were not in our original design. They became necessary as we moved from prototype to continuous operation. The failures that ultimately mattered most were the ones that only appear under sustained load: a message-queue consumer silently evicted because a multi-target push outran its poll interval, and a dead-letter queue that grew past its cap and filled the disk. Both were invisible in testing and inevitable in production, which is why self-healing (watchdogs, forced reconnects), alerting, and an external freshness check belong in the design from day one, not after the first outage. Any implementation guide should address these patterns from the start.
Decouple consume from push. When a connector feeds multiple targets that each have their own latency and failure modes, the push must never block the consume loop. Our Kafka consumer was repeatedly evicted because a multi-target push took longer than the broker's poll interval. The fix (an async worker queue between consume and push) eliminated the evictions entirely and allowed the consumer to stay live while FROST targets catch up asynchronously. This generalizes beyond Kafka to any source with a liveness heartbeat: the ingestion loop should hand off to a bounded queue and commit as fast as it can.
The hardest part is access, not technology. Governance, credentials, institutional coordination, and data ownership took more calendar time than writing code. Two of our three fieldlab locations (Diergaarde Blijdorp and EUR Campus) could not be connected during the testbed period despite the technical side being ready. Future testbeds should front-load access agreements and credential provisioning, at least for fieldlabs.
Entity modeling needs domain expertise. Mapping sensors to Things, ObservedProperties, and Datastreams requires understanding both the SensorThings data model and the domain context. This is not a purely technical task and benefits from collaboration between domain experts and implementers.
5.7 Public sessions
5.7.1 Public session 1
At the kick-off session we introduced Geo Insights and our co-applicant Building Changes, presented our use case (connecting climate adaptation sensors from urban fieldlabs to the SensorThings API), and outlined our goals for the testbed: building an open-source connector, pushing to multiple FROST servers, and feeding observations into our UrbanAdapt platform. With the ultimate goal to create a national fieldlab sensornetwork and train a geospatial foundation model.
5.7.2 Public session 2 (25 June 2026)
At session 2 the connector existed as scaffolding: 10 Things, 26 Datastreams, and 13 ObservedProperties were registered on the Brabantse Delta FROST server for The Green Village and Diergaarde Blijdorp, but no live sensor data was flowing yet; we were still waiting for real payload samples from the fieldlabs. The presentation introduced the connector architecture (FastAPI, entity registration, dead-letter replay, Node-RED bridge), demonstrated governance via the FROST Projects extension, and showed a working Tasking MVP end-to-end on the live server. Open questions posed to the audience included push-vs-pull per fieldlab location and how to populate resultQuality across sources with different reliability characteristics.
By session 3 the project had moved from scaffolding to live operation. Three real data sources were connected (TGV Office Lab via Kafka with 5 sensors, Ohnics air quality via REST polling with 46 sensors, and Levellog groundwater via OData with OAuth2 with 3 sensors) and observations were flowing to all three FROST targets simultaneously (Brabantse Delta v1.1, CollaborAll v1.1, and the Fraunhofer v2.0 test server). The presentation walked through the engineering problems solved since session 2 (Avro union type handling, polling deduplication, multi-server fan-out with per-target auth and versioning) and gave a live demo of the UrbanAdapt Monitoring Module, which had been expanded with features inspired by another participant's RIME Observatory. The session closed with 101 datastreams live across 2 data sources feeding 2 servers, and a roadmap for onboarding Blijdorp, EUR Campus, Living Lab Binckhaven, and AMS Institute in the coming months.
Based on our experience in this testbed, we offer the following recommendations for future SensorThings API adoption:
Standardize fieldlab onboarding. Develop templates for access agreements, credential provisioning, and data discovery that can be shared across fieldlabs. The governance overhead of connecting a new site was consistently larger than the technical effort.
Publish entity modeling and semantic guidelines. The SensorThings standard defines entity types but not modeling conventions or semantics. A best-practice guide covering naming, Thing granularity, property structures, a controlled vocabulary per domain for ObservedProperty.definition, and mandatory UCUM symbols for unitOfMeasurement would significantly improve cross-dataset interoperability. This is the layer a national profile gains most from agreeing on, and it requires no change to the specification.
Address the v1.1 to v2.0 migration path. Implementers need clear guidance on payload differences, migration tooling, and dual-version support. The current gap creates risk for early adopters.
Consider standard-level deduplication. Either add idempotent observation submission to the standard or document recommended deduplication patterns. Every implementer will face this problem in production.
The testbed format works. Working with a real use case, real sensors, and a shared central server generated practical insights that a specification review never would. Future testbeds should allocate more time for fieldlab onboarding and consider requiring access agreements to be in place before the implementation phase begins.
Connecting sensors to an OGC SensorThings API server
Programme
Geonovum Testbed 2026, topic 2
Implementing party
Clappform B.V.
Version
2.0
Status
Final
Date
28 August 2026
Table 1. Document control.
Servers in scope
Server
Endpoint
Our role
Waterschap Brabantse Delta (WBD)
https://sta.wbd-rd.nl/FROST-Server/v1.1
We write to it
Collaborall
https://sta-server.collaborall.net/v1.1
We read from it, and write back to it
SULO, via the REEN platform
https://api.reen.com/api/3
We poll it; a vendor REST API, not STA
Table 2. Servers and sources we worked against.
A note on terminology. SensorThings, shortened to STA throughout this report, is an open OGC
standard for publishing sensor data over plain HTTP. It defines a small fixed set of records, namely Thing, Location, Sensor, ObservedProperty, Datastream, Observation and FeatureOfInterest, plus one query language over them. Any client that speaks STA can therefore read any server that speaks STA, without the two parties agreeing anything in advance. We call those records entities, which is the term the standard uses.
6.2 Executive summary
6.2.1 What we were asked to do
Topic 2 asks participants to connect one or more sensors to an OGC SensorThings API server, to
validate that the connection is reliable, to document an approach others can reproduce, and to report
what we learned about the practical effort involved. Each participant picks their own technical
route: connect devices directly, or put an intermediary in between.
6.2.2 What we built and validated
We built an intermediary translation layer instead of connecting devices directly, because our
sensors are not reachable as devices: the waste-container fleet reports into a vendor platform, and
what we get is a vendor API. The layer takes any source, maps it to the STA entity model, and writes
it to one or more STA servers, and the parts that make those writes reliable sit between the two
ends, meaning the stored cursor, the deduplication and the write-log.
We validated the layer against the live Brabantse Delta FROST-Server, not a local mock: five
containers with their fill-level datastreams and more than 1,400 observations, written and read back
over both HTTP and MQTT, including a multi-day catch-up and a restart.
We then did something that was not in the original scope, and learned more from it than from the
planned work. We used the same codebase to read another organisation's STA server and copy it into
ours. That gave us two directions, two independent servers and one entity model, and it turned a
single-server exercise into a real interoperability test.
Finally we connected the real thing. The SULO waste-container fleet reports into a proprietary vendor
platform, and once its documentation reached us we wrote a third adapter against it: 51 container slots
across 27 sites in Nieuwpoort, Belgium, four waste fractions, hourly fill-level estimates, published
into the same shared FROST server. This is the case most organisations are in: the source is
a vendor API that will never speak STA, and it is where the standard's value has to be argued. It also
produced the single most serious defect we found, in section 4.11.
6.2.3 What we found
Our main finding is where the work goes.
Conforming to the standard was the cheap part. Everything around it, meaning transport,
authentication, server-specific behaviour, concurrency, idempotency and semantics, is where the
engineering went.
Seven findings shaped our view most.
Mapping to STA was mechanical. An unfamiliar domain, municipal waste containers, fitted the
seven core entities with no extensions and no compromises. A second, unrelated domain
then fitted the same mapping layer (see sections 1.2, 1.3 and 5.1).
Concurrency corrupted data in silence. Resolving datastreams in parallel duplicated the
entities they share. STA does not require names to be unique, so the server reported nothing at
all (see section 4.4).
Idempotency is the client's problem, and it costs throughput. Idempotency here means that
writing the same reading twice leaves one copy on the server. Nothing in STA enforces
it, so we built our own bookkeeping plus a check before every write, and that check, not the write,
became the bottleneck (see section 4.5).
The same payload was accepted by one server and rejected by another. We read the specification.
The payload is legal, and the rejecting server enforces a constraint the standard does not state
(see section 4.7).
Well-formed STA data can still be hard to use. The second server is valid and carries
four different definitions of "CO₂ level", plus units written as "...". We achieved
interoperability of form. We did not achieve interoperability of meaning (see section 4.8).
Server capabilities are not discoverable. Which extensions exist, which authentication
scheme applies, whether the build is standard, even which dialect of the query language is in
force: we learned all of it by probing, one request at a time (see sections 4.2, 4.9 and 6.3).
Two individually correct rules combined to delete data. Our newest source publishes forecasts in
the same collection as measurements, distinguished only by a future timestamp. Rejecting a
future-dated reading is right; advancing the poll cursor past a definitively rejected reading is also
right. Together they moved the cursor into the forecast horizon and discarded every real measurement
until that date, with no error logged, while the stream still looked healthy (see section 4.11).
Four clusters account for the effort the standard itself caused: semantic vocabulary, entity and
observation identity, capability discovery, and loosely typed fields. Section 6 covers all four, in
that order of importance.
Section 4.10 answers a question the findings raise again and again, because the answer decides
what to do about them. Is a given problem the FROST-Server implementation, the specific deployment, or the
standard? The three have different remedies on
different timescales, and confusing them is how integration teams end up absorbing other people's
bugs into their own client.
6.2.4 Why we recommend the standard
The strongest argument for STA came out of the second integration. We pointed our reader at an
unfamiliar organisation's server: 46 observed properties, 460 datastreams, and a domain of pump
telemetry, seismics and air quality with no overlap at all with our own waste containers. Working
replication took days, with no bilateral agreement, no vendor SDK, no schema exchange, and no
conversation with the other party's engineers. Every vendor API integration we have done cost more
than that. Section 7 makes that case in full, together with the honest caveats.
6.3 1. What we built
Our starting position shaped the decisions that follow. The sensors in scope are fill-level sensors
on a municipal waste-collection fleet, and we cannot reach them, let alone reprogram them. They
report into a vendor platform, and what we get is a vendor API. There is no firmware to change and no
MQTT topic to redirect. Anything that connects those sensors to a SensorThings server has to sit
between the vendor API and the server and translate as it goes.
That situation is common, and it shapes
what connecting a sensor to STA means in practice. In most existing sensor estates the sensor is
already connected to something. The integration problem runs platform to
server, with someone else's data model on the near side and a standard one on the far side.
6.3.1 1.1 Shape of the system
Figure 1. The translation layer, its sources and its targets. The Collaborall server appears twice,
because we read from it and also write back into it.
The system has three seams: the adapter contract, the ingest core and the FROST writer.
The adapter contract. Every source produces the same canonical Thing, Datastream and
Observation types, whether that source is a polled vendor API, a pushed webhook or another STA
server. Canonical here means our own internal shape, one step removed from both the vendor and the
standard. Nothing downstream knows where the data came from. Adding the Collaborall source needed no
change to the ingest core, the validator or the writer, beyond loosening two type constraints (see
section 4.6).
The ingest core. It resolves the STA entity chain, meaning the six linked records STA requires
before an observation can exist: Thing, Location, Sensor, ObservedProperty, Datastream and
FeatureOfInterest. It then writes the observations, records what it wrote, and moves the per-stream
cursor forward. The cursor is a stored marker of the last reading we delivered successfully. Cursor
and write-log together are what make the pipeline safe to restart and able to fill gaps.
The FROST writer. It upserts entities by name, meaning it looks each entity up by name and
creates it only when it is absent. It checks for an existing observation before writing a new one,
and it supports two transports for creating observations: HTTP POST, or MQTT publish over
WebSockets, while entity upserts always go over HTTP. We can write several targets in parallel,
which is what we call dual-write.
6.3.2 1.2 How a waste container becomes STA entities
Table 3 is the whole mapping: one container arrives from the vendor API as an id, a position and a
fill percentage, and becomes six entities plus a reading.
Domain concept
STA entity
Name we generate
Notes
The container itself
Thing
SULO Container 0001
Vendor ids, source system and first-seen timestamp go inproperties
Where it stands
Location
Location of SULO Container 0001 at 2026-07-17
GeoJSON Point. The date in the name keeps it stable per day, so a container that has not moved does not accumulate Locations
The fill sensor
Sensor
SULO fill-level sensor <model> <firmware>
Shared on purpose by every container with the same model and firmware
result, phenomenonTime, resultTime, plus the two fields described below
Table 3. Mapping one waste container onto the seven core STA entities.
The shared entities carry weight. Sensor and ObservedProperty are shared across containers on
purpose, because there is one "Fill level" concept and not five hundred. That is correct modelling,
and it is what raced and duplicated when we resolved datastreams in parallel (see
section 4.4).
We carry vendor detail in properties, not in fields of our own invention. Each Thing gets
vendor, vendor_native_id, clappform_source_system and first_seen_at, plus whatever the
adapter adds. For the synthetic set that means area and synthetic. Nothing we carry forces a
non-standard field onto the wire, which keeps the output readable by any STA client (see section 5.2).
Every entity name takes a configurable prefix. On a server we share with other testbed parties,
ENTITY_NAME_PREFIX, for example CF_, goes in front of all seven names. Our upsert resolves
entities by name, so without a prefix it would bind to someone else's Fill level (see section 4.3).
Each stored Observation carries two more fields, and STA has a place for both, so we did not have to
invent one. resultQuality.validated_by and
validation_version record that our validation stage ran and which version ran. And
parameters.raw_observation_id holds the vendor's own identifier for that reading, which is the key
that makes re-polling safe.
6.3.3 1.3 Worked examples: what arrives, and what we send
Table 3 is the destination, and we reach it differently from each of our two kinds of source. A
vendor REST API has a model with nothing to do with STA, while another STA server already has the
right model, so the job there is faithful copying.
The shapes below follow the source schemas and our mapping code. The values illustrate rather
than reproduce captured traffic.
6.3.3.1 1.3.1 Example A: a vendor REST API (SULO, on the REEN CMS platform)
Step 1: what the vendor gives us. Not one object, but four, from four endpoints. A slot:
// GET /api/3/containerSlots{"id":4821,"name":"Dorpsstraat 12 - rest","customerKey":"SULO-NL","contentType":3,"site":"915","container":7734,"fillLevel":24.0,"observedFillLevel":27.0,"observedFillLevelTime":"2026-07-17T09:15:00Z","lastModified":"2026-07-17T09:15:04Z"}
Its site, which is where the coordinates live:
// GET /api/3/sites{"id":915,"name":"Dorpsstraat","areaName":"Centrum","address":"Dorpsstraat 12","postalCode":"8620","city":"Nieuwpoort","country":"BE","latitude":51.1298,"longitude":2.7521}
Its device, which describes the physical sensor, and the waste fraction:
// GET /api/3/devices/linked{"id":5567,"brand":"Sensoneo","model":"Single Sensor 4","serial":"SN-88213","container":7734,"installed":"2025-03-04T00:00:00Z","status":{"signal":74,"batteryPercentage":91}}// GET /api/3/contentTypes{"id":3,"name":"Restafval","englishName":"Residual waste","categoryName":"Household"}
And the readings themselves:
// GET /api/3/fillLevels/containerSlot/4821?after=2026-07-17T08:00:00Z{"time":"2026-07-17T09:15:00Z","fillLevel":"27","containerSlot":4821,"site":915,"contentType":3,"contentTypeName":"Restafval","confidence":100,"frozen":true}
Step 2: what the adapter produces. One canonical Thing with one Datastream, and one Observation:
Step 3: what we send to the STA server. Six upserts and a write, shortened to the parts that show
the transformation:
// POST /Things{"name":"CF_Sulo Container 4821","description":"SULO waste container slot \"Dorpsstraat 12 - rest\" for Restafval ...","properties":{"vendor":"sulo","vendor_native_id":"4821","clappform_source_system":"smartsulo","first_seen_at":"2026-07-17T09:17:40Z","reen_container_slot_id":"4821","city":"Nieuwpoort","...":"..."}}// POST /Things(96)/Locations{"name":"CF_Location of Sulo Container 4821 at 2026-07-17","encodingType":"application/geo+json","location":{"type":"Point","coordinates":[2.7521,51.1298]}}// POST /Sensors{"name":"CF_Sulo fill-level sensor Single Sensor 4 unknown","encodingType":"application/json","metadata":{"brand":"Sensoneo","model":"Single Sensor 4","serial":"SN-88213","...":"..."}}// POST /ObservedProperties{"name":"CF_Fill level","description":"Container fill level as a percentage of total capacity","definition":"http://qudt.org/vocab/quantitykind/DimensionlessRatio"}// POST /Datastreams{"name":"CF_Fill level — Sulo Container 4821","unitOfMeasurement":{"name":"Percent","symbol":"%","definition":"http://www.opengis.net/def/uom/UCUM/0/%"},"observationType":"http://www.opengis.net/def/observationType/OGC-OM/2.0/OM_Measurement","Thing":{"@iot.id":96},"Sensor":{"@iot.id":41},"ObservedProperty":{"@iot.id":12},"properties":{"expected_cadence_seconds":3600}}// POST /Observations{"phenomenonTime":"2026-07-17T09:15:00Z","resultTime":"2026-07-17T09:15:00Z","result":27,"FeatureOfInterest":{"@iot.id":88},"parameters":{"raw_observation_id":"4821@2026-07-17T09:15:00Z"},"resultQuality":{"validated_by":"clappform-translation-layer","validation_version":"v1"}}
Six parts of that transformation took thought, and they represent what any vendor-to-STA
adapter deals with.
The Thing is the container slot, not the container. REEN attaches fill-level history to a slot
rather than to the physical bin, because bins get swapped out and the measurement series has to
survive that. The slot is therefore the stable sensing platform and becomes our Thing, and the
container id drops down into a property. Getting this the wrong way round would produce a new
Thing every time a bin was replaced, and break every series.
Coordinates swap. REEN reports latitude, longitude. GeoJSON, and therefore STA, expects
[longitude, latitude]. The swap happens inside the adapter, so nothing downstream has to know
about it. This is the easiest thing to get wrong in the whole pipeline, and it fails in silence. The
JSON and the GeoJSON both stay valid, and the container sits in the Indian Ocean.
Numbers arrive as strings. REEN documents fillLevel as a string, "27", and site as a
string where every other cross-reference is an integer. Both decoders accept either
form, because a schema that says one thing and a payload that says another is normal.
Forecasts have to go before the cursor sees them./fillLevels mixes settled estimates with
forecasts, and forecasts carry future timestamps. Leave one in and the validator correctly rejects
it as in_future, but that counts as a final rejection, so the cursor moves past it. Every real
measurement between now and that forecast date would then be dropped as before_cursor. One vendor quirk costs weeks of
data. The adapter discards anything newer than now.
We honour the vendor's own quality flag. REEN sets confidence to 100 for a normal reading,
80 when the distance measurement sat too close to the sensor, 60 when there was no measurement at
all, and 0 when REEN itself judged the reading erroneous. We drop 0, keep the rest, and count the
drops by reason instead of discarding them without a record.
We normalise order and duplicates. REEN returns newest first and pages by offset over live
data, which can repeat a row. We remove duplicates by timestamp and sort oldest first, because
that is the order cursor arithmetic needs.
Note also what is not in the outbound payload. We lose nothing REEN-specific, and we carry none of it
in a field of our own invention. Every vendor identifier rides in properties, which keeps the
result readable by any STA client (see section 5.2).
6.3.3.2 1.3.2 Example B: another STA server (Collaborall), faithful passthrough
Here the source model is already STA, so we keep the transformation small on purpose. The aim is a
faithful copy, including phenomena we know nothing about. Passthrough means we reproduce the source's
own naming instead of substituting our own.
Step 1: what we read, using $expand so related entities arrive inline:
// GET /Things?$expand=Locations{"@iot.id":31,"name":"Netatmo Indoor 24E124707E427318","properties":{"deviceEUI":"24E124707E427318","floor":2},"Locations":[{"@iot.id":44,"location":{"type":"Point","coordinates":[4.478,51.925]}}]}// GET /Things(31)/Datastreams?$expand=Sensor,ObservedProperty{"@iot.id":148,"name":"motion","observationType":"http://www.opengis.net/def/observationType/OGC-OM/2.0/OM_TruthObservation","unitOfMeasurement":{"name":"...","symbol":null,"definition":"..."},"Sensor":{"@iot.id":9,"name":"Netatmo module"},"ObservedProperty":{"@iot.id":61,"name":"motion","definition":"https://en.wikipedia.org/wiki/Motion_detection"}}// GET /Datastreams(148)/Observations// ?$filter=phenomenonTime gt 2026-07-21T00:00:00Z&$orderby=phenomenonTime asc{"@iot.id":90211,"phenomenonTime":"2026-07-22T07:31:00Z","result":true}
Step 2: the canonical envelope the reader posts to our own /ingest/collaborall. The
Passthrough* fields carry the source's own naming, so the mapper can reproduce it:
{"things":[{"thing":{"VendorID":"collaborall","VendorNativeID":"31","Location":{"Lon":4.478,"Lat":51.925},// note: 2 becomes "2", JSON-encoded"Properties":{"deviceEUI":"24E124707E427318","floor":"2"}},"datastreams":[{// ObservedProperty here is a stream key, not a fixed list member"ThingVendorNativeID":"31","ObservedProperty":"motion","Name":"motion","ObservedPropertyName":"motion","ObservedPropertyDefinition":"https://en.wikipedia.org/wiki/Motion_detection","SensorName":"Netatmo module","ObservationType":".../OM_TruthObservation","UnitName":"","UnitSymbol":"","UnitDefinition":""}]}],// "..." blanked"observations":[{"ThingVendorNativeID":"31","ObservedProperty":"motion","PhenomenonTime":"2026-07-22T07:31:00Z","ResultRaw":true}]}// verbatim, not a float
Step 3: what we write to the destination, the source's own names with our prefix:
// POST /Things -> { "name": "CF_Netatmo Indoor 24E124707E427318", ... }// POST /Sensors -> { "name": "CF_Netatmo module", ... }// POST /ObservedProperties -> { "name": "CF_motion",// "definition": "https://en.wikipedia.org/wiki/Motion_detection" }// POST /Datastreams -> { "name": "CF_motion",// "observationType": ".../OM_TruthObservation",// "unitOfMeasurement": { "name": "", "symbol": "",// "definition": "" } }// POST /Observations{"phenomenonTime":"2026-07-22T07:31:00Z","resultTime":"2026-07-22T07:31:00Z","result":true,"parameters":{"raw_observation_id":"collaborall:148@2026-07-22T07:31:00Z"},"resultQuality":{"validated_by":"clappform-translation-layer","validation_version":"v1"}}
What this example shows that the first one does not.
result is not a number.true travels from source to destination untouched, as ResultRaw,
and the validator skips its numeric range checks, because a boolean has no range. Our own model typed
result as float64, while the standard left it open (see section 4.6).
We keep the source's vocabulary, however imperfect. The ObservedProperty is motion and its
definition points at Wikipedia. We do not improve it, because a copy that reclassifies its
source is worse than one that copies faithfully. This is the material sections 4.8
and 6.1 are about: the copy is honest, and still not useful in meaning.
We blank placeholder units instead of passing them on. The source's "..." unit name and
definition become empty strings at the adapter boundary. Copying "..." onward would spread a
placeholder into a second server as though it were data.
We JSON-encode non-string properties. Canonical Properties is map[string]string, so
"floor": 2 becomes "floor": "2". Nothing is lost. This is a rough edge in our own canonical
model rather than anything the standard imposes.
We never reuse ids across servers. The source's @iot.id 148 appears only inside the
idempotency key. The destination assigns its own ids, and we correlate the two by name alone. That
is the advice hylkevds gives in
discussion #11, where another
participant asked how to handle dual-target delivery, and it is the reason section 6.2 matters so
much to us.
6.3.4 1.4 What one cycle does
The mapping above is static, whereas the sequence below runs on every poll, and it is where the
reliability in section 2 comes from.
Poll from a stored cursor. The adapter asks the vendor for everything newer than the last
observation we wrote successfully for each stream. The cursor lives in Postgres, so a restart
resumes instead of starting over, and a multi-day outage becomes a catch-up rather than a hole.
Normalise to canonical types. The adapter emits Things, Datastreams and Observations in our own
vocabulary. Everything downstream is vendor-agnostic from here on.
Validate. Drop readings with a missing or non-numeric result, drop fill levels outside 0-100,
and reject timestamps implausibly far ahead of our clock. Stamp provenance on what survives. We
count rejections by reason instead of discarding them without a record.
Resolve the entity chain. For each Thing and Datastream pair: upsert the Thing, then the
Location under that Thing, then Sensor, ObservedProperty, Datastream and FeatureOfInterest. Each
upsert is a cache lookup, then a GET ?$filter=name eq ..., then a POST only when the entity is
absent.
Write each observation. Check our own write-log for this datastream and timestamp, ask the
server for the same key, then either POST the observation or publish it over MQTT.
Record and advance. Write the log row, move the cursor. Only now do we treat the observation as
delivered.
Repeat per target. With more than one target configured, steps 4 to 6 run per server, each
with its own entity-id mapping. Server-assigned ids differ per server, so the only thing the two
may share is the names.
Steps 4 and 5 are the expensive ones, and they are expensive for the same reason. A client cannot tell the server
"create this if it does not already exist", so both steps read before they write.
That is one extra round-trip per entity and one per observation, and section 6.2 is the argument for
removing them.
6.3.5 1.5 The three integrations
WBD (Brabantse Delta)
Collaborall
SULO / REEN
Direction
Wewrite
Weread, then write back into our destination
Wepoll, then write into WBD
Domain
Waste-container fill level
Pump pressure and flow, air quality, CO₂, seismics, weather
Waste-container fill level (the real fleet)
Scale
5 Things, 5 datastreams, 1,400+ observations
46 ObservedProperties, 460 datastreams
51 container slots across 27 sites, hourly estimates
Transport
HTTP POSTand MQTT (wss), both validated
HTTP
HTTP
Authentication
HTTP Basic (write account)
HTTP Basic
Session token, username/password traded for an X-Token header
Server
Customised FROST build, with a project-scoped authorisation extension
Anindependent, non-FROST STA implementation (PHP/Laravel)
Not an STA server at all, a proprietary vendor REST API (REEN v3)
Purpose
Prove the write path end to end against a live shared server
Prove STA as a source, and portability between servers
Prove the adapter contract against a real vendor platform
Table 4. The three integrations side by side.
The server row is the most important one in that table, and we had it wrong at first. We assumed
both targets were FROST-Server builds, and only one of them is: the Collaborall server is a
separate, independently written STA implementation. Its maintainer confirmed this in the testbed
discussions as the first non-FROST implementation in the programme, and the PHP/Laravel stack traces
other participants received from it point the same way. Once we knew that, the work became an
interoperability test rather than a single-server exercise. Every difference between the two targets
in section 4 is a difference between two independent readings of one specification, not two
configurations of one codebase. It is also the reason section 4.10 exists.
The third integration answers a different question from the first two. WBD and Collaborall are both STA
servers, so both tested the standard against itself. SULO's platform is not an STA server and never
will be: it is a proprietary vendor API, which is the situation most organisations are in when
they consider adopting the standard. It therefore tested the claim in section 1.6 that a vendor's
concerns can be kept behind one contract. It is also the only one of the three where the sensors are a
real operational fleet rather than a test fixture. The fill levels are live municipal waste containers
in Nieuwpoort, Belgium, across four waste fractions, reporting hourly. What that integration cost is in
section 3; what it taught is in sections 4.1, 4.9 and 4.11.
6.3.6 1.6 The build decisions that mattered
We built a translation layer instead of connecting sensors to STA directly. Our sources are
vendor APIs and ERP systems, not devices we can reprogram. A layer in between keeps vendor concerns
behind one contract, meaning authentication, pagination, rate limits and wire formats, and it lets the
same validated write path serve any future source.
We hand-rolled an STA client on net/http instead of taking a third-party STA library. This
looked like the more expensive option and turned out to be the cheaper one. STA's wire format is plain
JSON with OData-style query parameters, so the client is a few hundred lines. In return we kept the
upsert and idempotency behaviour explicit and easy to inspect, and that is where the subtle problems
in section 4 showed up. A library would have hidden them rather than prevented them. That is
a finding about the standard in itself: it is small enough to implement directly.
We built a synthetic dummy adapter first. Deterministic, labelled fake containers, with
properties.synthetic = "true" and names prefixed Dummy , let us exercise the whole chain against
the live WBD server before any real sensor connector existed. No vendor dependency, no risk of
polluting real records, and a one-command run that reviewers can reproduce. It paid for itself several
times over, because we discovered every finding in sections 4.1 to 4.5 with synthetic data.
We made the reader a separate binary that pushes through our own ingest endpoint. The Collaborall
reader, cmd/collaborall-reader, does not write to FROST directly. It reads the source and posts
canonical envelopes to /ingest/collaborall, so copied data passes through the same validation,
mapping and dual-write path as everything else. That gives us one code path to trust, and the reader
still deploys on its own. It keeps its own cursor file, and a re-run is safe because the service's
write-log removes duplicates.
6.4 2. Reliability we demonstrated
We validated all of this against live servers, not local mocks.
End-to-end persistence. We created the full Thing, Datastream and Observation chain and read it
back through the public API.
No duplicates. Observations formed an unbroken 5-minute series, and the stored count matched
the number of distinct cadence ticks: 16, then 455, then 1016, then 1423 across the run. MQTT
publish offers no server-side uniqueness guarantee, so this held on our own bookkeeping alone.
Restart-safe gap recovery. A gap of several days between runs filled from the stored cursor,
with no duplicates.
Transport resilience. The MQTT and WebSocket session dropped once and reconnected within about
300 ms. We retried the publishes that fell in the gap.
Concurrency safety. After the fix in section 4.4, a race-detector regression test asserts that
many concurrent resolutions of one shared entity name produce exactly one POST.
Two transports, one mapping. We confirmed observation writes over both HTTP POST and MQTT
publish, with no change to the entity mapping.
A real vendor fleet, mapped without touching the write path. The SULO adapter discovered 51
container slots across 27 sites and 50 linked devices from the REEN API, and produced valid STA
entities through the same validation, mapping and dual-write path as the synthetic and Collaborall
data. No change was needed downstream of the adapter.
Self-healing under intermittent transport failure, observed to completion. The client-side
TLS-intercepting proxy of section 4.1 drops a fraction of connections, so the first SULO run
registered only 10 of the 51 slots and failed the rest mid-chain. Because entity resolution is
name-keyed and observation writes are checked against the write log, later cycles completed the
missing slots instead of duplicating the finished ones: 10 slots and 12 observations at first read,
then 17 and 21, then 48 slots and 152 observations after four hours, unattended, with no
duplicates and no manual replay. Because upsert is idempotent, a failing network cost us
latency instead of data. This is the same property that made the multi-day
catch-up above safe, observed here against a transport that was failing rather than absent. The
three slots still absent are not transport casualties; section 4.12 explains them.
6.5 3. Where the effort went
This assessment is relative and qualitative, and it answers the testbed's question about the practical
effort of exposing sensor data through a common standard.
Area
Effort
Why
STA entity mapping and write path
Low
The entity model is well specified and complete. Mapping and name-keyed upsert were mechanical.
Concurrency and idempotency
Medium to high
The shared-entity upsert race, and observation idempotency under real latency, needed real fixes.
Authentication
Medium
Reads are authenticated too, and an empty password returns 500 instead of 401.
Server-specific behaviour
Medium
The customised WBD profile, and a server rejecting aSensor.metadata payload the specification allows (see section 4.7).
Semantic reconciliation
Medium
Duplicate ObservedProperty names, placeholder units, four vocabularies for one concept. Not fixable in code.
Network and transport
Medium
Timeouts, MQTT reconnects and slow paths during catch-up all needed handling.
Reading an unfamiliar STA server
Low to medium
460 datastreams, unknown domain, no documentation, no contact, and still only days. See section 7.
Onboarding a third vendor API
Low
A documented REST API behind the existing adapter contract. Nothing downstream of the adapter changed. See section 3.1.
Vendor data semantics
Medium to high
Forecasts mixed into historical readings, and a confidence flag marking some readings unusable. See section 4.11.
Reproducibility and tooling
Low to medium
Dummy adapter plus a one-commanddocker compose.
Table 5. Relative implementation effort by area.
Conforming to the standard was cheap, and production robustness against a real network, a real
authentication scheme and a real non-standard server is where the engineering lived. That second
cost exists whether or not you use a standard, which is why adopting one is close to free, and
section 7.3 works that argument through.
6.5.1 3.1 The third adapter, as a measured data point
The design set a reproducibility target: a contributor should be able to wire a second, non-SULO vendor
adapter and produce valid observations in at most one working day. The SULO/REEN adapter is the honest
test of it, because it is the first adapter written against the contract after the contract had
stabilised.
The mapping work landed inside that budget and the shared write path needed no changes at all. Two
things cost more than the mapping, and neither was the standard's doing:
Reading the vendor's documentation. The published guide renders as a JavaScript single-page
application with no content in the served HTML, so the machine-readable OpenAPI description behind it
had to be found and read directly. Once located it was clear and complete, and it is where we found
the forecast rule in section 4.11 that no amount of inspecting responses would have revealed.
Deciding what a Thing is. The vendor models a container slot and a container separately, and
attaches fill-level history to the slot, because a physical bin can be swapped out without breaking
the measurement series. Mapping the container instead would have fragmented every stream on the first
bin replacement. That is a modelling judgement about the vendor's domain, not a translation problem,
and it is the kind of decision the standard cannot make for you.
The transferable point: the cost of an additional source sits in understanding the source, and it does
not scale with the number of targets. That is the economic argument of section 7.3, measured once.
6.6 4. Problems we ran into
Each entry covers what we saw, why it happened, what we did about it, and whether it is closed.
6.6.1 4.1 Authentication edge cases
Two separate problems, both cheap to fix and expensive to diagnose.
Reads are authenticated too. We first attached Basic credentials to writes only. But our upsert
starts with a GET ?$filter=name eq ... probe, and WBD authenticates every request, so entity
resolution failed before we ever attempted a write. We now attach credentials to every request.
The wider point is that our upsert reads before it writes. Anything that authenticates reads
differently from writes therefore breaks entity resolution before the write path is exercised at all.
A credential scheme that looks fine for a write-only client is not necessarily fine for an upserting
one, and every STA client that resolves entities by name is an upserting one.
An empty password returns HTTP 500, not 401. FROST-Server's BasicAuthFilter splits the decoded
user:pass string on the colon and assumes two fields. An empty password produces a one-element
array, an ArrayIndexOutOfBoundsException and a 500. A useful rule of thumb for operations: a 500
from this endpoint usually means a missing or empty credential rather than an outage.
This one cost far more than it should have. A 500 sends you to look at the server, and a 401 sends you
to look at your own configuration. We spent time on the former when the answer was the latter. Section 6.4 returns to the same theme. A server whose
error response carries the wrong meaning sends the client to the wrong place, and whoever is
debugging pays the difference.
Status: closed. Both are fixed on our side. The 500 is a robustness issue in the FROST
implementation rather than anything to do with the standard, which is verdict A territory in section
4.10, and worth reporting upstream.
We designed for an API key the vendor does not have. On the source side, our design assumed the
SULO platform authenticated with a static API key, and specified a zero-downtime rotation procedure
around two simultaneously valid keys, with the config to match. The vendor documentation, when it
arrived, settled it differently: REEN has no API-key scheme at all. Credentials are the account's
username and password, exchanged at a /session endpoint for a short-lived token sent as an X-Token
header. These are the credentials used to sign in to the web interface.
The dead configuration was easy to delete. The more useful observation is what replaced the
requirement. We wanted rotation without an outage; what we got was better, and from a different
mechanism. Because the adapter holds a token rather than the credential, and re-authenticates
automatically on any 401, a password can be rotated with no coordination at all: change it at the
vendor, update the secret, and in-flight polling recovers by itself. The capability we had written a
runbook for fell out of ordinary session handling.
The first lesson: do not specify a credential lifecycle before you have the vendor's
authentication documentation. We wrote a procedure, a config surface and an operational runbook
for a scheme that never existed, and unwound all three. The second: treat "session token,
refreshed on 401" as the default assumption for a vendor REST API, because it is both more common
than a static key and easier to operate. The one design consequence worth carrying: a token refresh
must be single-flight. Datastreams are polled concurrently, so an expired token is discovered by
many goroutines at the same instant, and without that guard every one of them opens its own session.
6.6.2 4.2 "FROST-Server" is not one thing.
The WBD deployment is not a standard build. Things expose non-standard navigation properties, namely
Configurations, ControlledDevices, DeviceSecrets and Projects, plus a restricted boolean on
each entity. Together these form a project-scoped authorisation and device-management extension.
Our live concern was whether new entities would need an explicit Project association, or would be
blocked by the restricted model, and neither turned out to apply: a standard STA POST of a new
Thing was accepted and created with restricted: false, with no Project association required.
We only established that by probing: create a Thing, read it back, see what the server added. The
extension is not announced anywhere a client can find it, which is the same gap section 6.3 asks to
close, and it is not a hypothetical inconvenience. Another participant in the programme registered a
batch of Things on this server before learning about the Project model, then had to go back and patch
every one of them to attach a Project. That is the cost of an undiscoverable data model: work that has
to be redone, not work that fails cleanly up front.
Status: closed for the testbed, still open as an integration-contract item. The lesson is that an
integration contract must not assume a standard server, and that a deployment's authorisation model
stays invisible until you probe it. Note also that customised is not a criticism here. The extensions
this server carries, project scoping and device management, address real needs the core standard does
not cover, and their maintainer has said they would like to see them standardised. The gap is discoverability, because a
client cannot find out that they exist.
6.6.3 4.3 Entity names collide on a shared server.
We write into a FROST-Server we share with other testbed parties. Our name-keyed upsert, a
GET ?$filter=name eq 'X' followed by a create when absent, will bind to someone else's
entity that happens to be named X, and our entities equally pollute their namespace. STA has no
concept of ownership, tenancy or namespacing, so nothing prevents this.
The failure mode is worse than a naming clash. Our upsert
treats a name match as identity. If another party has already created an ObservedProperty called
Fill level, our datastreams attach themselves to their entity in silence, and inherit their
definition, their units and their intent. No error appears, because from the server's point of view
nothing unusual happened. Two organisations' data then share a concept neither of them agreed to
share, and the only symptom is that someone's units look wrong much later.
Resolution. A configurable ENTITY_NAME_PREFIX, for example CF_, goes in front of every entity
name the layer creates. The mapper exposes a single set of *EntityName methods, so the upsert probe
and the created payload can never disagree about the name. Status: closed, though it is a
workaround for a missing concept rather than a fix.
It is also a workaround with a real cost, which is why section 6.2 asks for the underlying capability.
A prefix marks our entities as ours by making them match nobody else's. So if two
parties should converge on one shared Fill level concept, prefixing stops that. We have traded accidental collision for guaranteed fragmentation. That is
the right trade for a testbed and the wrong one for a national ecosystem, and it illustrates why
namespacing belongs in a profile rather than in each participant's config file.
6.6.4 4.4 A concurrency race duplicated shared entities in silence.
What we saw. On WBD, the ObservedProperty Fill level and the Sensor
Dummy fill-level sensor DUMMY-1 1.0.0 each existed five times.
Why. The scheduler resolves Datastreams concurrently, one goroutine each, sharing one Writer.
Entity upsert is check-then-act, meaning it looks first and creates second, with a gap in between
where another request can slip through: cache miss, then FindByName, then POST when absent.
Entities with per-container unique names, Thing, Location, Datastream and FeatureOfInterest, never
collide. But Sensor and ObservedProperty are shared across all five datastreams, so five goroutines
each saw the entity as absent and each posted a copy. Two defects sat underneath that: the in-memory
entity cache was an unsynchronised map, which is a genuine data race, and check-create was not
atomic per key.
Why the standard is implicated. FROST does not reject duplicate names, and it is right not to,
because STA does not require entity names to be unique. So there is no 409, no error and no signal.
The race writes wrong data, and our conflict-refetch branch never fired. This is the clearest
example we found of a gap in the standard causing an implementation bug, and section 6.2 sets out
what we would change.
Resolution. A mutex-guarded cache plus a single-flight group keyed on entity and name, so
concurrent first-sight creates collapse into one FindByName and one POST. Single-flight means one
shared lookup that every waiting caller reuses. This sits in the writer layer, independent of
scheduler concurrency, and a race-detector regression test backs it. Status: closed. Cleaning up
the redundant entities already created on WBD is still outstanding.
How we found it. No error appeared; what surfaced it was a routine log line reporting
sta duplicate entity ... count=5 when a later run's name lookup returned five hits where it
expected one. The duplicates had been created cleanly and accepted cleanly, and they would have stayed
invisible indefinitely if the lookup had not been written to notice multiplicity. Had we written
FindByName to take the first result, which is the obvious implementation, this defect would still
be in production and we would not know.
We caught a silent data-integrity bug only because of a defensive check we had written for
unrelated reasons. Any pipeline doing name-keyed upserts against
STA without such a check has this bug and no way to see it. A server-side uniqueness constraint would
have turned invisible corruption into a loud, immediate 409 on the very first run (see section 6.2).
Impact. Low functional risk, because name lookups resolve to the lowest @iot.id and the stored
observations are correct. But it is real data-quality debt, and the map race could have caused a panic
under load. It also does not grow after the first race, because later runs find the existing copies
and reuse the lowest id. That is why it is easy to miss, because the symptom shows up once, early, and
then stops.
6.6.5 4.5 Idempotency costs throughput, and it bit us during catch-up.
A synchronous HTTP probe, GET .../Observations?$filter=phenomenonTime eq ..., gates every
observation on both transports before we write it. During a multi-day catch-up over a high-latency
path, that probe rather than the write became the bottleneck. It repeatedly hit the client timeout
while MQTT publish kept pace.
Resolution. The FROST HTTP timeout is now configurable, FROST_HTTP_TIMEOUT_SECONDS, default 15
seconds, so it can absorb slow paths. Status: closed.
On the MQTT path the write itself is asynchronous and fast, while the safety check in front of it
is synchronous and slow. The slower protocol throttles the faster transport, and adding MQTT
capacity changes nothing, because requests queue in front of the probe. Under burst load the probe
hit the client timeout while publishes kept pace, which looks like a transport failure and is not
one.
The optimisation, and a better version of it than we had planned. We already knew that when the
stored cursor guarantees an observation is new, the probe is redundant and we could skip it. Hylke van
der Schaaf described a fuller pattern in
discussion #22: run one thread
for non-observation entities and one thread per datastream for observations, ask once per datastream
what its latest observation is, and from then on treat anything with a later timestamp as new and
create it directly. Then upload in batches using data arrays rather than one request per reading. That
replaces one probe per observation with one probe per datastream per run.
It also explains something we had recorded as unexplained. Inserting an observation takes a lock on
its datastream, so running several threads at one datastream buys nothing, which is why adding
concurrency did not move our throughput. Single-threading per datastream costs us nothing and
removes the contention, so we are adopting the pattern.
One case still needs our own bookkeeping. That invariant holds for a source that only moves forward
in time. Ours does not, because the vendor API mixes settled estimates with forecasts and can
re-issue a corrected value for a timestamp we have already passed. For a back-dated correction, the
newest stored reading tells us nothing, so the write-log stays, which is part of what section 6.2
asks the standard to remove.
A second implementer measured the same cost. After we posted this finding, the team
running the TGV/BDJ connector reported their own numbers from three weeks of continuous operation
across three STA targets in
discussion #23. During a backlog
replay of roughly 4,500 observations they measured the per-observation probe at 150 to 300 ms on a good
connection, which made it the bottleneck by a wide margin, exactly as it did for us. They also report
the one mitigation we had not tried: the dataArray batch extension cuts the write side,
500 observations per request instead of one POST each, but the probe cannot be batched, so on a
replay path it still dominates. Their working arrangement is three-layered rather than one mechanism:
seed the cursor from the server's latest observation at startup, write optimistically and treat a
409 as "already delivered", and fall back to the probe only when replaying a previously failed write.
The third layer carries the wider lesson. Optimistic 409 is the cheapest correct approach, and it
only works if the server enforces uniqueness and says so with that status. Nothing in the
specification requires either, and no server documents which it does, so a client cannot adopt the
cheap path without testing each target by hand. The probe is one cost, and the other is that the
efficient alternative is undiscoverable.
This one belongs to nobody's implementation. The probe exists because STA defines no idempotent write,
and both servers behave correctly, so section 4.10 gives it neither verdict A nor verdict B. The
specification is silent here rather than ambiguous, and that silence costs a round-trip per
observation, paid by each producer separately.
6.6.6 4.6 The second source broke our canonical model.
What happened. We scoped the Collaborall integration expecting fill-level data and found none of
it. What the server held instead was 460 datastreams of pump pressure and flow, indoor and outdoor
air quality, CO₂, particulates, motion, noise, light, ground acceleration on three axes, and a full
weather station. Our fill-level-only filter would have mapped zero of them.
The shapes did not fit our model either, in three ways.
Non-numeric results.OM_TruthObservation results are booleans, such as motion, and
OM_CountObservation results are integers, such as light_level. Our canonical
Observation.Result was a float64, and our FROST payload typed result as float64.
Arbitrary phenomena. Our ObservedProperty was a closed list with FillLevel as its only v1
member, and both the push endpoint and the mapper filtered on it.
Placeholder and null units. Some unitOfMeasurement fields hold the string "...".
Resolution: generalise instead of special-casing. We added an optional passthrough mode alongside
the existing fill-level mode.
canonical.Observation gained ResultRaw json.RawMessage, which carries the verbatim STA result,
and frost.Observation.Result became any. Numeric vendors are unaffected.
The validator skips numeric range checks when ResultRaw is set, because a boolean has no range.
canonical.Datastream gained optional Passthrough* fields: source names, the unit triple,
observationType and sensor name. When they are present the mapper reproduces the source entity
faithfully. When they are absent it falls back to the fill-level defaults, so existing adapters stay
untouched.
The push endpoint no longer filters on fill level. The observed-property string is now the per-Thing
stream key.
We blank placeholder units, "...", at the source boundary instead of passing them on.
Status: closed. We changed nothing in the ingest core, the state store, or the
dual-write and MQTT paths, which is the adapter seam paying off.
Lesson. We over-specialised our model where STA had not. STA already had
OM_TruthObservation, OM_CountObservation and an untyped result. We narrowed it and then had to
widen it back. When you map onto a general standard, resist encoding your first use case's assumptions
into the canonical layer. Read the places the specification leaves open as a list of the variation you
have not met yet.
6.6.7 4.7 One payload, valid on one server and rejected by another.
What we saw.POST /Sensors against Collaborall:
frost http 422: {"error":{"code":"STA-422",
"message":"The metadata field must be a string.",
"target":"metadata"}}
The identical Sensor payload succeeds against WBD.
Why. We serialise Sensor.metadata as an inline JSON object:
What the specification says. We read the normative text, because it determines whose
defect this is. OGC 18-088, STA 1.1, Table 13, types metadata as "Any (depending on the value of the
encodingType)", mandatory. Table 15 lists three encodingTypes, application/pdf, SensorML and
text/html, and the accompanying prose states that "Other encodingTypes are permitted". It adds that
the metadata property may contain either a URL to metadata content, or the metadata content itself, in
the case of text/plain or other encodingTypes that can be represented as valid JSON.
An inline JSON object under encodingType: application/json therefore sits inside the
specification, and nothing in the standard requires metadata to be a string. A server that
rejects a JSON object with "The metadata field must be a string" enforces a constraint the
specification does not state, which makes the rejection a server-side defect.
Where the constraint comes from. The rejecting server is not a FROST build at all, but an
independent PHP/Laravel implementation (see section 1.5), and the message gives it away. "The metadata
field must be a string." is the verbatim output of Laravel's built-in string validation rule. The
constraint is almost certainly one line in a request-validation schema, 'metadata' => 'string',
written by someone reading the three encodingTypes the specification enumerates, all of which do carry
string-shaped content. That is an understandable place to land, and it is still narrower than
what the standard permits. Section 4.10 works through what follows from that.
Where the standard still contributes. The same passage closes by saying that clients are
responsible for whatever string parsing is needed to handle metadata content, and all three enumerated
encodingTypes carry string-shaped content. An implementer reading that could reasonably type the
column as a string. The declared type is Any, and the surrounding prose assumes a string. The mixed signal makes the mistake easy,
and section 6.4 asks for the loose typing to be pinned down as well as the bug fixed.
Impact. Sensor registration fails, so the whole Datastream chain that references it fails. Our
error classifier also treats the 422 as temporary and retries a payload the server will never accept.
Status: open. The plan of record is to serialise metadata as a JSON-encoded string, which
satisfies "must be a string" while preserving the structure, to make the serialisation per-target since
one server accepts objects and the other does not, to classify STA-422 as permanent rather than
retryable, and to report the rejection to the operator.
This remains the most instructive interoperability finding in the testbed. A server can present itself as STA-conformant and still reject a payload the
specification allows. You cannot take a conformance claim on trust, which is the argument both for
testing against several implementations (see section 7.7) and for publishing a test suite that a
candidate server must pass (see recommendation 3 in section 8.1). See also section 6.4 for the part of
this the standard should tighten.
6.6.8 4.8 Well-formed STA data can still be hard to use.
The Collaborall server is a well-formed STA server carrying data that is hard to consume.
Duplicate ObservedProperty names with different @iot.ids and different definitions. Several
distinct co2_levels, gauge_pressure, battery_level and temperature_indoor entries, with
definitions drawn variously from QUDT, Wikipedia, DBpedia and CF conventions.
Placeholder or null unitOfMeasurement values, "..." and null.
One node maps to many datastreams, one ObservedProperty maps to many sensors, with no consistent
naming convention across them.
None of this breaks STA, and all of it means a consumer cannot answer "give me all CO₂ readings in
this area" without a human-curated mapping. We achieved interoperability of form. We did not achieve
interoperability of meaning. This is the gap a national profile is best placed to close (see section
6.1), and in our view it is the highest-value thing Geonovum could add on top of the standard.
6.6.9 4.9 Smaller findings worth recording
phenomenonTime is polymorphic. The same field is either an instant, 2026-01-02T03:04:05Z, or
an interval, start/end. Every consumer has to handle both. Ours parses it and takes the interval
start.
@iot.id order is not input order. Concurrent creation means server ids reflect completion
order: 96 for DUMMY-0001, 97 for DUMMY-0003, 99 for DUMMY-0002, and so on. Harmless,
since we resolve entities by name, but confusing when you reconcile by hand.
MQTT publish is fire-and-forget. There is no acknowledgement and no server response, so we can
only verify persistence with a REST read-back. That is correct behaviour, and it means the MQTT path
cannot report its own success. Monitoring has to be built separately.
Which OData functions a server implements is not discoverable. STA borrows its query syntax from
OData, but not from one version of it. On the WBD server substringof('Sulo', name), the OData v3
spelling, works; contains(name, 'Sulo'), the v4 spelling that replaced it, returns 400. Nothing
in the service document or the $metadata response says which dialect is in force, so a client
either probes or hard-codes one server's answer. This is a concrete instance of the gap section 6.3
argues about: the query surface is uniform in shape but not in vocabulary, and the difference is only
visible by trial.
Auditing our own footprint on a shared server is harder than it should be, and that is our fault.
Our entity names follow a different template per entity type, so no single name prefix finds all of
one vendor's entities: a Thing is CF_Sulo Container 514419 but its Datastream is
CF_Fill level — Sulo Container 514419, with the phenomenon first, a lowercase "level", and an
em dash. Verifying a run meant six differently-shaped queries and produced two false negatives
before we noticed. The name templates are fine for upsert, which is what we designed them for, and
poor for audit, which we did not. We already write vendor and clappform_source_system on Things
but on nothing else, and had we put that marker on every entity type, one query would answer "what
did we write?". Section 5.2 makes the case for properties; this is the case for using it
consistently.
We recorded the server's observation ids but not its entity ids. Our state store had columns for
the FROST @iot.id of each Thing and Datastream, and setters to populate them, and nothing ever
called those setters. Only sta_observation_id was written, so a local row could not be joined to
the entity it created without re-resolving it by name, which is the fragile path the previous point
describes. Nobody noticed because name-keyed resolution works, which is the point: a reconciliation
gap stays invisible until you need to reconcile, and we only needed to when auditing a partial run.
Status: fixed. The ids are now written back when the entity chain resolves. Where several targets
are configured the first one wins and a single id is kept, matching the convention already used for
sta_observation_id; per-target bookkeeping stays deferred under ADR-004.
6.6.10 4.10 Whose problem is it: the implementation, the deployment, or the standard?
Almost every problem above arrived looking like "the standard is unclear" or "their server is
broken", and those two diagnoses lead to different actions on different timescales. One team can fix
a server bug in a week. A specification change takes years and needs a working group. A client
workaround takes an afternoon and makes the problem permanent, so choosing wrongly is expensive. The
Sensor.metadata rejection in section 4.7 serves as the worked example.
6.6.10.1 4.10.1 Why this was answerable at all
We only got a clean answer because our two targets are two independent implementations, not two
configurations of one. WBD runs a customised FROST-Server, and Collaborall runs a separately written
PHP/Laravel implementation (see section 1.5). When two independent codebases disagree about the same
payload, you can read the disagreement as evidence. One of them is right, or the specification is
ambiguous enough that both are. With one server, or two builds of one server, you
cannot tell the difference between "this is the rule" and "this is what this codebase happens to do",
and that is the position most integration projects are in.
6.6.10.2 4.10.2 The three possible verdicts, and how to tell them apart
There are three, and teams miss the middle one.
Verdict
Test
Remedy
Timescale
A. The lax implementation is wrong
The specification mandates a constraint, and server X accepts violations of it
Report to X, and fix your own payload, which was invalid all along
Weeks, and your data may already be wrong
B. The strict implementation is wrong
The specification permits the payload, and server Y rejects it
Report to Y, and work around it per target meanwhile
Weeks
C. The standard is ambiguous
The text can be read both ways in good faith
Clarify the specification. Both servers stay defensible until then
Years
Table 6. Three verdicts, three remedies, three timescales.
The test does not turn on which server is more popular, or which one you developed against. Read the normative text and check whether it mandates, permits, or is silent.
6.6.10.3 4.10.3 Applying it to the testbed's evidence
Three cases came up in this testbed, and other participants found two of them and reported them on
the programme's discussion board. They land in three different rows of that table, one each.
Datastream.observationType: verdict A, the lax implementation is wrong. Another participant had
been creating Datastreams with observationType: "instant", which FROST accepted for months.
Collaborall rejected it with a 422 listing the permitted values. The specification types
observationType as a ValueCode, mandatory, drawn from a fixed list, so "instant" was never valid.
FROST was lax, the client had built on that laxity without knowing, and the strict server was right.
The specification needs no change. The permissive server needs to stop being permissive, and each
client that developed against it has invalid data to correct.
Sensor.metadata as a JSON object: verdict B, the strict implementation is wrong. This one is
ours. The specification types metadata as Any, permits encodingTypes beyond the three it
enumerates, and explicitly contemplates the metadata content sitting inline for encodingTypes that
can be represented as valid JSON, so our payload is legal. The rejection comes from a Laravel
string validation rule rather than a reading of the standard (see section 4.7). The strict server
is wrong here, which mirrors the previous case on the same server.
unitOfMeasurement with a null symbol: verdict C, then B. A third case, also from another
participant. A boolean-valued stream with symbol: null was rejected as "the unit of
measurement.symbol field is required". The specification says unitOfMeasurement is a mandatory JSON
object of three key-value pairs, and also that a Datastream without a unit of measurement, for example
an OM_TruthObservation type, shall have null values in those properties. So null is explicitly
permitted and the rejection is over-strict. But the specification does not say what to do
about a partially null unit, which is what that stream had, so it is part B and part C.
The same server is wrong in one direction on one field and right in the other direction on another.
Independent implementations diverge like this until someone publishes a test
suite.
6.6.10.4 4.10.4 The positive and negative case for each diagnosis
Taking our own metadata finding, and arguing both sides:
Treating it as an implementation bug. In favour: it is true, and it is fixable now, with one
validation rule, one deployment and no committee. It keeps Any flexible, so the legitimate
uses all remain available: a URL to a PDF datasheet, an inline SensorML document, a JSON object. It
puts the correction where the defect is, so the ecosystem does not inherit a constraint nobody agreed
to. Against: it fixes one deployment and nothing else. The next implementer reads the same three
enumerated encodingTypes and the same instruction that clients must perform string parsing, makes the
same choice, and we discover it again on the next server. "File a bug and wait" is also not a strategy
a client with a delivery date can rely on, because we still need to write today, against a server that
rejects us today.
Treating it as a specification problem. In favour: fixing the text fixes each implementation at
once and for good, and it makes the requirement testable, which turns interoperable in principle
into interoperable in practice. The ambiguity is real and demonstrable, and it amounts to mixed
signalling: Any as the declared type, three enumerated encodingTypes that are all string-shaped,
and a sentence telling clients to do string parsing. Against: timescales run to years, so it helps
nobody this quarter. Narrowing Any risks removing flexibility that existing deployments
legitimately rely on. Mandate "string" and you break servers storing objects. Mandate "object" and
you break the PDF-URL case the specification explicitly describes. Worst of all, "the standard
should be clearer" is an easy place to park a problem so that nobody owns it, which looks like
progress and delivers none.
The third option, which teams take without arguing for it: treat it as your own problem.
Serialise the object to a string and move on. In favour: it works this afternoon, against any
server. Against: an unstated constraint becomes the de facto standard. As each client degrades to the
strictest server it meets, the strict behaviour spreads and the loose but legal form falls out of use,
until no client exercises the permission the specification grants. We care most about this failure
mode, because it is invisible and because we were about to do it.
6.6.10.5 4.10.5 What we concluded
The three remedies are not alternatives, because they work on different timescales. So:
Report it as an implementation defect, with the normative citation attached rather than a
question. A report that quotes the specification is actionable. One that asks "should this be a
string?" invites the answer "yes".
Work around it per target, never globally. Our serialisation is a per-Target setting, so
the strict server's constraint does not become our behaviour against the other target. We
accommodate that server without adopting its reading.
File the ambiguity separately, as a clarification rather than a restriction. Say how metadata
is encoded when encodingType is a JSON media type, and reconcile the string-parsing sentence with
the declared Any type. That is a note, not a redesign, and it removes nobody's flexibility.
Push hardest on the thing that fixes the whole class, a published interoperability test suite
(recommendation 3 in section 8.1). A suite that asserts what servers must accept and what they must
reject would have caught all three cases in this section, the lax server, the strict server and the
ambiguous field. A test suite is the only remedy here that scales, because it converts prose into
something a server either passes or fails, and it is the one deliverable that helps all three
verdicts at once.
The transferable rule. When a server rejects your payload, do not start by changing the payload.
Read the normative text first and decide which of the three rows you are in, because the remedy differs
and only one of the three is your bug. Most teams skip this step and default to whatever makes the
server stop complaining. In doing so they ship someone else's misreading as their own behaviour and remove
the evidence that anything was wrong.
6.6.11 4.11 A vendor's forecasts would have truncated our history without warning.
This finding arrived with the third integration, after we wrote section 4.10, so we get to use that
section's framework on a case it did not consider. The verdict is verdict A, our implementation,
and it is the most consequential defect we found, because unlike everything above it destroys data
rather than duplicating or rejecting it.
The SULO waste-container fleet reports into the REEN platform, whose fill-level endpoint returns a
mixture of three kinds of value. The vendor's own documentation is explicit about them: values that are
frozen, meaning analysis is complete and the number will never change; values that are pending,
still being refined as more data arrives; and values that are predicted, which are forecasts of
the near future derived from historical trend. The documentation also states the rule that makes the
three separable: a predicted value never carries a past timestamp, so a forecast is always dated in
the future at the moment you read it.
So a single request for a container's fill-level history returns both measurements and forecasts, and
the only thing separating them is that the forecasts are dated ahead of now.
Why that is dangerous rather than merely untidy. Our write path already rejects a future-dated
observation: the validator flags it in_future against a clock-skew tolerance, and it never reaches
the server, so that part was fine. The damage came from a second rule interacting with the first.
Our poll cursor advances to the newest observation the cycle covered, and "covered" includes, by
design, observations that were definitively rejected. A permanently invalid reading must not be
re-fetched forever, so the cursor moves past it. in_future was classified as definitive.
Compose the two and the failure is silent and total:
REEN returns a forecast dated three days ahead, alongside today's real readings.
The validator rejects the forecast as in_future. Correct.
Because that rejection is definitive, the cursor advances to the forecast's timestamp.
Every genuine measurement arriving over the next three days is now older than the cursor, so it is
discarded as before_cursor.
Three days of real data for that container are gone, no error is logged, and the stream looks
healthy because the cursor is moving.
Each rule is defensible alone, and together they convert a vendor's forecast horizon into a
data-loss window that the freshness watchdog does not catch, because the datastream is being polled
successfully the whole time.
The fix, and where it belongs. The adapter discards any row newer than the current time before
handing the batch to the shared write path. We put it in the vendor adapter rather than in the
validator on purpose: "my source publishes forecasts in the same collection as measurements" is a fact
about REEN, not about SensorThings, and the adapter layer exists to stop vendor quirks from
becoming core behaviour. A regression test asserts that a future-dated row is dropped and that the
cursor stays on the last real measurement.
What generalises. Two things, and neither is specific to this vendor.
A cursor that advances past rejected records is a data-loss risk whenever the rejection reason can
be caused by data you have not seen yet. The rule is safe for "malformed" and "out of range", which
are properties of the record itself. It is unsafe for in_future, which is a property of the record
relative to now, and time moves. Any implementation that batches "skip it" and "advance past it"
into one decision should re-check it against that distinction.
Ask what a historical endpoint contains before trusting its ordering. We would not have
found this from the response alone, because a forecast and a measurement are the same JSON shape. It
was in the prose of the vendor documentation, two sections away from the endpoint reference. A
status or frozen field that distinguishes matured from projected values should be read as a
filter you must apply, not as metadata you may keep.
For STA specifically this is an argument for section 6.2: had the observations carried a
vendor-side identity and maturity marker that survived into the entity model, the distinction would
have been machine-checkable at the boundary instead of inferred from a timestamp.
6.6.12 4.12 A container is only published once it reports, so a dead sensor is invisible rather than stale.
Four hours into the SULO run, 48 of 51 container slots had reached the server. We expected the
remaining three to be the last stragglers of the proxy problem in section 4.1. They were not. The
transport had fully recovered, and checking the three directly against the vendor API showed two
unrelated causes, neither of them a transport fault:
Slot
Readings at source, 7 days
Sensor installed
Why it never published
500196
0
no
No device is linked, so the slot has never produced a reading.
509319
0
no
The same. Both are asset records for containers that are not instrumented.
493012
173
yes
It has data. Its newest reading is about 13 hours old, and our first-sight lookback window is one hour, so the window came back empty.
The first two are correct behaviour: no sensor, no observations, nothing to publish. The third is a
defect, and the mechanism behind it is more general than the one slot it caught.
Where it comes from. Our ingest path returns early when a cycle produces no accepted observations,
before it resolves the entity chain. That is a deliberate efficiency: do not create a Thing, Location,
Sensor and Datastream on a server for a stream that has nothing to say. The consequence is that a
container is created in STA by its first observation, not by being discovered. Combine that with a
first-sight cursor that looks back one hour, and any container whose sensor stopped reporting more than
an hour before we first saw it is never published at all.
Why this is the wrong failure mode. The container that most needs attention is the one whose sensor
has died, and that is the one that disappears. A consumer reading the server sees 48
containers and has no way to learn that three more exist: absence is indistinguishable from
non-existence. Our own monitoring is fine, because the freshness watchdog counts a datastream that has
been polled but never written as stale, so internally we know. The published data is
incomplete, and nothing on the server says so. An asset inventory taken from the STA server would be
missing the broken containers.
What we would change. Create the entity chain at discovery rather than on first observation. A
Thing, its Location and an empty Datastream describe a real asset whether or not a reading has arrived,
an empty Datastream being how a server says "this container exists and
has told us nothing". Widening the first-sight lookback only moves the boundary and would
still lose a sensor that died a week ago. We have not made the change yet, because it trades a silent
omission for entity churn on sources with many dormant assets, and that trade should be a deployment
choice rather than a default.
The transferable point, and it is not about this vendor. Publishing driven by data arrival, rather
than by asset discovery, reshapes what your dataset means: it becomes "things that are working"
rather than "things that exist". If a consumer is going to use the server as an inventory, and section
5.1's argument that the entity model fits a domain invites that, then the set of Things has to
be complete independently of whether observations are flowing.
6.6.13 4.13 We derived a staleness alarm from the wrong quantity.
Once the third source was running steadily we set its per-datastream freshness expectation from the
cadence we had measured: REEN publishes an estimate for each container every hour, so one hour went
into the configuration. Our watchdog turns that into an alarm threshold of three times the cadence,
giving three hours. Within a day it was reporting thirteen of fifty-one streams as stale.
None of the thirteen were stale. Checking each against the vendor API showed we held every reading
the source exposed, and the flagged containers had not yet had a new estimate published. The lag
between the hour a reading describes and the moment it becomes readable varies per container, and we
measured it spread across the fleet like this:
Publication lag
Slots
under 1 hour
4
1 to 2 hours
21
2 to 3 hours
11
3 to 4 hours
7
4 to 5 hours
3
5 to 6 hours
3
The mistake is a category error, and it is easy to make. Two different quantities were both being
called "how often does this update":
Cadence is the interval between consecutive readings. It is a property of the sensor and its
duty cycle, and for this fleet it is one hour.
Publication lag is the delay before a reading becomes visible to a client. It is a property of
the vendor's processing pipeline, and here it runs from minutes to five hours depending on the
container.
A staleness alarm is asking "should I have heard something by now?", which is bounded by cadence
plus lag. We fed it cadence alone, so it alarmed on the vendor's normal processing delay. The
failure mode is worse than a wrong number: an alarm that fires on healthy streams gets
muted, and then it is not there for the container that has stopped reporting.
What we changed. The expectation is now derived from the observed lag rather than the cadence, so
the threshold clears the slowest normal publisher while still catching a dead sensor inside half a day.
The measurement matters more than the value, since both quantities are per-deployment: another REEN
customer, or another vendor, will have a different lag profile and copying our number would move
the error.
Why this belongs in a report about a standard. STA carries phenomenonTime and resultTime, which
describe when a thing was measured and when its result was produced. Neither says when the record
became retrievable, and that third timestamp is the one a monitoring system needs. We could
only recover it by polling and watching what appeared. A consumer reading the server after the fact
cannot compute it at all. This is the same gap section 6.2 describes from the identity angle, seen from
the freshness angle: the entity model records the measurement well and records almost nothing about the
pipeline that delivered it.
A note on how we got this wrong twice. The one-hour figure came from sampling three containers and
reading their median gap. That was right about cadence and silent about lag. We made the same shape of
mistake earlier in the same integration, concluding from three slots that the fleet ran a single sensor
model when a survey of all fifty-one found two. Both times a three-item sample produced a confident and
incomplete answer, on an estate of fifty-one that one request enumerates in full. Where the population
is that small, count it rather than sampling it.
6.7 5. What the standard does well
We earned each of these during implementation rather than reading them off the specification.
6.7.1 5.1 The entity model needs no extensions.
We mapped a domain the standard's authors did not have in mind, municipal waste containers,
onto Thing, Location, Sensor, ObservedProperty, Datastream, Observation and FeatureOfInterest, with no
extensions and no compromises. A container is a Thing, its position is a Location, the fill sensor is a
Sensor, "fill level" is an ObservedProperty, and the pairing is a Datastream. We then mapped pump
telemetry, seismics and weather onto the same seven entities without touching the mapping layer. The model absorbed both
without strain.
6.7.2 5.2 properties extends the model at no cost to conformance.
Every entity carries a free-form properties bag. We used it for vendor native ids, source-system
tags, an area label, a synthetic marker, expected cadence and first-seen timestamps. STA models
none of that, and we needed all of it. Nothing we carry forces a non-standard field onto the wire, so
our data stays readable by any STA client while losing nothing from the source. This is the feature
that makes adopting the standard a non-destructive decision. You do not have to throw away your
vendor-specific data to conform.
6.7.3 5.3 One uniform query surface, no SDK required
$filter, $count, $expand, $orderby and $top over plain HTTP meant that:
our entire upsert is GET ?$filter=name eq '<escaped>' followed by a POST, with no vendor API
involved;
verification is curl, reproducible by any reviewer with no tooling, for example
GET /Things?$count=true&$filter=startswith(name,'Dummy');
$expand let the reader inline Locations onto Things, and Sensor plus ObservedProperty onto
Datastreams, so rebuilding the entity graph costs one request per level instead of one per related
entity;
$filter=phenomenonTime gt <cursor> with $orderby and $top is an incremental-sync primitive we
got for free. It is the query a cursor-based reader needs, and we did not have to negotiate
it with anyone;
our client is a few hundred lines of net/http.
Compare that with a typical vendor telemetry API: bespoke pagination, bespoke filtering, a proprietary
SDK, and no way to express "count the things whose name starts with X".
6.7.4 5.4 Navigation-scoped creation
POST /Things(96)/Locations creates the Location and the association in one request, so there is no
separate link call and no risk of an orphaned entity between two writes. It is a small feature that
makes the write path simpler.
6.7.5 5.5 Two transports, one entity model
STA specifies a REST interface and an MQTT interface over the same entities. We switched observation
writes from HTTP POST to MQTT publish over WebSockets with no change to the mapping layer, because the payload
stays the same and only the pipe changes. Changing transport characteristics without touching your data model is unusual
and valuable. Our MQTT run sustained a multi-day catch-up that HTTP was struggling with.
6.7.6 5.6 Self-describing enough for a generic reader
This is the strongest property of the lot. @iot.selfLink and navigation links on every entity mean a
client can walk a server it has never seen, with no prior knowledge of its contents. Our
collaborall-reader enumerates Things, follows each to its Datastreams with Sensor and ObservedProperty
inlined, reads Observations per stream, and rebuilds the entity graph. It did that against a server
whose domain we did not know, whose operators we never spoke to, and for which no documentation exists.
This is what turns STA from a data format into an integration strategy, and section 7 works through
the consequences.
6.7.7 5.7 A server can be both a source and a sink.
Because the same model describes both directions, the same client code serves both. Our
internal/frost package holds a reader.go next to writer.go, sharing the same entity types.
Copying one STA server into another therefore takes a small program. Federation, mirroring,
aggregation and archival follow from the same code.
6.7.8 5.8 Provenance and idempotency metadata have a standard home.
Observation.resultQuality and Observation.parameters gave us standard places for validated_by and
validation_version provenance, and for the vendor idempotency key parameters.raw_observation_id,
without polluting result or inventing a side channel. Our observations describe how we processed
them, and any STA client can read that.
6.7.9 5.9 Non-numeric observations are already in the model.
OM_Measurement, OM_TruthObservation, OM_CountObservation and friends, all with an untyped
result. As section 4.6 recounts, the standard was more general than our canonical model, and that
generality was correct.
6.8 6. The four biggest things we would improve
Ordered by importance rather than by what they cost us. The two at the top limit what the standard can
deliver, and neither cost us the most time. Each item states the concrete problem we hit and a
concrete change, either to the standard or to a national profile layered on top of it.
6.8.1 6.1 Bind the semantics: a controlled vocabulary for definitions, UCUM for units.
Problem.ObservedProperty.definition is a free URI and unitOfMeasurement is free text. In real
data (see section 4.8) that produces four different co2_levels ObservedProperties with definitions
from four different vocabularies, and units written as "...". A consumer cannot reliably ask
for "all CO₂ measurements in this region" across servers, or even within one server.
Why this comes first. It is the only item on the list that limits the standard even when every
implementation is correct. Everything else here makes STA harder to implement. This one makes the result less
useful however well you implement it. We achieved interoperability of form, and
not of meaning, and meaning is what a data consumer wants.
It is also the only item anyone can fix without changing the specification, which gives it the most
realistic route to being fixed at all.
Another team has now done it, and reported the cost. In
discussion #24 the TGV/BDJ
connector team pin every ObservedProperty.definition to a CF Standard Names URI and every
unitOfMeasurement.symbol to a UCUM code, publishing the mapping they use for temperature, humidity,
CO₂, air pressure, PM2.5, groundwater level, wind speed, precipitation and solar radiation. Their
assessment of the effort was "maybe two hours to set up and zero maintenance since". That is a second
party confirming the high-value, low-effort claim, and it makes the recommendation
concrete: the work is picking the authority, not building machinery.
The same thread carries a caution worth passing on. A fixed parameter-to-unit table holds for
atmospheric and hydrological quantities but breaks once engineering units enter, mass flow being the
example given, and the durable form is a conversion table anchored on base units rather than a list of
pairs. The related point is that an STA server is not the right place to hold that truth: what the
standard should settle is how a unit is written, so that two servers spell the same unit the same
way, leaving conversion to the client or a shared library. That is an argument for our proposal rather
than against it, and it sharpens what to mandate.
Proposed change. At the standard level: require definition to resolve to a term in a declared
vocabulary, and require UCUM for unitOfMeasurement.symbol. At the national-profile level, which is
our recommendation to Geonovum: mandate a specific vocabulary per domain, require UCUM, and publish a
profile that can be checked for conformance. This is the single highest-value thing a Dutch STA profile
could specify, because the base standard leaves that layer open, and gemeenten,
waterschappen and their suppliers gain from agreeing on it together.
6.8.2 6.2 Give entities and observations an identity story.
These were two findings for us, at two different altitudes, and they share one root cause. STA
defines no natural-key identity, so every writer has to implement check-then-act. At entity level
that races. At observation level it forces a check before every write.
Problem, at entity level. STA specifies no uniqueness constraint on name or any other natural
key, no server-side upsert, and no conditional create. So every integrator reinvents check-then-act:
GET ?$filter=name eq ..., then POST. That is an extra round-trip per entity, and it is racy. We hit this and produced five copies of two entities (see section 4.4).
Problem, at observation level. For our source, a datastream and a timestamp together identify one
reading, and nothing enforces that pairing. Re-polling, catching up or retrying after a timeout can
all duplicate observations, and MQTT publish is fire-and-forget with no acknowledgement at all, so you
cannot even tell whether your write landed. We built a Postgres write-log and a synchronous
per-observation GET probe to compensate, and that probe became our throughput bottleneck during
catch-up (see section 4.5).
We first wrote this up as though that pairing were a natural key in any deployment. It is not, and we
did not know the counter-example until Hylke van der Schaaf, who maintains FROST-Server and sits on
the STA standards working group, set it out in
discussion #22 on the testbed
board. Several observations on one datastream at the same phenomenonTime are common in the water
domain, where a laboratory repeats an analysis on one sample with different parameters. He gives two
further cases: two Things may each carry a Datastream of the same name, and two Things may share a
name while sitting at different Locations.
That changes the ask, and it lands in the domain of the server we write to, since WBD is a
waterschap. Uniqueness is a property of a use case rather than of the standard, so a specification
that mandated it would break valid shapes rather than protect them. What we ask for below is
therefore narrower than our first draft: not enforcement, but a way for a client to create without
reading first, and a way to find out what a given server does enforce.
Why it matters. Two reasons, and the second is the serious one.
The first reason is cost, and it is the largest piece of infrastructure the standard pushes onto its
clients. A write-log, a stored cursor and a check before every write is not a small amount of code,
and every reliable STA producer has to build it.
The second is worse: the entity-level failure is silent. FROST does not reject duplicate names,
and it is right not to, because STA does not require them to be unique. So there is no 409, no error
and no signal, only duplicated data. The standard makes the naive implementation the incorrect one
and gives the implementer no feedback that they got it wrong. Anyone writing to STA concurrently
either has this bug, or has found and solved it themselves.
What the standard is already doing about it. STA v2 addresses the entity half directly. As Hylke
van der Schaaf quotes in discussion #22,
v2 states that the service usually generates the id of an entity, and that a service may also let
the client specify one. The same capability came up from the other direction in
discussion #12, where a
participant asked for external identifiers as a first-class field and hylkevds described both the
new definition attribute in v2 and client-defined ids as answers. That is the capability we needed
and did not have. If we can supply the id, our
upsert stops being check-then-act altogether. We derive an id from the vendor key, write at that id,
and the primary key deduplicates, which client-side locking cannot guarantee. That removes the race
and a round-trip per entity together.
Proposed change. Three asks remain, and they are about portability rather than enforcement.
Make client-specified ids discoverable. The v2 text says a service may allow them, which is
the kind of optional behaviour a portable client has to establish by trial. A
conformance class, or a statement in the landing document, turns that into something a client can
read (see section 6.3).
Pair it with create-or-replace at a known id, so a client can be idempotent in one request
rather than treating an already-exists error as a normal path.
For observations, keep any dedupe on a datastream and timestamp pair strictly opt-in and
discoverable, because the water-domain case above shows a mandatory version would break valid
data. Give the MQTT path an acknowledgement mechanism as well, so a publisher can tell a delivered
write from a dropped one without a REST read-back.
And one route that needs no specification change at all. Hylke van der Schaaf points out in the
same thread that a server administrator can add uniqueness constraints in the database, after talking
to local domain experts, including constraints on fields inside the properties object, and that this
project could have done so. That is the fastest remedy available to us. On the shared testbed instance
a unique constraint on Sensor.name and ObservedProperty.name would have turned our silent
duplication into a 409 on the first run.
That reframes the finding: on a server several parties write into, the constraints are a deployment
decision that every writer depends on and none of them can see. A national profile is where that
belongs, alongside the vocabulary work in section 6.1, and it is a second argument for the
capabilities document in section 6.3, because an administrator's added constraints are the kind of
local truth a client cannot guess.
6.8.3 6.3 Require a machine-readable capabilities document.
Problem. We discovered everything by probing, one request at a time: which extensions exist,
whether MQTT is available, which authentication scheme is required, whether duplicate names are
rejected, what the page size is, and whether the server is a standard build or carries custom entities
and an authorisation model (see section 4.2). None of that is discoverable from the service root, which
lists collections and little else. Every one of those probes was a manual investigation.
Why it matters. It makes onboarding a human task, and it makes generic clients defensive and slow.
OGC API - Features solved this with a required /conformance endpoint. It is also the cheapest
item on this list to specify, which is why it sits above section 6.4 despite costing us less in
absolute terms.
A second implementer reached the same conclusion from three servers at once. The TGV/BDJ team run
FROST v1.1, Collaborall v1.1 and a Fraunhofer v2.0 server concurrently, and report in
discussion #22 that they had to
build an explicit write-capability discovery step: does this target support dataArray batching, does
it accept client-specified ids, what does it do when handed a duplicate. All three behave differently
and, in their words, there is currently no way to discover that programmatically. Our own list of
probes came from two servers; theirs comes from three, including a v2.0 implementation, and it produced
the same finding. Two independent teams building the same missing feature by hand is the argument for
specifying it.
Proposed change. A required machine-readable capabilities document that declares supported
conformance classes and extensions, transports, authentication schemes, limits, and any non-core
entities.
6.8.4 6.4 Pin down the loosely typed fields, and make rejections classifiable.
Problem.Sensor.metadata is declared as Any, its shape is said to be governed by
encodingType, only three encodingTypes are enumerated, and the same passage that permits others tells
clients to perform string parsing. As section 4.7 records, one server took that as licence to require a
string and rejected a payload the specification allows. The server carried the defect, and the
standard carried the ambiguity behind it, and this item asks only for the second half.
The rejection then compounds the ambiguity, because STA-422 is a FROST-specific code and nothing
in the standard tells a client whether a rejection is permanent or temporary. Ours classified the
422 as temporary and retried, indefinitely, a payload that server was never going to accept.
Why it matters. Implementers read a field declared Any alongside prose that assumes a string,
and they diverge. Divergence in the core entity set breaks the assumption most adopters start with,
that STA-conformant implies portable. Without a classifiable error model, each divergence becomes
a retry bug as well as a compatibility bug.
Proposed change. State normatively how metadata is encoded when encodingType is a JSON media
type, and reconcile the string-parsing sentence with the declared Any type. Either answer works, and
the specification should not imply both. More broadly, audit the fields declared Any and give each a
normative encoding per encodingType. Separately, define normative error codes for the common failure
classes, with an explicit permanent-versus-temporary distinction, so clients can implement correct
retry behaviour without per-server heuristics.
6.8.5 6.5 Smaller items we are not expanding here
Three further gaps cost us less and are still worth reporting.
Bulk observation insert sits in an extension rather than in the core, so a portable client cannot
assume it, and 1,400 observations meant 1,400 posts plus a check each. We first wrote this up as a
capability gap. Hylke van der Schaaf uses data arrays and batch processing
as his normal import path in
discussion #22, so the facility works. The
problem is the one running through this section, that a client cannot discover whether a given server
offers it (see section 6.3).
There is no multi-tenancy or ownership model (see section 4.3). And phenomenonTime polymorphism
forces every consumer to write the same defensive parser (see section 4.9).
6.9 7. Why other organisations should adopt the standard
This section argues from our three integrations rather than from a general preference for standards.
6.9.1 7.1 The evidence: integrating a stranger's server took days.
We pointed our reader at sta-server.collaborall.net, an organisation we had no integration agreement
with, whose data we had never seen, in a domain of pump telemetry, seismics and air quality with no
overlap with our own. We had a base URL and a set of credentials.
We did not have documentation, a schema, an SDK, a data dictionary, a sample payload, an API changelog,
or a single conversation with their engineers.
Working replication took days, and most of that was our own model being too narrow (see section 4.6)
rather than the source being hard to read. For comparison, a typical vendor telemetry integration in
our experience takes weeks: read the docs, get a sandbox key, reverse-engineer pagination, discover the
rate limits the hard way, write bespoke parsing, then repeat all of it for the next vendor.
That gap, days against weeks, is the argument, and you can measure it.
6.9.2 7.2 Integrate once, be consumable by everyone.
A bespoke API means N consumers times M producers, so N times M integrations, each needing its own
bilateral agreement, and STA collapses that to one. Publish once, and every existing STA client can
consume you with no work on your side and no negotiation. The marginal cost of your second data
consumer is close to zero, and you never have to know who they are.
For a gemeente, waterschap or provincie this is the decisive argument. You cannot list your future data
consumers, so you cannot negotiate with them in advance, and a standard lets you serve the ones you
have not met.
6.9.3 7.3 You are already paying the hard costs.
Section 3 answers anyone who argues that standards are expensive. The effort went
into network access, authentication, server behaviour, concurrency and idempotency, and every one of
those costs is identical whether you expose a bespoke API or an STA endpoint. Ingestion reliability is
hard whatever you publish at the far end. The STA-specific part was the cheapest line in the table.
So the incremental cost of conforming is small and the interoperability upside is large. Bespoke costs
the same and returns less.
6.9.4 7.4 You do not have to build the server.
Mature open-source implementations exist, and FROST-Server, which one of our targets runs, is one.
Adopting STA as a publisher can mean deploying software rather than writing it. The build-versus-adopt
calculation weighs deploying an existing server against designing, building, documenting, versioning
and supporting an API forever.
6.9.5 7.5 Conforming does not mean losing your data model.
The properties bag (see section 5.2) carried every vendor-specific field we had: native ids, source
system, cadence, provenance and synthetic markers, with no loss and no cost to conformance. That answers the common objection, that the standard does not
model our domain. Model the bulk in standard entities and carry the rest in properties. You lose
nothing, and the bulk becomes interoperable.
6.9.6 7.6 It is small enough to implement directly.
Our production STA client is a few hundred lines of plain net/http (see section 5.3). No SDK
dependency, no vendor lock-in, no version-upgrade treadmill. A standard you can implement in an
afternoon from the specification is a standard with low adoption risk, and that is not true of
most enterprise integration standards.
6.9.7 7.7 The honest caveat
Adopters should go in knowing section 6. STA-conformant does not guarantee portability between servers.
Test against more than one server implementation before you call a client done. We found a hard
portability failure (see section 4.7) on the second server we touched, and we would have shipped it
undetected had we tested against one only. Note also that the payload was one the specification allows,
so the server, and not our client, was at fault. Expect to build idempotency yourself as well (see
section 6.2).
Neither of those changes the conclusion, though both change your project plan, and day one is the
cheapest time to learn them.
6.10 8. Recommendations
6.10.1 8.1 To Geonovum and the testbed programme
Specify a Dutch STA profile that closes the semantic gap (see section 6.1): a mandated
vocabulary per domain for ObservedProperty.definition, UCUM for units, and a conformance checker.
This is the highest value per unit of effort of anything on the list. The base standard leaves that
layer open, and a national ecosystem gains from agreeing on it.
Add namespacing and constraint guidance for shared, multi-party servers (see sections 4.3 and
6.2): entity-name conventions, an ownership convention in properties, and which
uniqueness constraints a deployment should set in its database and publish to the parties writing
into it. Uniqueness cannot come from the standard, because it depends on the domain, so on a shared
server it has to come from an agreement. A profile is where that agreement belongs.
Publish an interoperability test suite that a candidate server must pass, including the
ambiguous cases: Sensor.metadata encoding, duplicate names, non-numeric result types and interval
phenomenonTime. Section 4.7 argues for this more directly than anything else in this report. A
server there rejected a payload the specification permits, and a published suite would have caught
that before we did. Over-strict servers damage interoperability as much as permissive ones, and
nothing currently tests for them.
Take section 6 to OGC as implementation feedback from a real multi-server deployment. Each item
is concrete, cheap, and backed by an incident in this report.
6.10.2 8.2 To organisations considering STA
Test against at least two independent server implementations before you call a client done (see
section 7.7).
Budget for the operational envelope, not the mapping (see section 3), and note that you would pay
that cost anyway (see section 7.3).
Build idempotency on the datastream and timestamp pair from day one, and store a cursor. The
standard will not do it for you, and it is what makes catch-up and restart safe (see section 6.2).
Prefix your entity names on shared servers (see section 4.3).
Our semantic-interoperability finding, and a CF/UCUM mapping put into production by another team
Sections 4.8, 6.1
Table 8. Testbed discussion threads referenced in this report.
6.11.3 9.3 Companion documents
These record the raw evidence behind this report and are contributed alongside it.
Document
Content
sulo-sta-translation-layer-design.md
Architecture, ADRs and the implementation contract
testbed-wbd-e2e-findings.md
Raw end-to-end evidence, responses and verification queries against the Brabantse Delta server
collaborall-source-findings.md
Inspection of the Collaborall source server
sulo-reen-source-findings.md
Inspection of the SULO and REEN vendor API, redacted where REEN's documentation is issued under confidentiality terms
Table 9. Companion documents.
7. Topic 2 TUDelft: Connecting sensors to a SensorThings API server
Dit onderdeel is niet-normatief.
7.1 Acronyms and Definitions
Term
Definition
ETL
Extract, Transform, Load — the pattern of reading data from a source, reshaping it, and writing it to a destination system.
STA
OGC SensorThings API, specifically v1.1 unless otherwise specified. A REST-based standard for publishing and querying sensor observations and their metadata.
In STA, a typed object in the data model (e.g. Thing, Sensor, Datastream, Observation) with defined properties and relationships to other entities.
Thing
Per STA / ITU-T Y.2060: an object of the physical or information world that can be identified and integrated into communication networks — typically the device or platform hosting one or more sensors.
Sensor
In STA, the procedure that produces observations (often a physical instrument, but potentially also a human observer or software algorithm).
Datastream
In STA, a time series of observations grouped by the same Thing, Sensor, and ObservedProperty.
Observation
In STA, a single measured or estimated value at a point or interval in time, linked to a Datastream and describing one ObservedProperty.
ObservedProperty
In STA, the phenomenon being measured (e.g. air temperature, CO₂ concentration).
Implementor
An individual or group deploying a STA service and the surrounding ingestion infrastructure for a given use case.
Upstream
Further from the STA server and closer to the physical sensing device. The sensor itself is the most upstream component in this report.
Downstream
Closer to the STA server and to client applications that consume observations.
Provider
An intermediary service between sensors and the STA server that receives device messages and exposes them to consumers — for example an MQTT broker, a vendor HTTP API, or a domain-specific relay such as SeedLink.
IoT
Internet of Things.
Notation. Italics (Thing) mark the abstract ITU-T / domain concept — a
device or object in the world. Code style (Thing, ObservedProperty) marks a
concrete STA entity type or field in the data model. Everyday English
("sensor", "observation", "temperature") is left unstyled when the meaning is
generic rather than an STA type name.
7.2 Introduction and the Scope of TU Delft's Experiments
This chapter assumes the reader is already familiar with the basics of the OGC
SensorThings API (STA): its core entities (Thing, Location, Sensor,
Datastream, ObservedProperty, Observation, and FeatureOfInterest), and
the idea that clients interact with a STA server through a REST API with
OData-style query parameters. A concise introduction to STA is given in the report
introduction; the OGC STA v1.1
specification remains the
authoritative reference. Where this chapter goes deeper, it focuses on what an
implementor must decide when connecting real, heterogeneous IoT devices to a
STA server — not on explaining the standard from first principles.
Heterogeneity in the Internet of Things (IoT) is a well-documented phenomenon
and a major source of tension when implementing IoT standards such as STA.
Sensing devices employ a wide range of transports (e.g. HTTP versus MQTT),
payload formats (e.g. JSON versus CSV), sampling regimes (e.g. near-real-time
versus hourly), identity schemes (e.g. MAC address versus DevEUI), and are often
tied to opaque vendor platforms. Yet STA expects a coherent entity graph: a
linked set of well-defined objects that a client can traverse and query.
STA is generic by design. At the start of this testbed it was therefore unclear
whether it could absorb that heterogeneity in practice. The scope was to explore
the tensions that arise when deploying a network of heterogeneous IoT devices
against server infrastructure that implements STA. It is important to state what
STA is not: it is not a complete software stack for the entire sensing IoT
lifecycle. STA sits further downstream, closer to clients and applications. It
defines a data model, entity relationships, and expected API behaviour, but it
does not prescribe how sensors are provisioned, how raw payloads are decoded, or
how observations travel from a physical probe across networks and intermediary
services into the shape the API expects. That entire upstream path from device
to STA endpoint is duly left to the implementor.
Managing that upstream path is an IoT and extract–transform–load (ETL) problem.
TU Delft addressed it with an in-house, open-source middleware layer, the
Realtime Ingestion and Management Engine
(rime). A full analysis of rime is outside the scope of this chapter; the
tensions listed here were surfaced through building and operating it, so a brief
introduction to its role is necessary.
7.2.1 The rime Middleware: IoT Management and ETL Pipeline
Two packages in rime were used during this testbed:
rime-ingest — the STA-centric ingestion pipeline that connects to
upstream providers, transforms payloads, and writes to STA servers; and
rime-client — a browser-based STA client for browsing, visualising, and
downloading data from any STA endpoint.
For each connected device, rime-ingest performs the following steps in
sequence:
Connect to the provider — manage communication with an upstream service
(e.g. poll an HTTP API or subscribe to an MQTT topic).
Decode the wire format — decode applied by the
provider or transport.
Deserialize the payload — parse the message into an in-memory structure.
Decapsulate to the Thing message — extract the device-level payload
from any provider-specific wrapper. The same physical Thing should yield
equivalent messages regardless of which provider carries them (e.g. a
different MQTT broker).
Decapsulate to Sensor messages — split the Thing payload into one or
more logical sensor readings where a single device exposes multiple
quantities.
Transform to STA entities — map each reading to STA Observation fields,
assign canonical ObservedProperty and Datastream names, and attach the
correct entity links (Thing, Sensor, Datastream).
Push to STA server(s) — write the resulting observations to one or more
STA endpoints (in this testbed, both v1.1 and v2 servers).
Following a brief description of the sensor
setup, this
report focuses on the tensions encountered in applying the SensorThings
API, the
benefits of using STA. Altough this testbed required
the deployment of STA v1.1, STA v2 is on the horizon and the implications of
this revision are explored. Finally, the findings are
condensed as recommendations.
7.3 Testbed Technical Summary: IoT Providers, Networks and Sensors
TU Delft's testbed contribution connected 31 Things to two testbed web servers
implementing STA v1.1, and to a
third, development server implementing v2.0, as implemented by the two Topic 1
developers earlier in this report.
As a reminder, Thing here follows the
ITU-TY.2060 definition adopted
by both STA v1.1 and v2:
A thing is an object of the physical world (physical things) or the
information world (virtual things) that is capable of being identified and
integrated into communication networks …
The 31 Things span four unrelated vendors and four distinct sensor models. In
total, these sensors observe 19 different ObservedProperties, and are located
across three countries.
None of the 31 sensors communicate directly with the STA servers. Each
transmits through an intermediary provider:
External LoRa air temperature and humidity sensor.
The Things Network
1
Netherlands → Italy
Table 1 — Summary of Things connected during this testbed.
7.4 Tensions and Experiences in Applying the SensorThings API v1.1
In the upcmoing subsections, the encountered tensions are elaborated on. Where
possible, recommendations are made and links are provided to Github discussions
which run adjacent to the topic.
7.4.1 IoT Heterogeneity: Managing Transformation and Ingestion
The message that flows downstream from an IoT device is rarely ready to fit
neatly into STA — or into any other system or standard, for that matter. IoT
heterogeneity goes beyond the shape of the decoded payload: network managers
must contend with variety along the entire network stack.
This challenge is not considered a consequence of applying STA, but rather
a general practical reality of the IoT domain. TU Delft's approach was to
develop an ingestion and transformation pipeline abstract enough to handle the
variety of sensors, while focusing entirely on STA compatibility.
Implementors should be aware that STA by no means solves the wider heterogeneity
problem. It does, however, offer a stable baseline, a robust data model — one
that is, in our view, further strengthened in v2. That baseline makes it
possible for developers to build and manage software products that handle
back-end ingestion and management while remaining out-of-the-box STA compliant.
Recommendation: Treat STA as the integration target and an
interoperability contract, not as a substitute for provider and model-specific
management. Budget explicitly for an ingestion layer (whether rime-ingest,
Node-RED, or any other middleware) that owns transport, identity, and payload
transformation.
7.4.2 The Permanent Identity of a Thing and Sensor
This issue was discussed on the testbed GitHub repository
(#12).
This issue is partially addressed in STA v2.
STA assigns every entity a service-local identifier (@iot.id in v1.1, id in
v2). That value uniquely identifies a record within one server, but it is not
a durable external identity: it is typically server-generated, and the same
physical device will receive a different @iot.id if recreated or published to
another STA instance. The standard does not require a Universally Unique
Identifier (UUID), DevEUI, MAC address, or similar key on Thing or Sensor.
Without a project convention, it is therefore easy to create two Things (or
Sensors) that represent the same physical device — with the confusion,
metadata drift, and fragmented timeseries that follow.
In STA v1.1 the usual place for a durable key, if required, is an unconstrained
entry in properties, for example on the Thing:
"properties":{"mac_address":"70:ee:50:7f:11:11"}
or:
"properties":{"dev_eui":"24E124707E421111"}
or:
"properties":{"network_station":"RO.TEB11"}
In the TU Delft deployment, however, sensor identity mattered more than
thing identity for ingestion. rime-ingest chooses decoders, deserializers,
and parsers from the identity of the upstream sensing device — the component
that produces the payload — not from the platform that hosts it. The implemented
convention was therefore to place a stable, unique external key in the name
field of the Sensor (MAC address for Netatmo stations, DevEUI for LoRa
devices, station code for seismic instruments).
That choice has a modelling consequence. In STA, Thing and Sensor are not
linked directly: each Datastream points to exactly one of each, and a single
Sensor may be reused across many Datastreams (and thus, indirectly, across
many Things). That reuse is how STA supports treating Sensor as a type or
procedure — for example one shared “DHT22” Sensor linked to many devices. By
putting a per-device UUID in Sensor.name, rime instead treated each Sensor
as an instance identity. The pipeline can then resolve an incoming message to
a unique Sensor (and from there to its Datastreams) without relying on a
shared type catalogue. The trade-off is that Sensor no longer behaves as a
reusable abstract type; it carries the same kind of instance-level uniqueness
assignable to a Thing. That is a valid modelling choice, but it
is a deliberate departure from the Sensor-as-type pattern that STA also permits.
The lack of standardised key names (dev_eui versus deveui versus
DevEUI, and whether the key lives on Thing.properties, Sensor.properties,
or Sensor.name) still undermines cross-system interoperability. STA v2
partially helps by adding an optional definition URI to most entities,
including Thing and Sensor, intended as a link to an external authoritative
identifier — but it still does not mandate a particular scheme.
Recommendation: Decide early whether durable identity lives primarily on
the Thing, the Sensor, or both, and document a project-wide key
convention (e.g. always dev_eui, mac_address, or network_station). Prefer
keys that are already unique in the upstream network. If middleware routes on
sensor identity — as rime-ingest does — put that key where the pipeline can
query it reliably (Sensor.name, Sensor.properties, or v2 definition).
Be explicit whether Sensor is modelled as a reusable type or as a
per-device instance; mixing the two silently is a common source of
duplicates. Track STA v2 definition for cross-service identity, but do not
expect it alone to replace a documented convention.
7.4.3 Domain-Specific Canonical Field Enumerations and Value Codes
STA v1.1 is a generic standard that imposes few limits on the user. That is
likely a feature, not a bug — but users should be aware that, within certain
domains, it may be desirable to further restrict the inputs of certain entity
fields.
Consider, for example, the entity ObservedProperty, which specifies the
phenomenon of an Observation and has the following fields:
name: CharacterString
definition: URI
description: CharacterString
properties: JSON Object [0..1]
Without enforced categories for any of these fields, a database can easily
accumulate semantically identical objects under different names. STA is
extensible, and additional enumerations can be imposed. The approach taken in
rime-ingest was to define a set of canonicalDatastreams that the
transformation pipeline always refers to. Transformation therefore happens at
object creation and is limited to canonical Datastream and ObservedProperty
names.
Recommendation: Define a closed vocabulary of ObservedProperty and
Datastream names (and, where possible, definition URIs) before ingestion
begins. Enforce that vocabulary in the pipeline rather than relying on
operators to choose consistent free-text labels.
7.4.4 Duplication Management
STA does not impose restrictions on the creation of duplicate entities. This
includes "partial" duplicates, where all fields except @iot.id are identical
but entity linkages differ — for example, two ObservedProperty entities with
identical fields linked to different Datastreams. It also includes full
duplicates, such as identical Observations linked to the same Datastream.
Pushing duplicate Observations is a common problem when polling a server for
new readings, and was encountered with the Netatmo HTTP API. The solution
implemented in rime-ingest was to check consistently for object existence
before writing to a STA server. To reduce the overhead of an existence check for
polling type transports, the last message from a given Sensor is kept cached
in memory and and the comparsion happens locally, reducing the need for a
redunant round trip.
Recommendation: The implementor should be aware that duplicate prevention
remains their responsibility: STA continues to permit duplicates in both v1
and v2. Prefer idempotent writes keyed on external identity and
phenomenonTime (or an equivalent natural key), or accept duplication risk
and carry out periodic de-duplication prunes.
7.4.5 Equivalence and Identity
Further to the previous issue, STA does not define what constitutes
equivalence (equal to) or identity (is) between entities. Consider, for
example, two Location entities with identical fields, linked to different
Things. It is at the discretion of the implementor to decide whether those
Locations are the same place reused, or two coincidentally identical records —
and whether the ingestion layer should merge, reuse, or always create anew.
This decision cascades: equivalence rules for Sensor, ObservedProperty,
Location, and FeatureOfInterest determine how aggressively the pipeline
deduplicates, and how portable the resulting graph is across STA servers.
Recommendation: Document explicit equivalence rules per entity type
before go-live (e.g. "Locations are equal if coordinates and name match";
"ObservedProperties are equal if definition URI matches"). Implement those
rules in middleware; do not leave them implicit in operator behaviour.
7.4.6 Unassignable Features of Interest
This issue is resolved in STA v2.
The FeatureOfInterest entity represents the feature being observed by a
sensing entity. For an indoor thermometer, this would be the air inside a given
room. Each Observation is linked to this common FeatureOfInterest. STA v1.1
links FeatureOfInterest exclusively to Observations, which creates a
problem for the ingestion pipeline: there is no way to infer which
FeatureOfInterest should be assigned to incoming Observations.
Consider a typical multi-sensor setup, where a server receives messages from
several sensors. Each message usually contains some identifying key (e.g. the
MAC address of the device) that can be used to resolve the Sensor in the
database — provided the identity issue in discussed
earlier is resolved. From the
Sensor, the system can trace the Datastreams, which generally gives enough
information to parse a message and assign an Observation to the appropriate
Datastream. When creating that Observation, however, there is no way to
query the STA system for the appropriate FeatureOfInterest, because STA v1.1
defines no relationship between FeatureOfInterest and any non-Observation
entity.
In this testbed, when applying STA v1.1, there was no practical solution other
than relying on server-side convenience behaviour, which auto-generates a
FeatureOfInterest from the Location of the Thing when none is supplied
with the Observation. Note that this auto-generation is a server feature,
concindentally implemented by both Topic 1 participants, not a requirement of
the STA standard itself.
This issue is resolved in the upcoming STA v2, which modifies the
FeatureOfInterest entity extensively: renaming it to Feature and linking
it to Datastream as well as to Observation.
Recommendation: On v1.1 deployments, decide deliberately whether to
accept auto-generated FeaturesOfInterest or to maintain an
out-of-band mapping (Thing/Datastream → FeatureOfInterest) in middleware.
Prefer STA v2 where Feature–Datastream linkage is desirable.
7.4.7 Modelling Sensor Arrays
All Things in TU Delft's testbed contribution are single physical objects
that contain multiple sensors and are, for practical purposes, indivisible.
This includes, for example, the consumer-grade Netatmo, which measures
temperature, humidity, and pressure in one sealed unit. That differs from
arrangements where sensors are wired to a transmitter or data logger and may
be removed, added, or swapped.
From a modelling perspective, for the sealed multi-sensor case it is acceptable
to treat the device as a single sensing object with multiple Datastreams
(cardinality 1..*).
Recommendation: Model sealed multi-parameter devices as one Thing /
one Sensor with many Datastreams. Reserve a finer Thing/Sensor split
for deployments where sensing elements are physically swappable or
independently calibrated.
7.4.8 Usefulness and Definition of Virtual Entities
Things need not be physical objects; they include entities "that are capable
of being identified and integrated into communication networks". This raises
the question of whether a virtual Thing may have a virtual Location, or
whether it is rather the absence of a Location that makes a Thing
virtual. Irrespective of that question, it remains unclear in which situations
modelling a virtual Thing is beneficial and if the differentation should be
made explicitly or implicitly.
Recommendation: Introduce virtual Things only when they clarify a real
operational role (e.g. an algorithm, aggregator, or connector — see
here). Avoid virtual entities that exist solely
to fill schema slots; prefer documenting absence (no Location) over
inventing placeholder Locations.
7.4.9 "Real" and "Derived" Observations
This issue was discussed on the testbed GitHub repository
(#3).
It is common for sensors to transmit data that is not ready for consumption —
for example, readings in voltages or counts. In the simplest cases, linear
arithmetic converts such "raw" figures into "real" observations that represent
the actual ObservedProperty. In other cases, complex non-linear functions
may be applied to a timeseries with extensive post-processing to remove noise,
producing an one or more entirely new timeseries.
In both cases, it can be reasonable to treat the initial measurement as the
"real" measurement and the produced observations as derived. A counter-
argument, made
in the same discussion,
is that all observations are themselves derivations. Irrespective of that
position, it is not recommended to subclass the Observation entity (or
any entity) with a convoluted type such as DerivedObservation.
Implementors are reminded that virtual Sensors can represent algorithms or
procedures that produce new Datastreams. Such a virtual Sensor may be
associated with the "raw" Datastream and produce "derived" Datastreams.
Recommendation: Decide early whether clients need both raw and derived
series. If both are required, model derivation as a virtual Sensor (or
documented procedure) producing separate Datastreams — not as Observation
subclasses. Record the processing relationship in properties or provenance
metadata so consumers can trace lineage.
7.4.10 Observations as Instants, Arrays or Any
This issue was discussed on the testbed GitHub repository
(#15).
This issue is largely addressed in STA v2.
The Netatmo, Milesight, and Dragino sensors used in TU Delft's contribution
observe every five to ten minutes. That regime lends itself to modelling
Observations as point-in-time measurements, where result and
phenomenonTime are scalar values. Conversely, the Kinemetrics sensor samples
at 100 Hz. Creating one Observation per sample is both contrary to waveform
practice and a significant load on the STA and database servers.
STA does allow the result of an Observation to be of type ANY. Each
Kinemetrics Observation therefore uses an array-typed result, with each
result holding five minutes of samples, down-sampled to 2 Hz for storage.
Different result types created issues for rime-client, which initially
expected scalar results and could not unpack arrays. The client fix was
relatively straightforward but incurs a small performance cost: the client must
first probe the Datastream endpoint, infer the datatype from the response, and
then choose the correct plotting function. That approach is incomplete — any
other result shape (e.g. JSON objects) is also valid in STA.
Recommendation: For complex result types, adopt a fixed object structure
with predictable keys, especially where plotting or export is required.
Note that STA v2 introduces a resultType on a revised Datastream entity,
which should become the preferred way to advertise result structure to
clients.
7.4.11 The "Connectors" Discussion
Throughout this testbed, the term Connectors appeared in several discussions
(e.g.
#16).
The term is taken from Fraunhofer's
OpenCitySense
extension, and represents a Thing that manages another Thing — usually a
device. Besides the Connector entity, the OpenCitySense data model also embeds
other virtual components of the IoT lifecycle, such as configurations and
credentials.
The rime-ingest package takes a different approach: it does not extend the
STA data model at all. Connectivity, credentials, and provider lifecycle are
managed exclusively by the application layer.
Either way, the discussion reflects a desire to model aspects of the "IoT
back-end", so that an extended STA could participate in more of the IoT
lifecycle. As noted in
earlier, middleware
such as Node-RED is commonly involved in managing IoT
systems. STA has no native connector concept, and does not appear designed to
own that layer directly; the connector question therefore remains open.
Recommendation: Implementors should treat this as an open design choice.
Keeping credentials and transport configuration outside the STA graph is an
option, unless there is a clear requirement for STA-visible management of
devices. If Connectors (or equivalent) are modelled, document the boundary
between STA entities and operational secrets carefully.
7.4.12 Extensions and the Future of the Standard
This issue was discussed on the testbed GitHub repository
(#16,
#10).
Presently STA v1 includes the
STAplus and the WebSub
Asynchronous Messaging Standard
extensions. The former extends the data model to include concepts of ownership
(see STAplus 1.0),
especially with respect to multi-user contributions such as citizen science
projects; while the latter is a technical extension allowing users to subscribe
to a STA endpoint and receive notification when the result of an
arbitrary query changes. These extensions were not used by TU Delft during this
testbed.
In this testbed, the Fraunhofer STA implementation (FROST) came loaded with
several non-standard management and quality-of-life extensions implemented as
plugins. While TU Delft did not make direct use of any of these plugins due to
time constraints, it was apparent during public discussions that some issues
encountered during this testbed are at least partially addressed by them.
Of note was the
Projects
plugin, which introduces Users, Roles, and Projects to the data model,
facilitating the management of boundaries between various groups pushing to a
common STA server — as was the case in this testbed. The
OpenCitySense
plugin on the other hand extended the data-model to allow for management of
various aspects of the IoT such as sensor configurations and Connectors.
OpenCitySense differs from the approach used by rime, which opted not to
extend the data model in favour of a predominantly file-based, "GitOps"
approach to management.
The absence of standardised management extensions is considered noteworthy,
and throughout this testbed we perceived that each Topic 2
participant was developing their own management approaches. The approaches
implemented by several partners appeared to have enough similarities to merit
the idea that some common standard could serve the various deployments.
Recommendation: Standardised management extensions to the core STA could
allow for the development of management systems, portals and interfaces that
do away with the need for middleware and reduce deployment tensions. This
issue is a SWG issue rather than an implementor's issue.
7.5 Benefits of Using STA
Irrespective of the relatively minor tensions described in the earlier section,
STA, from our perspective as implementors, proved to be an effective standard
for the management of our networks. The most notable gain vis-à-vis
interoperability was seen in the development of the consumer-facing
rime-client
(#9),
which was able to serve results from multiple, unconnected STA servers simply
by changing the target endpoint.
Consider client functionalities such as health-checks, which can leverage the
OData-style querying built into the standard. A health-check was conceived as
a query to each Datastream, requesting the temporal extent (and thereby the
time of the most recent Observation)
(#14).
Consider the STA service at
https://airquality-frost.k8s.ilt-dmz.iosb.fraunhofer.de/v1.1/, which allows
for this relatively complex query through a single URL:
The query and result remained consistent and functionally valid across multiple
STA servers. The client was similarly capable of displaying and placing all
Things of a STA instance on a Leaflet map using the
query:
... and may return any number of observations, say, for plotting by changing the
value passed to $top.
The STA data model is lightweight but was found to be adequately flexible.
Provided that implementors enforce some internal conformity on their data
structures, much domain complexity can be achieved by simply soft-typing
certain fields such as properties. Besides the issue with regard to
FeatureOfInterest discussed in
Unassignable Features of Interest,
navigating the data model effectively was straightforward.
7.6 SensorThings API v2
The targeted versions of STA were v1.0 and v1.1. STA v2, however, is known to
be on the horizon and was up for OGC member voting at the time of writing. The
process appears to be nearing its conclusion, so much so that the Fraunhofer
IOSB FROST server's v2.8 development
snapshot
already includes support for v2. STA v2 updates the data model and the typing
of some fields, and is thus a breaking (or "major") revision, although the
spirit of the standard remains relatively unchanged.
The former is resolved by a modification to the data model that links Feature
(renamed from FeatureOfInterest) directly to Datastream. The latter is
partly mitigated by introducing an optional definition field to most entities,
including Thing, which allows users to store a URI linking the entity to an
external, authoritative source. This helps cross-service identification, though
it does not mandate a specific identity scheme such as a UUID or DevEUI.
During this testbed a public STA v2 server was made
available.
The rime-ingest pipeline was
modified to support v2 of
STA. The modifications to the pipeline were simple, albeit non-trivial. The
structure of the configuration files used to construct the initial STA entities
differed, unsurprisingly, between the two versions, which led to configuration
drift.
While the ingestion pipeline did successfully and simultaneously push to both
v1.1 and v2 STA instances from the same providers, the two versions remain
incompatible. We therefore do not recommend running parallel versions unless
absolutely necessary, considering that migration does not, in our opinion,
appear to be a significant challenge; rime-client, for example, supports v1,
v1.1, and v2 with an almost identical codebase.
From our experience as implementors, STA v2 offers many benefits over v1.1,
especially with regard to extended OData-style querying — such as a richer
$filter option set — as well as the resolution of some tensions described in
this document.
Recommendation: Implementors should be aware of the upcoming STA v2,
which addresses some tensions described in this document and includes other
major improvements that are out of scope to describe here. From our
experience, migration between versions is not a major overhead but is
certainly involved. We recommend that implementors choose deliberately
between versions, and we recommend v2.
7.7 Recommendations
The following recommendations are addressed to implementors of STA:
Budget for an ingestion layer. STA is the integration contract, not the
IoT manager or the decoder. FROST plugins such as Projects and
OpenCitySense can help with management, but they are not standardised.
Standardise external identity and equivalence early. Document where
durable keys live (Thing, Sensor, or both) and which key names to use.
Decide explicitly whether Sensor is a reusable type or a per-device
instance — and do not mix the two silently. Document equivalence rules per
entity type before go-live.
Enforce canonical vocabularies in middleware, or by extending the
datamodel. Close the set of ObservedProperty and Datastream names (and,
where possible, definition URIs) rather than relying on free-text labels.
Own duplicate prevention. Do not expect the API to do it; prefer
idempotent writes keyed on external identity and phenomenonTime.
Model sealed multi-parameter devices simply. Prefer one Thing / one
Sensor / many Datastreams. Reserve a finer Thing/Sensor split for
deployments where sensing elements are physically swappable or independently
calibrated.
Decide a v1.1 FeatureOfInterest strategy. Accept FROST auto-generation
from Location, or maintain an out-of-band Thing/Datastream →
FeatureOfInterest map in middleware. Prefer STA v2 where
Feature–Datastream linkage is required.
Prefer virtual Sensors and separate Datastreams for derived series —
not Observation subclasses. Record lineage in properties or provenance
metadata.
Advertise complex result shapes explicitly. Adopt a fixed object
structure for arrays and composites; use v2 resultType when available.
Keep connectors, credentials, and transport lifecycle outside the core STA
model unless STA-visible device management is a hard requirement.
Use Datastream.phenomenonTime for health-checks rather than nested
“latest Observation” expands — it is far cheaper and is what the field is
for.
Validate against more than one STA implementation. Behaviour can differ
on observationType, unitOfMeasurement, filters, and related edges;
testing only against FROST (or only against one commercial server) hides
interoperability gaps.
Choose one STA version deliberately; prefer v2. Do not run v1.1 and v2
in parallel unless absolutely necessary. Plan migration where v1.1 gaps
(Feature linkage, result typing, external definition) blocked correct
ingestion.
8. Conclusion
Geonovum started this testbed wanting to learn more about what would be needed to make the SensorThings API a broadly adoptable standard for gathering and querying sensor data in the public sector. In particular, Geonovum wanted to learn whether making the SensorThings API a mandatory standard would be beneficial, and what would need to be solved before we could do this. The testbed taught us a number of important lessons and confirmed others.
The use of the SensorThings API facilitates interoperability. The testbed has shown this with practical, quantifiable results. For instance, Clappform was able to connect to the Collaborall server in a matter of days without outside help — no documentation, no schema, no SDK and no contact with the other party's engineers — where a comparable vendor integration usually takes them weeks. TU Delft could point their client at multiple unrelated servers without any issue, by changing only the target endpoint.
When using the SensorThings API you put harmonisation right at the edge, when ingesting sensor data. As TU Delft put it, the SensorThings API is the integration contract, not the IoT manager or the decoder. Clappform noted that the effort of conforming to the SensorThings API was by far the cheapest part of the work, compared to the other things you have to do regardless of harmonisation when configuring sensors for data ingestion: network access, concurrency, authentication and idempotency. Collaborall reached the same conclusion from the connector side and reads it as an argument for the standard: the translation problem is real, but it only has to be solved once, at the edge. These are major arguments for using the standard. It costs little extra effort and provides interoperability at every stage of using sensor data after ingestion.
8.2 What needs attention
When implementing the SensorThings API there are things that need attention. Each participant has mentioned their lessons and recommendations in their own chapters. From the Geonovum perspective the following stood out.
8.2.1 Conformity does not guarantee interoperability
The SensorThings API standard allows for a lot of freedom in semantics. This is what makes it such a broadly applicable standard, but it comes with the danger of having two conforming implementations in the same domain that do not understand each other. Clappform found four different definitions of "CO₂ level" on a single, entirely valid server, with units written as "...", and concluded that they had achieved interoperability of form but not of meaning. Geo Insights arrived at the same conclusion independently, and TU Delft recommends closing the set of names and definition URIs in middleware. When making the SensorThings API a national standard this should be addressed in some way, for instance by letting specific domains make their own semantically interoperable data model extensions. Both Clappform and Geo Insights note that this is the one layer a national profile can pin down without any change to the specification.
8.2.2 Modelling conventions
Regardless of domain, it would also be beneficial to have modelling conventions: conventions on the granularity of Things, naming conventions, how to deal with sensor arrays, derived time series, and so on. The standard allows a lot of freedom in these. Geo Insights states the problem concretely: should a weather station with five parameters be one Thing with five Datastreams, or five Things? The standard allows both, but only consistent choices enable meaningful queries across datasets. TU Delft worked out preferences for sensor arrays, derived observations and equivalence and identity. A national standard would improve interoperability by making more of these choices on a national level.
8.2.3 Implementation choices the standard leaves open
Another important lesson is that there are implementation choices that the standard leaves open which can still lead to interoperability problems. Collaborall found a number of these while implementing their server from the specification, and observed that because FROST was the only other server in use, the behaviour of the reference implementation has quietly become part of the standard. Geo Insights met the same class of problem from the client side, including a server that silently rejected every observation for weeks, and Clappform sets out how to tell an implementation defect from an ambiguous specification, because the two have very different remedies. A centralised interoperability test suite with support for national profiling would be a way to address this, and three participants recommend one independently.
8.2.4 Which version to mandate
The published version of the standard is 1.1. Version 2.0 had not finished its formal approval process at OGC at the end of the testbed, but Fraunhofer has already implemented it and made a 2.0 server available during the testbed, and it solves a significant number of the issues found here: a definition attribute on almost all entities, direct linking of Features to Datastreams, and explicit typing of observation results. Still, at Geonovum we regard 2.0 as the better candidate for a mandatory standard, both for the benefits it has and because the gap between 1.1 and 2.0 is not so large that it would put a major burden on implementers: TU Delft supports 1.1 and 2.0 in an almost identical codebase and also recommends 2.0. Geo Insights did find the payload differences significant enough to require an adaptation layer. The installed base of sensorthingsAPI is low in the Netherlands at the moment making the overall impact low.
8.2.5 Best practices for implementation and governance
Governance around sensor networks and uniform policies for onboarding — that is, all the non-technical, contractual and organisational issues that need to be solved for interoperability — would equally benefit from suggested best practices. This was the clearest single finding for Geo Insights, who could not connect two of their five fieldlabs during the testbed period even though the technical side was ready, and for whom governance took more calendar time than writing code. When sensor networks also harmonise at the organisational level, this would help re-use and interoperability a lot.
8.3 Future work
The conclusions already have recommendations in them that can be worked out in more detail. In addition there are important issues that were out of scope for this testbed but need to be addressed before making the SensorThings API a mandatory standard.
First of all, there are competing standards, both internationally and nationally, that partially overlap with the functionality of the SensorThings API. Within OGC there is OGC API - Connected Systems, which was investigated in the 2025 Geonovum testbed on sensor data. On a national level there is the Digitale Delta API standard for observing water levels, and the Key Register of the Subsurface (BRO), which has implemented its own interface for sensor data on groundwater levels in the registration object Grondwaterstandonderzoek (GLD). There are also related standards, for instance IM Metingen, a Dutch profile of Observations & Measurements, the predecessor of Observations, Measurements and Samples. IM Metingen is also based on NEN3610 2011, this also has a more current version NEN3610 2022. IM Metingen is currently used in multiple standards that incorporate observations and could also be used in conjunction with the SensorThings API. Though upgrading IM Metingen to Observations, Measurements and Samples and NEN3610 2022 should also be considered.
STAplus is an extension to the SensorThings API 1.1 that solves some of the issues raised in this testbed but was out of scope. TU Delft notes that it adds concepts of ownership to the data model, which is the gap Collaborall reports from both the server side and the connector side. We would need to look into whether this could be part of a national profile, and whether it is compatible with the SensorThings API 2.0.
9. Conformiteit
Naast onderdelen die als niet-normatief gemarkeerd zijn, zijn ook alle diagrammen, voorbeelden, en noten in dit document niet-normatief. Verder is alles in dit document normatief.