Inspiration

A weather dashboard tells a farmer it is 24 °C with 60% humidity. It does not tell them whether to spray this afternoon.

That gap is the whole project. Spraying in the wrong conditions wastes the chemical, drifts onto a neighbour's field, and has to be repeated. The information needed to avoid it already exists in the Conduit station's sensors — it just never becomes an instruction.

So KIVULI does not show you a temperature. It says "Do not spray now — wait until 13:25", and then shows exactly why.

Kivuli is Swahili for shade.

What it does

KIVULI turns the JKUAT Conduit station's measurements into field instructions: spray windows, grain-drying windows, and ISO 7243 work/rest cycles for outdoor labour. Every instruction opens into the chain that produced it — sensor reading, validated correction, each threshold that passed or failed, then the sentence.

Four standing watches sit over the data: heat stress, spray window, heavy rain, river discharge. A campus shade map projects real building shadows from surveyed footprints and the sun's true position. A natural-language box answers questions in English and Kiswahili.

How we built it

Bias correction. A global forecast model is systematically wrong at any single point. We fit an hour-of-day offset for each variable against the station's own record:

Every coefficient is leave-one-out validated — each point is corrected by a model fitted on all the other points, so nothing scores itself. Fitted on 312 paired station-hours:

Variable Bias MAE before MAE after
Temperature (°C) −0.50 0.89 0.80
Humidity (%) +0.29 5.19 4.41
Wind speed (m/s) +1.54 1.55 0.59
Pressure (hPa) +2.76 2.77 0.41

Decisions. Delta-T is dry-bulb minus wet-bulb — and because the station measures wet bulb, this is exact rather than estimated from humidity. A spray window needs Delta T in [2, 8] wind in [0.8, 4.2] m/s, and no rain within six hours.

Wind below 0.8 m/s is a failure, not ideal weather. Still air signals a temperature inversion that holds fine droplets and drifts them off-target — the mistake operators make most often.

Heat. WBGT against the ISO 7243 work/rest bands, from the station's measured globe temperature rather than an estimate.

Shadows. Projected geometrically from 133 surveyed building footprints and the solar position for the selected minute.

What we learned

More data made our headline number worse, and that was the right outcome. Fitted on one day (24 hours), temperature MAE looked like 1.12 → 0.56 °C. Fitted on thirteen days (312 hours), the honest figure is 0.89 → 0.80. The first number was mostly noise. We published the correction in the README rather than quietly swapping the table.

The instrument limits the advice. The mast carries three independent thermometers. They disagree with each other by 0.42 °C on average — and if we had built on a different one of the three, 64 of 1,412 spray verdicts would flip. The station's own noise is large enough to change the recommendation, so we put that on the model-check page instead of hiding it.

Honest data is often uneventful. Across all thirteen days, peak WBGT is 22.6 °C — well under the 28 °C threshold. Our heat detector never fires on real weather, which means it cannot be demonstrated. The tempting fix was to ship a hot day that never happened.

Challenges we ran into

The map rendered black through two rounds of fixes. The cause was not in the map code at all: Mapbox adds its own .mapboxgl-map class declaring position: relative, our container was absolute inset-0, and both are single-class selectors — so source order decided, and mapbox-gl.css loads last. The element stopped being positioned, inset-0 stopped stretching it, and the canvas came out 1367×300. That 300 is the HTML default height: the fingerprint of a zero measurement. We found it by measuring the live DOM, not by reading code.

Two pages were unreachable on a phone. The nav needed 525px in a 390px viewport and the header clipped the overflow, so "Model check" and "Calibration" could not be tapped at all.

Showing an alarm without faking one. We solved it with what-if sliders: shift a real day's readings and re-run the same decision functions. On 4 September, the warmest day recorded, maximum offsets take WBGT from a measured 21.8 °C to 31.4 °C and the watch fires — with the measured column still reading 21.8 °C beside it. Nothing is invented, and one tap returns to the truth.

Two broken sensor channels. Wind Gust Direction duplicates Wind Gust in all 18,364 rows — a speed in a field labelled degrees. Rain Gauge 2 reads flat zero while Gauge 1 records real tips. Both are dropped, because a broken gauge and a dry gauge are different claims.

Built with

Node 22, TypeScript, Express, React 18, Vite, Tailwind, Mapbox GL, Python for the offline fit. 378 tests — 217 server, 161 component. Every displayed number is tagged measured, bias corrected, raw forecast or reanalysis, and where the data cannot answer, the app says so instead of guessing.

Built With

Share this project:

Updates

Submission history