Final Report Testbed Sensordata 2026

Geonovum Handreiking
Vastgestelde versie

Deze versie:
https://docs.geostandaarden.nl/ngii/def-hr-testbed-sensordata-2026-20260921
Laatst gepubliceerde versie:
https://docs.geostandaarden.nl/ngii/testbed-sensordata-2026/
Laatste werkversie:
https://geonovum.github.io/testbed-sensordata-2026/
Redacteurs:
Frank Terpstra (Geonovum)
Linda van den Brink (Geonovum)
Bart de Lathouwer (Geonovum)
Auteurs:
Frank Terpstra (Geonovum)
Jan Willem Kattouw (Collaborall)
Hylke van der Schaaf (Fraunhofer IOSB)
Justin Schembri (TU Delft)
Mathis van der Voordt (Geo Insights)
Bowen Harkema (Clappform)
Doe mee:
GitHub Geonovum/testbed-sensordata-2026
Alle issues
Dien een melding in
Revisiehistorie
Pull requests

Abstract

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:

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 logo

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.

A typical FROST-Server deployment.
Figuur 1 A typical FROST-Server deployment.

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:

name: myFROST
services:
  nginx: (Usually defined externally)
    image: nginx:1-alpine
    [...]

  frosthttp:
    image: fraunhoferiosb/frost-server-http:latest
    [...]

  frostmqtt:
    image: fraunhoferiosb/frost-server-mqtt:latest
    [...]

  mosquitto:
    image: eclipse-mosquitto
    [...]

  database:
    image: postgis/postgis:18-3.6-alpine
    [...]

volumes:
    postgis_volume: {}

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.

name: myFROST
services:
  nginx:
    image: nginx:1-alpine
    ports:
      - "443:443"
    volumes:
      - "./nginx.conf:/etc/nginx/conf.d/default.conf:ro"

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.

  frosthttp:
    image: fraunhoferiosb/frost-server-http:latest
    environment:
      - serviceRootUrl=http://${hostname}/FROST-Server
      - mqtt_exposedEndpoints=ws://${hostname}/mqtt
      - plugins_coreModel_enable=true
      - plugins_actuation_enable=true
      - plugins_openCitySense_enable=true
      - plugins_projects_enable=true
      - bus_busImplementationClass=de.fraunhofer.iosb.ilt.frostserver.messagebus.MqttMessageBus
      - bus_mqttBroker=tcp://mosquitto:1883
      - persistence_db_url=jdbc:postgresql://database:5432/sensorthings
      - persistence_db_username=${db_username}
      - persistence_db_password=${db_password}
      - persistence_autoUpdateDatabase=false
      # [More settings ...]

Some complete docker-compose examples can be found in the FROST-Server repository.

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:

      # [More settings ...]
      - auth_provider=de.fraunhofer.iosb.ilt.frostserver.auth.basic.BasicAuthProvider
      - auth_db_driver=org.postgresql.Driver
      - auth_db_url=jdbc:postgresql://database:5432/sensorthings
      - auth_db_username=${db_username}
      - auth_db_password=${db_password}
      - auth_plainTextPassword=false
      - auth_autoUpdateDatabase=true
      - auth_allowAnonymousRead=false
      # [More settings ...]

The first time FROST-Server is started with an empty database, a few standard users are created with standard passwords:

  • read / read: A user that can only read entities.
  • write / write: A user that can read, create and update entities, but not delete entities.
  • admin / admin: A user that can read, create, update and delete entities, and can access the Database Status page.

These should be changed as soon as possible.

2.2.3 Deployed FROST-Server @ Brabandse Delta

The server at Brabandse Delta is available under the following URLs:

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:

SensorThings API Core data model.
Figuur 2 SensorThings API Core 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.

SensorThings API Tasking data model extension.
Figuur 3 SensorThings 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.

OpenCitySense Architecture.
Figuur 4 OpenCitySense Architecture.

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.

OpenCitySense Data Model.
Figuur 5 OpenCitySense Data Model.

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.
OpenCitySense Configuration Definitions.
Figuur 6 OpenCitySense Configuration Definitions.

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:

Sensor ManagerFROST-ServerLoRaWANConnectorList DeviceModelsDataPOST ThingLinks to DeviceModel@idPOST OnboardDevice TaskPOST Task(OnboardDevice, ThingID of Device)@idOnboard DeviceUpdate Task: DoneSensor ManagerFROST-ServerLoRaWANConnector

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:

Sensor ManagerFROST-ServerLoRaWANConnectorLoRaWANPlatformLoRaWANApplication ServerLoRaWANNetwork ServerLoRaWANJoin ServerPOST TaskOnboardDevice,(ThingID of Device)MQTT Push:New Task (new Sensor with ThingID)GET Thing(sensor)expand=DeviceModel, ConfigurationDataLink Thing(sensor)to Thing(Connector)Create Datastreamsloop[For each Sensor linked toDeviceModel]POST Register Sensor(ApplicationID, DeviceID, DevEUI,JoinEUI, JoinServerAdr,NetworkServerAdr,ApplicationServerAdr)ResponsePOST Register Sensor(DeviceID,DevEUI,JoinEUI)ResponsePOST Register Sensor(DeviceID, DevEUI, JoinEUI, FrequencyID, PhyVersion, LoRaWANVersion, AppKey)ResponsePOST Register Sensor(DeviceID, DevEUI, JoinEUI, AppKey, NetworkServerAdr, ApplicationServerAdr)ResponseGet DeviceModel(x)/decoderDataSet Up DecoderPATCH Sensor(x)/ConfigurationActiveSensor ManagerFROST-ServerLoRaWANConnectorLoRaWANPlatformLoRaWANApplication ServerLoRaWANNetwork ServerLoRaWANJoin Server

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.

Projects extension data model.
Figuur 7 Projects extension data model.

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.

SensorThings API v2.0 data model
Figuur 8 SensorThings API v2.0 data model.

2.4.1 Definitions

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:

Feature updates in SensorThings API v2.0
Figuur 9 Feature updates in SensorThings API v2.0.

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:

ResultType updates in SensorThings API v2.0
Figuur 10 ResultType 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

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:

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:

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.

3.6 Demonstration: use-case relevant queries

The queries below run against the live server (https://sta-server.collaborall.net/v1.1/; a read token is available to testbed participants via the portal):

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:

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.

3.10 Traceability: proposal deliverables → delivered

Proposal (quotation 30-04-2026) Delivered Evidence
Own STA server on Collaborall infrastructure, latest OGC STA Yes Live server, source at https://github.com/collaborall/sta-server, ReSpec profile
OMS-based data model accommodating all testbed sensors Yes Data model section above
SBS/IFC object hierarchy for FeatureOfInterest (ANT CDE) Partially — deliberately re-scoped "A note on the object-context ambition" above; MQTT + multi-protocol depth delivered instead
Interoperability / federation with Brabantse Delta FROST server Not implemented — validated with the other testbed connectors instead, as agreed with Geonovum Demonstration section; discussion #10
Reproducible documentation, how-to, Docker Compose Yes README + clean-room install (Reproducibility section)
Public demo with use-case relevant queries Yes Demonstration section
Open source publication Yes — Apache 2.0 as proposed Full source at https://github.com/collaborall/sta-server
CC-BY 4.0 results Yes This report
Available ≥ 6 months after testbed (≥ Jan 2027) Yes Live server; ANT demo environment until end 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

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:

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.

4.10 Traceability: proposal deliverables → delivered

Proposal (quotation 30-04-2026) Delivered Evidence
Intermediary translation solution connecting existing sensors to a central STA server Yes Source at https://github.com/collaborall/sta-connector; Sources connected
BIM-aware FeatureOfInterest enrichment via ANT CDE mapping Deliberately re-scoped Approach section: protocol breadth (MQTT/Modbus/LoRaWAN) delivered instead; ANT capable, path untested — future work
Source 1: Brabantse Delta via Node-RED Not implemented — validated with the other sources and testbed connectors instead, as agreed with Geonovum Sources connected
Source 2: own sensor set (building context) Yes KPN Things LHT52 (real LoRa hardware) + demo fleet
Configuration-driven onboarding, template, how-to Yes Configuration model; clean-room install (Reproducibility section)
Validation of accuracy and reliability Yes Validation section; discussion #10
Public demonstration, continuously available Yes Public demonstration section
Open source publication Yes — Apache 2.0 as proposed Full source at https://github.com/collaborall/sta-connector
CC-BY 4.0 results Yes This report
Available ≥ 6 months after testbed (≥ Jan 2027) Yes Live connector + demo
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:

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.

The source code is publicly available at github.com/Geo-insights/sensorthings-testbed-connector under the MIT license.

5.2 Approach and architecture

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.

Targets
Connector
Sources
UrbanAdapt monitoring
FROST v1.1 (WBD)
FROST v2.0 (Fraunhofer demo)
PHP FROST v1.1 (CollaborAll)
Dedup + datastream resolution
Async worker queue
Kafka (TGV Office Lab)
Ohnics API (air quality)
Levellog / CARS (groundwater)
Bridge payloads (file / HTTP)

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:

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 2
22 Jul Multi-target hardening: fixed secondary targets using wrong datastream IDs, batch timeout tuning, documentation overhaul 10
28 Jul 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

Presentation slides: Public session 2 slides (PDF)

5.7.3 Public session 3

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.

Presentation slides: Public session 3 slides (PDF)

5.8 Recommendations

Based on our experience in this testbed, we offer the following recommendations for future SensorThings API adoption:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

5.9 References

6. Topic 2 Clappform: Connecting sensors to a SensorThings API server

Dit onderdeel is niet-normatief.

Clappform B.V. | Version 2.0 | 28 August 2026

Github Repo: ClappFormOrg/Clappform-SensorThings

6.1 Document control

Item Detail
Report title 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

STA servers
Translation layer
Sources
GET
poll
poll
push: POST /ingest/collaborall
write
write
WBD FROST-Server
HTTP and MQTT
Collaborall STA
(destination)
Ingest core
cursor, deduplication, write-log
Validator
clock skew, range, provenance
FROST writer
upsert by name, idempotency probe, dual-write
SULO vendor API
Dummy generator
Collaborall STA
(source server)
collaborall-reader
(separate binary)

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
"Fill level" ObservedProperty Fill level Shared by every container
The pairing of the two Datastream Fill level — SULO Container 0001 UnitPercent / % (UCUM), observationType: OM_Measurement, properties.expected_cadence_seconds
What is being observed FeatureOfInterest Container location: SULO Container 0001 Mirrors the container position
One reading Observation n/a 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:

// canonical.Thing
{ "VendorID": "sulo", "VendorNativeID": "4821",
  "Description": "SULO waste container slot \"Dorpsstraat 12 - rest\" for Restafval
                  at site \"Dorpsstraat\" (Dorpsstraat 12, 8620, Nieuwpoort)",
  "Location": { "Lon": 2.7521, "Lat": 51.1298 },      // note the swap, see below
  "Properties": {
    "reen_container_slot_id": "4821", "reen_container_slot_name": "Dorpsstraat 12 - rest",
    "reen_customer_key": "SULO-NL", "reen_content_type_id": "3",
    "reen_content_type_name": "Restafval", "reen_site_id": "915",
    "reen_container_id": "7734", "reen_site_name": "Dorpsstraat",
    "reen_site_area": "Centrum", "address": "Dorpsstraat 12",
    "postal_code": "8620", "city": "Nieuwpoort", "country": "BE",
    "reen_device_id": "5567", "device_serial": "SN-88213" } }

// canonical.Datastream
{ "ThingVendorNativeID": "4821", "ObservedProperty": "FILL_LEVEL", "Unit": "PERCENT",
  "Description": "Fill level (% of capacity) of Restafval in SULO container slot
                  Dorpsstraat 12 - rest",
  "ExpectedCadenceSeconds": 3600,
  "SensorMetadata": { "source_system": "reen", "model": "Single Sensor 4",
    "firmware_version": "unknown", "brand": "Sensoneo", "serial": "SN-88213",
    "reen_device_id": "5567", "signal_percent": "74", "battery_percent": "91" } }

// canonical.Observation
{ "ThingVendorNativeID": "4821", "ObservedProperty": "FILL_LEVEL",
  "PhenomenonTime": "2026-07-17T09:15:00Z", "ResultTime": "2026-07-17T09:15:00Z",
  "Result": 27, "RawObservationID": "4821@2026-07-17T09:15:00Z" }

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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.

  1. 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.
  2. Normalise to canonical types. The adapter emits Things, Datastreams and Observations in our own vocabulary. Everything downstream is vendor-agnostic from here on.
  3. 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.
  4. 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.
  5. 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.
  6. Record and advance. Write the log row, move the cursor. Only now do we treat the observation as delivered.
  7. 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.

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:

{ "encodingType": "application/json",
  "metadata": { "model": "DUMMY-1", "firmware_version": "1.0.0" } }

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:

  1. 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".
  2. 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.
  3. 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.
  4. 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:

  1. REEN returns a forecast dated three days ahead, alongside today's real readings.
  2. The validator rejects the forecast as in_future. Correct.
  3. Because that rejection is definitive, the cursor advances to the forecast's timestamp.
  4. Every genuine measurement arriving over the next three days is now older than the cursor, so it is discarded as before_cursor.
  5. 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.

  1. 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).
  2. 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.
  3. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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

  1. Test against at least two independent server implementations before you call a client done (see section 7.7).
  2. Budget for the operational envelope, not the mapping (see section 3), and note that you would pay that cost anyway (see section 7.3).
  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).
  4. Prefix your entity names on shared servers (see section 4.3).

6.11 9. References

6.11.1 9.1 Standards

Reference Document
OGC 18-088 SensorThings API Part 1: Sensing, version 1.1. The normative text behind sections 4.7, 4.10 and 5.9
OGC 15-078r6 SensorThings API Part 1: Sensing, version 1.0
OGC 17-069r4 OGC API - Features Part 1: Core. The required /conformance endpoint that section 6.3 asks STA to adopt
UCUM Unified Code for Units of Measure. The unit vocabulary section 6.1 recommends

Table 7. Standards referenced in this report.

6.11.2 9.2 Testbed discussions

All on the programme board at https://github.com/Geonovum/testbed-sensordata-2026/discussions.

Thread Subject Where we use it
#6 Brabantse Delta server information and extensions Section 4.2
#10 Collaborall server bug reports, including ourSensor.metadata case Sections 4.7, 4.10.3, 8.1
#11 Dual-target delivery and datastream id mapping Sections 1.3.2, 1.4
#12 External identifiers as a first-class field Section 6.2
#14 Latest-observation lookup on a Datastream Section 4.5
#15 phenomenonTime for high-rate sampling Section 4.9
#16 Connector and data provenance Section 5.8
#18 WBD server latency Section 4.5
#20 Extensions and the future of the standard Sections 1.5, 6.3
#22 Our uniqueness finding, the reply that corrected it, and a second implementer on capability discovery Sections 4.5, 6.2, 6.3, 6.5
#23 Our observation-idempotency finding, with independent timings from a second implementer Sections 4.5, 6.2
#24 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.
FROST Fraunhofer Opensource SensorThings Server, a widely used STA-compliant server implementation.
Entity 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:

  1. Connect to the provider — manage communication with an upstream service (e.g. poll an HTTP API or subscribe to an MQTT topic).
  2. Decode the wire format — decode applied by the provider or transport.
  3. Deserialize the payload — parse the message into an in-memory structure.
  4. 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).
  5. Decapsulate to Sensor messages — split the Thing payload into one or more logical sensor readings where a single device exposes multiple quantities.
  6. 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).
  7. 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:

  1. An MQTT broker provided by The Things Network,
  2. An HTTP API provided by Netatmo,
  3. A SeedLink server,
Thing Description Provider Quantity Countries
Netatmo NWS03IN Consumer-grade, Wi-Fi, proprietary indoor weather station. Netatmo 3 Netherlands
Milesight AM308L Professional LoRa indoor air-quality sensor. The Things Network 20 Italy, Romania
Kinemetrics ETNA2 Specialist GPRS seismology accelerometer. SeedLink 7 Romania
Dragino LSN50v2-S31 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 canonical Datastreams 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:

https://airquality-frost.k8s.ilt-dmz.iosb.fraunhofer.de/v1.1/Datastreams?
    $select=phenomenonTime,@iot.id
    &$expand=Thing($select=@iot.id)
    &$top=10000

Which returns:

{
"value": [
    {
    "phenomenonTime": "2017-12-31T23:00:00Z/2026-06-10T07:00:00Z",
    "@iot.id": 1,
    "Thing": {
    "@iot.id": 165
    }
        },
    {
    "phenomenonTime": "2017-12-31T23:00:00Z/2026-06-10T07:00:00Z",
    "@iot.id": 2,
    "Thing": {
    "@iot.id": 165
    },
    ...
}

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:

https://airquality-frost.k8s.ilt-dmz.iosb.fraunhofer.de/v1.1/Things?
    $expand=Locations
    &$top=10000

In the above URL, the $expand parameter was shown to be highly effective, allowing the return of related entities from a parent entity.

The client may request the latest Observation value for a given Datastream via:

https://airquality-frost.k8s.ilt-dmz.iosb.fraunhofer.de/v1.1/Datastreams(18824)
    /Observations?
    $top=1
    &$orderby=phenomenonTime desc

... 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.

Several implementation tensions described in this document are targeted by STA v2, most notably the issue pertaining to unassignable features of interest, which is completely addressed, and The Permanent Identity of a Thing and Sensor, which is partially addressed.

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:

  1. 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.
  2. 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.
  3. 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.
  4. Own duplicate prevention. Do not expect the API to do it; prefer idempotent writes keyed on external identity and phenomenonTime.
  5. 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.
  6. 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.
  7. Prefer virtual Sensors and separate Datastreams for derived series — not Observation subclasses. Record lineage in properties or provenance metadata.
  8. Advertise complex result shapes explicitly. Adopt a fixed object structure for arrays and composites; use v2 resultType when available.
  9. Keep connectors, credentials, and transport lifecycle outside the core STA model unless STA-visible device management is a hard requirement.
  10. Use Datastream.phenomenonTime for health-checks rather than nested “latest Observation” expands — it is far cheaper and is what the field is for.
  11. 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.
  12. 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.

8.1 What works well

First of all, the SensorThings API model is easy to implement. All participants succeeded in this, even for completely new domains that had never been used in conjunction with the SensorThings API before: municipal waste containers at Clappform, and climate adaptation monitoring at urban living labs at Geo Insights. Clappform mapped that unfamiliar domain onto the seven core entities with no extensions and no compromises, Collaborall confirmed that the v1.1 entity model maps one-to-one onto OMS concepts, and Fraunhofer reports that setting up a server with the core data model is trivial. This shows the model serves its purpose very well.

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

In addition to a standard, national profiles and interoperability testing, a best practice for implementing the standard would also add value. For instance, the testbed found several error conditions and misconfigurations that the standard and its implementations do not alert you to. Clappform's most serious defect was a poll cursor that advanced into a forecast horizon and discarded every real measurement up to that date, without logging an error and while the stream still looked healthy. In the same chapter, a dead sensor turns out to be invisible rather than stale, and concurrent writes duplicated shared entities in silence because the standard does not require entity names to be unique. Geo Insights adds the operational failures that only appear under sustained load, and concludes that watchdogs, alerting and an external freshness check belong in the design from day one. Knowing what these are and how to check for them will save implementers a lot of time.

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.

10. Lijst met figuren

A. Index

A.1 Termen gedefinieerd door deze specificatie

A.2 Termen gedefinieerd door verwijzing

Geonovum Handreiking - Vastgestelde versie