DEV Community

Vidya Krishnan
Vidya Krishnan

Posted on

Same Water, Different Robots: Cross-Checking Ocean Sensors with PostGIS

As a personal project to learn PostGIS, I started building a small app to check whether ocean gliders and Argo floats were profiling the same waters, using the data they collect. When a glider is selected, it finds, within seconds, the Argo float profiles close enough to it in place, time and depth. I wanted to test a theory: if the glider and the float are sampling the same water, the values recorded by their sensors should be roughly the same. I set up the PostGIS tables to store only metadata, and wrote the code to cache it and compare the profiles. Then I plotted the values recorded by both and, lo and behold, they matched almost perfectly. Most of the work happens in one PostGIS query, and no data file is ever saved.

Apples and Mangoes

My first question was a wrong one: "where and when did a glider and an Argo float sample the same water?"

I drew boxes on a map, pulled in every glider and float inside, and looked for encounters within 50 km, 100 m of depth and 10 days.
The first real glider I opened was a Slocum off New York, diving to about 90 m on the continental shelf. Argo floats don't go there: they live in the open ocean, parking at 1000 m and profiling up from 2000 m.

A shelf glider and a float were never going to meet, and when they did come near each other, they were sampling different oceans. I was comparing apples and mangoes.

An analogy helped me reset. Argo floats are like long-haul lorries on a fixed timetable: sparse, slow, one report every ten days. Gliders are local cars: piloted, sampling densely, on short trips.

When a lorry and a car pass close by, you can learn three different things:
• Calibration: whether the car's speedometer is right, by checking it against the lorry's while they drive side by side.
• Representativeness: what the lorry's sparse report missed, which the car saw in between.
• Coverage: which roads the lorries never use at all.

I chose calibration. Glider conductivity and oxygen sensors may drift over a mission, and I assume nothing on board can check them. Argo profiles, especially the delayed-mode ones that experts have quality-checked, are the most widely available trusted reference. Finding the right ones for a deployment is usually done by hand, one deployment at a time.
The problem is close to home.

As part of BODC, I see the effort data managers and scientists put into setting up, processing, and monitoring every float and glider, carefully, one platform at a time.
The question, precisely
For a glider deployment, which Argo profiles lie within 50 km and 10 days of one of its dives, with depth ranges that share at least 100 m below 150 m? Keep the 5 best.
Each number has a reason:

Limit Value Why
Distance 50 km A common matchup scale in the open ocean, where the deep water changes slowly across that distance
Time 10 days Argo surfaces every 10 days; deep water changes over weeks, not hours
Comparison layer below 150 m Surface water changes within hours (sun, wind, mixing); deeper water is stable enough to compare days apart
Shared depth at least 100 m Enough water column for a meaningful comparison
References kept 5 A handful to compare, ranked by distance/50 km + time/10 days

I started stricter: 200 m shared below 200 m, so a glider had to reach 400 m. The first live run showed every glider then working off the US Northeast was a shelf glider under about 100 m deep, so I loosened it. A glider now has to reach 250 m to qualify, at the cost of a less stable comparison layer. All five values live in .env, so changing the rule needs no code change.

A shelf glider that never reaches the layer gets a clear answer instead of an empty list: "no deep overlap possible". That is a coverage finding in its own right.

Data without downloads

One rule from the start: never save a NetCDF file or raw measurements. Everything is read into memory, summarised, and only the summary is kept.

Gliders come from ERDDAP servers: the US IOOS Glider DAC and Fisheries and Oceans Canada's CNODC ERDDAP, which serves deep Seagliders. They publish glider data in two different layouts:

  • Dive-numbered (IOOS): every row carries a profile_id, one per dive or climb. Asking for profile_id,time,latitude,longitude with &distinct() is answered from ERDDAP's cached subset table without reading the measurements. ERDDAP's orderByMinMax("profile_id,depth") returns only the shallowest and deepest row of each dive, so the full data never leaves the server.

  • Continuous trajectory (DFO, European EGO style): no dive numbers,
    one long series. orderByClosest("time/5minutes") thins it on the server to one row every 5 minutes, and I group those into 6-hour pseudo-profiles, each with its depth range, mean position and mid time.

Argo comes through argopy from Ifremer's ERDDAP, which serves the Coriolis GDAC data. I chose its erddap source over gdac because the gdac source did not seem to show the biogeochemical data (no oxygen or nitrate), and ERDDAP filters the box, depth and dates on the server.

The slow part was finding gliders. Searching the IOOS server for deployments in a box took about a minute and timed out. The fix was to stop searching it live: a background thread loads each server's list of active deployments into my own PostGIS table every hour, and every search becomes an indexed query against that table, answered in milliseconds.

How it fits together

Architecture · 3 data servers, the FastAPI backend, 3 PostGIS tables

Architecture · 3 data servers, the FastAPI backend, 3 PostGIS tables

The browser only talks to the backend. The backend reads every server in memory and writes small summaries to three PostGIS tables; the sensor check fetches full profiles on demand and stores nothing.

Modelling a profile

Calibration compares one vertical column of water with another, so the unit of storage is a profile: a glider dive (or pseudo-profile) or one Argo cycle. Every profile is one row:

CREATE TABLE profiles (
    platform_id    text NOT NULL,                 -- GLIDER_<dataset_id> or ARGO_<wmo>
    platform_type  text NOT NULL CHECK (platform_type IN ('GLIDER', 'ARGO')),
    profile_id     text NOT NULL,                 -- dive id, or <wmo>_<cycle>
    geom           geometry(Point, 4326) NOT NULL,
    t              timestamptz NOT NULL,
    depth_range    numrange NOT NULL,             -- e.g. [0.8,958.0]
    sensors        jsonb NOT NULL DEFAULT '[]',   -- ["temperature","salinity","oxygen"]
    data_mode      text,                          -- Argo: R, A or D
    PRIMARY KEY (platform_id, profile_id)
);
CREATE INDEX ON profiles USING GIST ((geom::geography));
CREATE INDEX ON profiles (platform_type, t);
CREATE INDEX ON profiles USING GIN (sensors);
Enter fullscreen mode Exit fullscreen mode

The column that does the most work is depth_range, a PostgreSQL numrange. "Which depths did both profiles cover?" becomes a range intersection, and "how much?" is upper() - lower().
The GiST index sits on the expression (geom::geography), because the distance test casts to geography; an index on plain geom would not help it.

My first design stored one LineStringZM per platform: longitude, latitude, depth and time packed into a single 4D line. It is a neat PostGIS feature and it suited the encounter question, but calibration needs to reason about whole columns of water, not points along a path. Tracks are now built on the fly for drawing, with ST_MakeLine(geom ORDER BY t).

The matching query

The whole search is one SQL statement. It pairs every glider dive with nearby Argo profiles, keeps pairs that share enough deep water, and ranks them:

WITH g AS (                                   -- the glider's dives
    SELECT profile_id, geom, t, depth_range FROM profiles WHERE platform_id = :glider
),
pairs AS (
    SELECT a.platform_id AS argo_id, a.profile_id AS argo_profile, a.data_mode,
           g.profile_id AS glider_profile,
           ST_Distance(g.geom::geography, a.geom::geography)       AS dist_m,
           abs(extract(epoch FROM a.t - g.t))                      AS dt_s,
           g.depth_range * a.depth_range * numrange(150, NULL)     AS shared
    FROM g
    JOIN profiles a
      ON a.platform_type = 'ARGO'
     AND a.t BETWEEN g.t - interval '10 days' AND g.t + interval '10 days'
     AND ST_DWithin(g.geom::geography, a.geom::geography, 50000)   -- metres
    WHERE a.sensors ? :param
),
scored AS (
    SELECT *, upper(shared) - lower(shared)          AS overlap_m,
           dist_m / 50000 + dt_s / (10 * 86400)      AS score      -- 0 best, 2 worst
    FROM pairs WHERE NOT isempty(shared)
),
best AS (                                     -- best glider dive per Argo profile
    SELECT DISTINCT ON (argo_id, argo_profile) *
    FROM scored WHERE overlap_m >= 100
    ORDER BY argo_id, argo_profile, score
)
SELECT * FROM best ORDER BY score LIMIT 5;
Enter fullscreen mode Exit fullscreen mode

Three details carry it:

  • ::geography. In SRID 4326, ST_Distance on plain geometry returns degrees. Cast to geography and it returns metres measured on the WGS84 ellipsoid, so 50000 really means 50 km at any latitude.
  • Range intersection. g.depth_range * a.depth_range * numrange(150, NULL) is the depth both profiles covered, below 150 m (NULL = open-ended, so everything deeper than 150 m). An empty result means no overlap; upper() - lower() is its thickness.
  • DISTINCT ON. An Argo profile can be near dozens of dives; this keeps only its best-scoring dive before the final top 5.

Checking the sensors

Finding references is half the job. "Check sensors" on a reference fetches both full profiles into memory, compares them, and throws them away: nothing is saved. It reports the glider's salinity offset (glider minus Argo) in the shared layer, with a verdict: within 0.01 agrees, up to 0.03 is worth watching, beyond that is possible drift.

I'm not an oceanographer, so the hard part was deciding which water to compare. The picture that made it click for me is a layered cake.

Why the comparison matches temperature, not depth

Why the comparison matches temperature, not depth

Each layer of the ocean has a colour, its temperature, and a flavour, its salinity. The Argo float is the trusted taster. The glider sees colour well (its temperature sensor rarely drifts), but its sense of flavour, the salinity sensor, may be off. And between the two visits, eddies and internal waves push the layers up or down by tens of metres.

  • Same depth compares different layers, so it reports an error that isn't there, or hides one that is.
  • Same density sounds safer, but density is worked out from salinity too, so a drifting salinity sensor picks the wrong layer.
  • Same temperature works: the temperature sensor is reliable, and a layer keeps its temperature when it is pushed up or down. In the code this is potential temperature from the TEOS-10 library gsw (gsw.pt0_from_t): the temperature the water would have if brought to the surface, which removes the small warming caused by pressure at depth.

This is how Argo's own experts check float salinity in delayed mode: they compare float salinity with trusted reference data on potential temperature levels (Wong, Johnson & Owens, 2003; the OWC toolbox used by Argo data centres; NOAA PMEL's summary).

Testing it with a known error

I tested the idea the way you'd test a weighing scale: with a known weight. The test makes up an ocean from two smooth curves, a perfect Argo profile of it, and a glider profile of the same ocean with a salinity error I chose and the water pushed up. It then hands both to the app's real comparison code and checks which method gives the hidden error back. No data is downloaded and no database is touched.

def water(p):                                   # the made-up ocean, p = depth in m
    temperature = 4 + 14 * np.exp(-p / 250)     # 18 °C at the surface, 4 °C deep
    salinity = 34.95 + 0.9 * np.exp(-p / 300)   # 35.85 at the surface, 34.95 deep
    return temperature, salinity

# Argo: the truth, 200 levels down to 2000 m
t, s = water(p_argo)
argo = pd.DataFrame({"pres": p_argo, "temp": t, "psal": s})

# Glider: the same ocean, pushed up by `lift` metres, salinity off by `offset`
t, s = water(p_glider + lift)
glider = pd.DataFrame({"pres": p_glider, "temp": t + noise, "psal": s + offset + noise})

# the app's own code, unchanged
result = compare_profiles(teos10(glider, lon, lat), teos10(argo, lon, lat), 150, 950)
result["theta_levels"]["salinity"]["median"]   # should come back as `offset`
Enter fullscreen mode Exit fullscreen mode

The whole test is in backend/tests/test_synthetic_compare.py. poetry run pytest tests/test_synthetic_compare.py -v runs the checks, and poetry run python tests/test_synthetic_compare.py prints this table:

How to read it: the first column is the right answer, because I put that error in on purpose. Compare each method's number with it, in the same row. The closer the two, the better the method.

Hidden salinity error Water pushed up by Same temperature Same depth Same density
+0.015 25 m +0.0145 +0.0031 +0.0254
0.000 25 m −0.0005 −0.0119 −0.0001
0.000 0 m −0.0002 0.0000 +0.0003
+0.040 60 m +0.0393 +0.0135 +0.0679
−0.008 10 m −0.0081 −0.0131 −0.0139

Rows 2 and 3 are the control: the glider's sensor is perfect, so I expected every method to report no error, 0.000. With the water still (row 3), they all do. Once the water moves 25 m (row 2), same depth reports −0.012, an error that isn't there. Same density copes with the moving water, but rows 1 and 4 show it exaggerates real errors: +0.068 for a real +0.040.

Across all five rows, same temperature gives the closest result every time:

Method Furthest from the right answer
Same temperature 0.0007 (row 4: +0.0393 for +0.040)
Same depth 0.027 (row 4: +0.0135 for +0.040)
Same density 0.028 (row 4: +0.0679 for +0.040)

The verdict only starts to care at 0.01, so same temperature's worst miss is about 15 times too small to matter, while the other two miss by more than that 0.01 line, enough to change the verdict.

The panel shows the verdict, the offsets and two plots: potential temperature against salinity for the shared layer, and salinity against pressure with the compared layer shaded.

Results

The first live match. The glider was ng1050-20260713T0000, a deep glider operated by the US Naval Oceanographic Office and served by the IOOS Glider DAC, working in the open Atlantic off the US Southeast coast. For its dive 65100041 (21 July 2026, 32.1°N 76.3°W, 0–958 m), the third-ranked reference was Argo float 6990687, cycle 51 (adjusted mode): 20.9 km and 4.7 days from the matching glider dive, the two sharing the water column from 150 to 958 m. That is about 800 m of stable water in which a good conductivity sensor should agree with the float to within a hundredth of a salinity unit.

Shelf gliders, correctly refused. Every glider I found off the US Northeast in late October was a shelf glider, none deeper than about 100 m. Each one answers "no deep overlap possible" straight from its metadata, without fetching a single Argo profile. That is the coverage result from the analogy: roads the lorries never use.

Keeping it small

The database is an index, not an archive. Four mechanisms keep it that way:

What How Why
Glider catalogue glider_deployments: extent Polygon, active dates as a tstzrange; refreshed hourly per server A one-minute server search becomes a millisecond query
Argo fetch cache argo_fetch_windows: each fetch's area and period; reused while ST_Covers(area, box) and period @> dates hold, for 7 days Reopening a glider no longer re-downloads the same Argo data
Retention Hourly: drop glider rows not reopened for 30 days, and Argo rows no fresh fetch still relies on The table holds roughly what was used last month
Shallow-track thinning Gliders that can't reach the comparison layer keep one point per 3 hours A shelf Slocum dives every 10–20 minutes: 2,500 dives became 161 points

Matches themselves are never stored. The query takes about 0.05 s on test data, and recomputing means a change to the rule in .env is never contradicted by stale results. The profiles table had reached 18,000 rows after a few sessions, mostly shelf dives that could never match, before retention and thinning went in.

What I learned

  • Ask the right question first. The depth layer mattered more than the distance; a working encounter engine for the wrong question was still the wrong tool.
  • Live data can change your rules. One look at October's gliders loosened the depth rule.
  • ST_ClosestPoint is 2D. My first 4D query compared the depths of two closest points; the point had no Z, so the check was always NULL and nothing ever matched.
  • Geometry measures degrees, geography measures metres. ST_Distance in SRID 4326 returned 0.0141 for two points 1.3 km apart until I cast to ::geography.
  • Range types are underrated. numrange intersection and isempty() say "the depth both profiles covered" in one line; tstzrange with && and @> does the same for time.
  • Index the expression you query. A GiST index on (geom::geography), not on geom.
  • Compare salinity at equal potential temperature, not equal depth or density.
  • Cache a slow server's catalogue in your own database, and cache fetches by area and period.
  • pandas 3 can parse times at second resolution. astype("int64") / 1e9 silently gave dates in 1970.
  • argopy's region order is [lon_min, lon_max, lat_min, lat_max, …], not the usual bounding-box order.

Limits and next steps

Fifty kilometres and ten days is not "the same water" near fronts and eddies; a wide spread in the offsets is the warning sign. The app only looks at deployments active in the last 30 days, while careful calibration usually happens after a glider is recovered. Real-time data on either side is less certain than delayed-mode, and oxygen is not compared yet because the sources report it in different units and forms.

Sources

Top comments (0)