fieldmesh

Two real clients, pointed at the HTTP service#

2026-10-07. spec §10, VIRTUAL_FARM.md step 7's cheap half. The HTTP layer in server/ was written, tested against 69 of its own tests, and then handed to two clients nobody here wrote:

Both read the service. Between them they found five defects, four of them ours, and one of our own tests turned out to be asserting the wrong thing. None of these was reachable from inside: every one needed a consumer with its own opinions about what a SensorThings document means.

What worked immediately#

QGIS loaded six entity sets from a simulated day of Harrow Bend:

entity features
Thing 9
Datastream 56
Observation 5450
Sensor 28
ObservedProperty 6
FeatureOfInterest 10

Timestamps parsed into QDateTime without coaxing, resultQuality survived as a field, the labels from harrow_bend.labels() showed as layer names, and paging worked — the Fraunhofer client walked the whole collection by following @iot.nextLink on its own.

The five findings#

1. Every QGIS layer had no geometry#

geometryType() came back NullGeometry for all of them, so the farm loaded as six attribute tables and nothing could go on a map. §7.6 names a GIS client as the acceptance test for the export story, and it was failing it silently.

Two causes, which is why it took a real client to see:

Fixed by giving Harrow Bend's features a layout — a row of six beds about 50 m long, with the paddock, plot and hoop house offset — delivered through the same side table the names use. Geometry is deliberately not a log record: a real farm's field boundaries are the material VIRTUAL_FARM.md keeps in a gitignored .site/ directory, and §3 has no record type for them. Harrow Bend is fictional and sits on the prime meridian, so its geometry can live in the repository.

After the fix: 10 of 10 features with geometry, layer extent -0.00060,-0.00040 : 0.00060,0.00030.

2. Every observed property had a blank name#

QGIS's legend showed six empty strings. The name fell back to "everything after the last /", and the Q4 vocabulary decision had just given every URI a trailing slash — which both vocabulary servers want, because the slashless form redirects and a redirect is a different identifier to a dumb client. Everything after the last slash of .../air_temperature/ is the empty string.

A defect introduced by a correct decision, invisible to every test we had, and the first thing a human looking at a legend would notice. humanise_term now strips the trailing slash and renders volume_fraction_of_condensed_water_in_soil as "volume fraction of condensed water in soil" and batteryVoltage as "battery voltage" — while leaving pH as pH rather than p h, which the first attempt got wrong and which soil pH will certainly exercise.

An observed property's identity is its vocabulary URI (§3.4), so its self link read ObservedProperties('http://vocab.nerc.ac.uk/standard_name/air_temperature/'). The unencoded slashes split the path into the wrong segments and our own router returned 404 no such entity set: ObservedProperties('http:.

The id is now percent-encoded, and the router decodes it after splitting the path, not before — the ordering is the whole fix. ObservedProperties is also now exempt from UUID parsing, since it is the one entity set whose key is not a UUID.

A second instance of the same class: Thing advertised a Locations@iot.navigationLink, and we serve no Locations at all. Removed rather than implemented — geometry here belongs to a feature of interest, not to the Thing that observes it, and 1.1 makes Thing.Locations 0..*.

Generalised into test_app.py::TestEverySelfLinkResolves, which walks every entity set, collects every self and navigation link, and fetches each one. A link you cannot answer is worse than one you never offered, because the client only learns after following it.

1.1 Table 9 gives it one. It was simply missing, and a client cannot work around it: there is no other way to ask "which series measure this property?" than to list every datastream and filter client-side. Added.

5. A raw series was typed as a count, with a unit#

QGIS showed observationType = OM_CountObservation on a datastream whose unitOfMeasurement was cm3/cm3. A count has no unit, so one document asserted two incompatible things, and a consumer honouring observationType would be entitled to refuse to average or interpolate a continuous measurement.

The cause was typing the datastream from the Python type of the first result it could find — and a raw soil-moisture series carries scaled integers, 280 for 0.280. The type now follows the declaration: a declared unit means a measured quantity, which is what a unit is for. Only an undeclared unit lets the values decide, and then they decide something real.

Two of our own tests asserted the old behaviour and were rewritten, because the behaviour they pinned was wrong. The same scan was also walking every observation in the log for every datastream on every /Datastreams request — the quadratic shape §7.2 forbids, hiding in a serialiser rather than in the projection. It uses the index now.

This fix only moved the real question, which is what unit a raw datastream should declare when it carries scaled integers. That is a data-model decision affecting the canonical farm, so it is recorded as Q21 rather than quietly chosen.

What FROST settled, and one thing it did not#

With the oracle running alongside, three behaviours were checked against it rather than against a reading of the specification.

$top and @iot.nextLink — we match FROST exactly. FROST returns $top=2 as two rows plus a nextLink with $skip=2; so do we. This mattered because the Fraunhofer client follows nextLink automatically, so asking for 3 rows yields the whole collection — which looks exactly like $top being ignored. I had drafted a change to suppress the nextLink whenever $top was explicit, on a reading of OData where $top bounds the result set rather than the page. FROST says otherwise, and FROST is the oracle this project chose, so the change was not made. Recorded because it is precisely the sort of conformant behaviour a later reader would "fix" into non-conformance.

FROST requires unitOfMeasurement even for a count. Posting a Datastream with OM_CountObservation and a unit: 201. The same without a unit: 400. So our count-with-a-unit document was never a protocol violation — it was a semantic one. This also constrains Q21: a raw datastream declaring a null unit could not be mirrored into the reference implementation.

FROST requires a FeatureOfInterest, or a Location to generate one from. A deep insert of a Thing with Datastreams and Observations and no Location is rejected with No FeatureOfInterest provided, and none can be generated. Our model derives features of interest from the datastream's declared feature instead, which is a different and deliberate choice (§3.1) — noted because anyone mirroring layer zero into FROST will meet it.

Reproducing#

make serve                      # or: cd server && python3 serve.py --days 1 --port 8099 --open
docker pull qgis/qgis:ltr
docker run --rm --network host -e QT_QPA_PLATFORM=offscreen \
  -v "$PWD/probe.py:/probe.py:ro" qgis/qgis:ltr python3 /probe.py

where probe.py builds layers with QgsVectorLayer("url='http://127.0.0.1:8099/v1.1' entity='FeatureOfInterest' type=Point", "foi", "sensorthings"). QT_QPA_PLATFORM=offscreen is required; without it the container cannot initialise Qt.

For the Fraunhofer client, pip install frost-sta-client into a virtual environment and point SensorThingsService at the same base URL.

Every finding above is pinned as a test, so none of it depends on the containers: sdk/test_sensorthings.py (TestVocabularyUriPresentation, TestFeatureGeometry, TestThingHasNoLocationsLink, TestUnitsAndTypes), server/test_app.py (TestEverySelfLinkResolves, TestUriKeyedObservedProperties), and sim/test_harrow_bend.py (TestLabelsAndGeometry).