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
- agritech
- climate-data
- data-visualization
- early-warning
- era5
- express.js
- geojson
- geospatial
- gis
- iot
- leave-one-out-validation
- mapbox-gl
- nasa-power
- node.js
- open-meteo
- pwa
- python
- react
- rest-api
- service-worker
- tailwindcss
- typescript
- vite
- vitest
Log in or sign up for Devpost to join the conversation.