Two days after UNR switched on the Tahoe Environmental Observatory Network, I started reading it. No API documentation, then or now. Ten endpoint slugs guessed from a truncation pattern, forty-three sensors that turned out to be thirteen devices, a soil pH of 1.8, and a five-digit parameter code that turned out to be the pollutant the lake’s TMDL actually regulates. Plus nine things I got wrong — including one that silently returned zero rows from every gauge for hours while reporting success.
A plate on a rope
In 1865 the Papal Navy asked an astronomer a simple question: how clear is the water?
Angelo Secchi lowered a white plate over the side of a yacht in the Mediterranean and wrote down the depth at which it disappeared.
That’s the instrument. One hundred and sixty-one years later it is still the world standard for lake transparency, and it is still how Lake Tahoe’s famous clarity gets reported every single year. Twenty-two metres in the 1960s. About twenty-one now.
The number that governs a hundred million dollars of restoration policy comes from dropping a disk in the water and watching it vanish.
On 15 September 2026, the University of Nevada, Reno switched on the Tahoe Environmental Observatory Network — sondes in the water, Campbell loggers in the forest, cameras on the ridgelines, all of it public. For the first time you could watch clarity assemble itself out of its causes, every fifteen minutes.
I did my undergraduate degree at UNR. Two days later I started reading their network.
There was no API documentation. There still isn’t.
The API had to be guessed
TEON publishes a JavaScript frontend talking to an undocumented REST backend on an AWS App Runner container. Everything below came out of browser network traffic and a lot of probing.
The first real find was the naming convention, and it’s a strange one. Time-series endpoints don’t use a sensor’s display name — they use the label truncated to its leading concept, kebab-cased. So:
"Air Temperature & Relative Humidity" → /sensors/air-temperature
"Tree stress and growth" → /sensors/tree-stress
"Soil Environmental Conditions" → /sensors/soil-moisture
That third one is not a typo. The soil endpoint is named after one of the things it measures rather than the thing it’s called.
Ten sensor types, all resolved eventually. The convention turned out to be {name}-sensor with the display name not word-split, once I’d seen enough examples: Minidot → minidot-sensor, which was the one candidate my first probe forgot to try.
I want to flag the method here, because it’s the most portable thing in the project. I did not write a client that assumed a URL pattern. I wrote a probe — a command that tries candidate slugs against a real site and reports which return a 200. That distinction saved me repeatedly, and where I skipped it, it cost me. More on that below.
Forty-three sensors, thirteen devices
The inventory endpoint lists forty-three sensors. They are not forty-three sensors.
At every terrestrial station, the soil, air-temperature, tree-stress and stream-level endpoints return projections of a single Campbell Scientific logger table. Identical record UUIDs. Identical battery voltage. Identical panel temperature. Identical row counts.
One box, four names.
The giveaway was PTemp_C_Avg — panel temperature, the logger enclosure’s own internal reading. No third-party sensor reports that; the datalogger reports it about itself. Combined with BattV_Avg/BattV_Min and an S3 bucket literally named teon-loggernet-data-storage, the hardware identified itself: Campbell loggers running CRBasic, LoggerNet writing to S3.
The arithmetic is exact. A full pull grabs 4,600 records. Deduplicating on the source’s own record IDs collapses it to 1,600. The real network is 2 lake sondes + 6 forest loggers + 5 cameras — thirteen physical devices.
Worth knowing before anyone writes a paper about forty-three independent measurement sites.
There’s a design lesson in it too. If you build an API over a logger network, be explicit about the difference between a logger, a sensor and a channel. Consumers will otherwise mistake your endpoint count for your instrument count, and the error inflates by a factor of three.
The ones you have to swim out to
Two of the five lake sondes carry Manual in their identifiers. Working out what that meant turned into my favourite piece of inference in the whole project.
Their records both stopped abruptly on 9 July and hadn’t moved since. My first read was that the fleet had failed. Wrong — the other three were reporting fine. So what does “manual” mean?
The completeness numbers answered it before the documentation did:
| Sonde | Telemetry | Records | Expected | Complete |
|---|---|---|---|---|
| Blackwood 3 | manual | 15,451 | 15,642 | 98.8 % |
| Meeks | manual | 15,441 | 15,632 | 98.8 % |
| 4H Camp | live | 52,443 | 54,344 | 96.5 % |
| Glenbrook | live | 46,271 | 47,811 | 96.8 % |
| Sunnyside | live | 38,562 | 52,422 | 73.6 % |
A self-logging instrument writes to internal memory and almost never misses a sample. A telemetered one loses whatever the radio link drops — Sunnyside has lost over a quarter of its expected record to transmission gaps.
Then the detail that settled it. Both manual sondes are short by exactly 191 records. 15,642 − 15,451 and 15,632 − 15,441 both equal 191, which is 47.75 hours. An identical two-day gap on two separate instruments is not packet loss. That’s one service visit with both units out of the water at the same time.
TEON’s own StoryMap confirmed it later:
“Emily Carlson and Katie Senft ride and snorkel to their sensors every month, come rain, shine, and even snow in the winter months.”
Monthly. By boat and snorkel. The reason is biofouling — algae grows over optical windows within weeks in productive water, and an unwiped fluorometer drifts in a way that looks exactly like real environmental change.
The counter-intuitive conclusion, and it’s measured rather than asserted: telemetry buys you timeliness, not completeness. If your question is “what is happening right now”, telemeter. If your question is “what happened over the last decade”, a logger you visit quarterly may give you a better dataset for less money. Telemetry’s real value is often not the data at all — it’s knowing the station is still alive.
The lake said pH 1.8
Every monitoring project has the moment where the instrument says something impossible and you have to decide whether it’s the world or the wiring.
The pH probes are dead across the entire fleet. Glenbrook returns exactly 0. Sunnyside returns null. TEON’s StoryMap lists pH among the measured parameters, so this is a fault rather than an unequipped channel. pH electrodes are the shortest-lived probe on a sonde and the first to drift — this is the most predictable failure in limnology, and it’s fleetwide.
Sunnyside is reading negative turbidity. Not noise. Across 48 consecutive readings it sat at −2.109 FNU with a standard deviation of 0.042. That tight a spread around that negative a mean isn’t randomness, it’s a mis-set zero point. Add 2.11 and the site reads about 0.0 FNU, which is correct for clear alpine water.
What made that conclusion solid rather than plausible: the USGS gauge on Blackwood Creek reads +0.3 FNU — same measure, same units, different instrument, different agency. Independent corroboration that the fault is Sunnyside’s and not the lake’s.
And in TEON’s own watershed dataset, a soil pH of 1.79. Cave Rock catchment. Glenbrook Creek reads 2.61, Mill Creek 2.74.
A soil pH of 1.8 is approximately battery acid and does not occur in Sierra granite. The enormous companion standard deviations are the signature of nodata cells being averaged in as zeros. Dante’s Peak had an acidified lake too, and it was also the thing that killed somebody — but this one is a raster processing artifact, and it’s excluded from display with the reason recorded so nobody wires it back in.
One Topographic Wetness Index of 833.9 against a 5–55 range everywhere else. Marlette Creek, whose catchment contains Marlette Lake. TWI diverges over standing water. Annoying, because TWI is one of the more useful variables in the set.
The stream gauge is called Uncalibrated_water_depth and reads 0.001 m. That’s TEON’s field name, not mine, and I respect the honesty. Meanwhile the USGS gauge on the same creek reads 0.04 ft³/s — two agencies, separate hardware, both saying the east-shore creek is essentially dry. That’s real cross-validation, and it resolves the ambiguity: the channel is dry, the datum isn’t broken.
A field camera filed a frame from the future. Glenbrook 4’s most recent capture was timestamped six hours after the snapshot that contained it. TEON returns naive timestamps with no offset; the loggers appear to run on Pacific wall-clock and the cameras on UTC. Mixed conventions in one payload.
None of this is a knock on TEON. The network was two weeks old when I started, and cataloguing the gremlins is the work. It’s also worth saying plainly: finding faults from outside, with no obligation to stand behind the numbers, is a great deal easier than operating the thing.
Same storm, two watersheds
The question I actually wanted to answer: do two stations on opposite shores respond differently to the same weather?
Lake Tahoe sits in a rain shadow. The west shore catches Pacific storms; the east shore is in the lee of the Carson Range. If you could find two stations at the same latitude on opposite shores, you’d have a controlled comparison — same storm, same sun angle, opposite sides of the divide.
Picking the pair took three attempts.
Attempt one: Blackwood Creek, the obvious west-shore choice. Offline since June.
Attempt two: Glenbrook 2 as the east anchor. Looked ideal until the data called it out — 42 % soil moisture against 3.5–11.5 % at every upland station around it, and the only station also carrying a stream gauge. It’s a riparian microsite. Pairing it with a hillslope station would have measured streambank versus hillside, not wet side versus dry side.
Attempt three, and the right answer: Homewood and Glenbrook 5. Opposite shores, both upland, and 64 metres apart in latitude. Effectively the same line across the map.
Then I found TEON’s watershed layer — 60 stream catchments with 164 climate and landscape attributes each, served as a static GeoJSON plus two JSON endpoints — and it made the point far better than any sensor could:
Homewood (Madden Creek catchment) 1,463 mm/yr
Glenbrook 5 (Glenbrook Creek catchment) 689 mm/yr
—————————
2.12×
Two stations a stone’s throw apart in latitude, in catchments receiving twice the annual precipitation. Basin-wide the gradient reaches 3.03× — 1,459 mm at Watson Creek on the northwest shore down to 482 mm at Deadman Point on the east.
That’s the Sierra rain shadow, measured, per catchment.
And here’s the honest part, which is also the least satisfying: right now those two stations read identical soil moisture. 3.5 % each. It’s mid-September and there’s been no rain in weeks, so there is nothing to detect.
My first read of that was “nothing here.” The catchment data says the opposite. The climatology isn’t remotely similar — so when the first atmospheric river lands, they should diverge sharply. The difference between those two readings is a hypothesis with a number attached now, rather than a hunch.
A five-digit code that turned out to matter
Late in the build, a USGS parameter code turned up at the Upper Truckee River gauge that I didn’t recognise: 70369.
I could have guessed from the numbering neighbourhood — the 703xx range holds dissolved-solids and suspended-sediment codes — and produced something plausible. Instead I wrote a lookup command and asked:
70369 — Suspended sediment particles between 0.50 to 16.00 microns, water, unfiltered, computed by regression equation, counts per liter
Then, from Lake Tahoe Info, verbatim:
“Fine sediment particles (0.5–16 µm) are primarily responsible for clarity loss in Lake Tahoe.”
The size class matches the regulation exactly.
Fine sediment under 16 µm is, per EPA, the main pollutant degrading Tahoe’s deep-water clarity — roughly two-thirds of the lake’s impairment. Lake Tahoe is Clean Water Act 303(d)-listed for it. The TMDL requires a 65 % reduction in fine sediment to restore Secchi depth to 97.4 ft by 2076, which was the average recorded between 1967 and 1971.
So parameter 70369, at the lake’s largest tributary, is the regulated clarity pollutant, measured continuously. Its companion 70372 is the same thing expressed as a daily load — which is the unit the TMDL actually writes its allocations in.
The mechanism is two-sided and we now measure both halves: fine sediment particles scatter light, and algae absorb it. Sediment from five gauges with real-time turbidity; algae from chlorophyll and phycocyanin fluorescence on the lake sondes. Plus 131 years of outflow at Tahoe City and 66 years of Blackwood discharge for context.
Both sediment parameters are regression surrogates, per USGS’s own definitions — derived from turbidity, not laboratory particle counts. That caveat travels with them everywhere they appear on the dashboard, because a modelled value labelled as a measurement is how you end up trusting a number you shouldn’t.
Nine things I got wrong
This is the part worth reading, because every one of these produced plausible-looking output rather than a crash.
1. I keyed the sensor-slug map on the wrong names. I built it from the class-style prefixes in each sensor’s id field (SoilEnvironmentalConditions) while the inventory nests everything under display names (Soil Environmental Conditions). Result: the code looked up all 23 sensors, matched nothing, and skipped every one — without making a single HTTP request. The log was clean. Caught by a probe that asked which slugs actually resolve.
2. I guessed 63158 for the lake outlet’s elevation series. The probe replied “configured but not reported: 63158 / available and not configured: 63160.” Wrong code, silently absent column.
3. I pinned statistic_id=00011 to stop daily aggregates colliding with instantaneous readings. Correct for its purpose, and it silently hid 70372 — the TMDL load parameter — which is published under statistic 00006 because a load is a daily total. The most regulation-relevant series in the project, filtered out by a fix for a different bug.
4. Then I fixed that by sending statistic_id=00011,00006. parameter_code accepts a comma-separated list. statistic_id does not. It returns HTTP 200 with zero features — no error, no warning. Every gauge, for hours, until I noticed an empty-looking CI log.
Worse: when Brooks reported the request URL showed statistic_id=00011%2C00006, I said that meant USGS had accepted it. It meant the URL was formed correctly and nothing more. The feature count was in the same log line and I didn’t look at it.
5. I used a bounding box where a watershed was needed. A rectangle around the Tahoe basin caught fourteen active stations draining to the Carson River or the Truckee below the outlet. Kings Canyon, Franktown, Ophir, Donner Lake — all flowing the wrong way.
6. I tested Web Mercator polygons against WGS84 points. TEON’s catchment GeoJSON stores coordinates in metres, not degrees, despite RFC 7946 saying otherwise. My point-in-polygon matched 0 of 28 stations with no error at all. A scattered miss means bad coordinates; a total miss means a systematic mismatch, which is what pointed at the projection.
Then the detector I wrote to catch it needed three attempts of its own. Checking coordinate magnitude misclassified UTM. Inverting and checking for a valid lat/lng also failed — UTM inverts to (6.7°E, 36.1°N), a perfectly plausible spot in the Mediterranean. What finally worked was using the one fact I actually had: we know where this data is. Invert as Web Mercator, then check it lands on Lake Tahoe.
7. I fitted a 48-hour linear trend to a diurnal signal. It reported Homewood’s air temperature as “rising 4.85.” Air temperature has no 48-hour trend; it has a daily cycle, and the slope was just reporting where in that cycle the window happened to start and end. Replaced with a day-over-day comparison, which cancels full cycles.
8. I drew a confident flat line through a dead sensor. Sunnyside’s turbidity is negative in every sample, so clipping floored all 192 readings to zero — producing a tidy flat sparkline that reads as “a stable, valid measurement” when it means “this channel is broken and we hid it.” Now labelled no valid signal · raw mean −2.11.
9. A string replace that matched nothing, and reported success. I added three commands to a config file with a find-and-replace anchored on a line that wasn’t there. It printed “tasks added.” Five commands went missing and neither of us noticed for a day, because they still worked when invoked directly.
The pattern
Almost all of those are the same bug wearing different clothes: a filter that removed more than intended, and returned success.
A query that returns zero rows is indistinguishable from a query that was never going to match. Neither raises an exception. Neither logs a warning. Both look exactly like “there’s no data.”
The rule that fell out of it, now written into the repo: when a query narrows results, check the count, not the syntax.
Seven probe commands exist in this project for that reason — probe, usgs-probe, usgs-discover, usgs-params, reference-inspect, camera-probe, watch. They’re a few dozen lines each and they caught six real bugs between them. The alternative isn’t fewer bugs. It’s the same bugs, shipped, producing numbers that look fine.
The infrastructure, briefly
Same architectural template as the streamflow monitor, grown up a bit:
TEON + USGS APIs
│
▼
data/raw/**.json a 7-day working buffer, pruned
│
▼
data/processed/*.parquet the durable record — accumulates,
│ deduped on the sources’ own IDs
▼
web/assets/latest.json built at deploy time, never committed
│
▼
GitHub Pages
Three workflows: fetch hourly, pages on push, and watch every six hours.
That last one exists because several things I care about will change without announcement. The manual sondes publish nothing until someone snorkels out. MiniDot and HOBO both stopped around 10 June, which looks like a seasonal haul-out rather than failure. TEON currently hides one sonde behind a visibility flag that could lift. And their StoryMap describes four monitoring domains — nearshore, terrestrial, stream, and aquatic — while the API exposes three. If ponds and wet meadows ever appear, that’s a large new dataset.
So watch diffs the inventory against a stored baseline and opens a GitHub issue when something notable moves. Record counts are deliberately excluded from the comparison — they change hourly, and including them would make every single run look like a change.
One nice detail: pixi again, and the repo is bounded at about 160 MB steady state. The first design committed every fetch artifact forever and would have hit GitHub’s soft limit in five days.
What the disk can’t see yet
3,392 camera frames in an S3 bucket named Snow photos, going back to November 2025. I wrote a probe to test whether they’re publicly readable; every standard URL form returns 403. A winter of Tahoe snowpack from five angles, one email away.
About 930,000 dormant records — slugs confirmed, reachable, waiting on a deliberate paginated backfill. Includes the precipitation gauge, which is the forcing variable the transect wants most.
Why Glenbrook 2 is wet. The catchment join gives Glenbrook 2 and Glenbrook 5 identical attributes while they read 42 % and 3.5 % soil moisture. Catchment means explain between-catchment variation, not within. That needs the source rasters sampled at each station point — real GIS work rather than a join.
Ground truth for a clarity model. UC Davis TERC’s Secchi record lives in the EDI repository as a versioned, DOI-bearing data package going back to 1968. Their 2025 report also shows why any model here has to be seasonal: winter clarity is stable, summer clarity is degrading, and 2025’s summer average of 53.4 ft was the fifth poorest on record. An annual mean averages away the only part of the signal that’s actually moving.
And one thing worth stating plainly. TERC’s 2025 report says they have begun lining up decades of data on the potential drivers of clarity, alongside Secchi depth — stream and atmospheric sediment, phytoplankton, picoplankton — and that in 2026 they’re deploying new imaging technology to visualise particle aggregation.
That’s the same analysis my “clarity nowcast” idea sketches, being done by the people with the instruments, the fifty-eight-year record, and the funding. This project isn’t a novel scientific result and I shouldn’t pretend otherwise. What it is: a fast, public, reproducible view over data that’s otherwise scattered across two agencies and several undocumented endpoints, with its own data-quality problems stated on the face of it.
That has real value. It’s just a different kind of value than the science.
The personal bit
I went to UNR. I drove around that lake more times than I can count and never once thought about how the clarity number gets made.
It gets made by two researchers in a boat with a snorkel, every month, in the snow, pulling algae off optical windows so a sonde doesn’t drift. It gets made by a plate on a rope that a Vatican astronomer invented in 1865 and nobody has improved on. And now it also gets made by thirteen Campbell loggers and a handful of YSI sondes quietly writing to an S3 bucket nobody documented.
The most Tahoe thing I found in the whole dataset isn’t a sensor reading. It’s a single field in USGS’s station description for the Tahoe City gauge:
Datum of gage is 6,220.00 ft above U.S. Bureau of Reclamation datum.
Without it, the lake level reads 7.07 ft — a meaningless number. With it, the lake surface is at 6,227.07 ft: four feet above its natural rim, and two feet below the legal maximum. Above the rim, water flows to the Truckee River and on to Pyramid Lake. Below it, Tahoe stops draining entirely and becomes a terminal lake.
Four feet of margin, expressed in a datum offset buried in a station note.
The dashboard shows the elevation now, with the rim and the legal ceiling beside it. Because 7.07 ft tells you nothing, and 6,227.07 ft tells you where the lake stands in a hundred and twenty years of water law.
🌲 The code is on GitHub, MIT licensed, and the data belongs to TEON and USGS. If you’re at TEON and reading this: the Sunnyside turbidity channel has about a −2.1 FNU zero offset, the pH probes are dead fleetwide, and your watershed layer has a soil pH of 1.79 at Cave Rock. Happy to share the details.