MIP-0001: Bathing-water quality in the ranking, and a sea-lore paragraph in every reply¶
| Status | Implemented — ARCHITECTURE.md §5g/§5h; branch feat/mip-0001-water-quality-rag-finetune |
| Author | Claude Fable 5.1, for M. Hoffmann |
| Created | 2026-09-05 |
| Phase | 0 (CLI) now; the reply format is designed for Phase 1 (Telegram) |
| Related | ARCHITECTURE.md §5 (integration pattern), §8 (heuristics honesty); FUTURE-WORK.md §1.3 (wave period), §9.1 (ocean-knowledge grounding — this is its first concrete step) |
| Effort | M — new client, a pure matcher, one scoring rule, a curated sourced lore corpus; no new module |
| Gain | user value (a safety-critical water veto) |
| Effort vs Gain | cheap win, delivered — moderate effort for a real safety fix in the score |
| Depends on | none; Phase 0/1; no cloud resource used or needed |
| Risk | the undocumented IMA endpoint can change or vanish without notice |
| Cost so far | n/a — PRs #2/#3/#4 predate the Cost: trailer convention (Claude-Session links instead) |
1. Summary¶
marola ranks beaches by waves, wind, temperature and a jellyfish heuristic, but says nothing about
whether the water is fit to swim in, and in Santa Catarina that is published, per sampling point,
by the state environment agency (IMA). This proposal adds a WaterQualityClient integration with
IMA's feed as the local default, folds PRÓPRIA/IMPRÓPRIA into the score deterministically, makes
the top pick's output a detailed block (water quality, tide, wave period) instead of one line, and
ends every reply with one short, sourced paragraph of sea lore, either a "thing most people don't
know about the sea" or a portrait of one sea creature, rotated daily so it doesn't repeat.
2. Motivation¶
The live run from Campeche on 2026-09-05 (RUN-LOCALLY.md §4) ranked six beaches at 55/100 with
identical notes. Meanwhile IMA's bulletin of 2026-09-03 has five sampling points on Praia do
Campeche: four PRÓPRIO, and Ponto 73 at the mouth of the Riozinho do Campeche IMPRÓPRIO with
749 enterococci/100mL (limit: 100). marola would have sent someone into that stream mouth with a
"choppy, cold water" note and nothing else. That is exactly the "safety-relevant, deterministic,
outside the LLM" category AGENTS.md says marola must get right.
The second half, the lore paragraph, is product, not safety: the reply is currently a number
and a sentence. One well-sourced paragraph about the sea in front of the reader is the cheapest
way to make it something people open on purpose, and it seeds the marine-knowledge corpus
FUTURE-WORK.md §9.1 needs anyway.
3. User-visible change¶
Today (top pick, one line):
1. [ 55/100] Praia da Armação (7.9km away) best at Sun 6 Sep, 00:00 | 19.3°C sea, 19km/h wind | jellyfish: Low | choppy (0.7m waves), breezy (19km/h), cold water (19.3°C)
Proposed. The ranked list gains one column; the top pick gets a block; the reply ends with lore:
1. [ 55/100] Praia da Joaquina (4.6km) Sun 6 Sep, 00:00 | water: PRÓPRIA (1/1 pts, 25 Aug) | 19.0°C, 20km/h, 0.9m | jellyfish: Low
2. [ 35/100] Praia do Campeche (2.1km) Sun 6 Sep, 00:00 | water: 4/5 PRÓPRIA — avoid Riozinho mouth (25 Aug) | 18.9°C, 20km/h, 1.0m | jellyfish: Low
3. [ 55/100] Praia do Rio Tavares (2.1km) Sun 6 Sep, 00:00 | water: no data | ...
Top pick — Praia da Joaquina, Sun 6 Sep 00:00-01:00
Water quality PRÓPRIA at Ponto 33 (Av. Pref. Acácio Garibaldi São Thiago, 1416), sampled 25 Aug,
10 enterococci/100mL, no rain, water 16°C. Source: IMA/SC bulletin 43, 2026-09-03.
Sea 19.0°C, waves 0.9m every 9s from the SE (swell 0.7m), current 0.4 km/h
Tide low 02:10 (-0.3m), high 08:40 (+0.9m) — tomorrow morning is a rising tide
Air 18°C, wind 20 km/h from the S, UV 0 at this hour, 5% chance of rain
Jellyfish Low — cold water and wind, none of the four warm-calm signals
Whales Low at this hour (dark); 06:00-10:00 rate High — humpback season
Draft summary: ...
Reviewer (score 80/100, verdict: approve): ...
Did you know? The "caravela-portuguesa" that strands on Santa Catarina beaches in winter isn't a
jellyfish at all — it's a colony of four kinds of animal living as one, and its sting stays active
for hours after it's dead on the sand. Never touch one, even dry. [source: <url>]
Telegram (Phase 1) gets the same block, the lore paragraph last, under a 🌊 line.
4. Data sources and dependencies reviewed¶
4.1 IMA/SC balneabilidade feed — the pick¶
- What: Instituto do Meio Ambiente de Santa Catarina's bathing-water programme, 260 sampling points across 28 coastal municipalities, classified PRÓPRIO/IMPRÓPRIO per CONAMA 274/2000 (enterococci ≤ 100/100mL in 80% of the last five samples).
- Format, verified 2026-09-05:
POST https://balneabilidade.ima.sc.gov.br/relatorio/mapawith no body returns a JSON array of every point:CODIGO,MUNICIPIO,MUNICIPIO_COD_IBGE,BALNEARIO,PONTO_NOME("Ponto 89"),LOCALIZACAO(street-level),LATITUDE,LONGITUDE(strings), andANALISES: the last five samples, eachDATA(dd/mm/yyyy),CONDICAO(PRÓPRIO/IMPRÓPRIO),CHUVA(Ausente/Fraca/Intensa),RESULTADO(enterococci/100mL),TEMP_AGUA. 207KB, 1.2s, no key, no auth, no cookie. This is the endpoint the portal's own OpenLayers map calls; it is not documented anywhere. Sibling endpoints (municipio/getMunicipios,local/getLocaisByMunicipio,pontoColeta/getPontosByLocal,registro/anosAnalisados) also answer, butrelatorio/mapaalone gives everything marola needs in one call. - Cadence, from bulletin 43's own "Considerações técnicas": weekly in season; from April to September only Balneário Camboriú's central beach is weekly (court order) and the full 260 points are sampled once a month, in the last week. So off-season a result can be up to ~5 weeks old.
- Coverage near the author: five points on Praia do Campeche, 1.0-3.3km from the origin used in
RUN-LOCALLY.md; Joaquina, Mole, Armação, Morro das Pedras, Pântano do Sul, Matadeiro all have points. Lagoa da Conceição (a lagoon, not sea) has eight, several IMPRÓPRIO, relevant because Overpass tags some lagoon shoresnatural=beach. - Terms: public agency, public data, no terms page found on the portal. Treat as: one request
per run, identify with marola's
User-Agent, cache, degrade to "no data" on any failure. - Not verified: whether the endpoint is rate-limited; how it behaves during the summer weekly
cadence (only the off-season shape was observed); whether
CONDICAOever takes the third value "Indeterminado" the portal's legend mentions (only PRÓPRIO/IMPRÓPRIO seen in the 2026-09-05 feed). - Also verified as the fallback: the weekly PDF bulletins
(
/relatorio/downloadPDF/YYYY-MM-DD, 18 pages) carry the same rows minus coordinates and counts. If the JSON endpoint disappears, this is the parse target. Not needed today.
4.2 Open-Meteo Marine API — already used; three variables not yet fetched¶
Verified 2026-09-05 against the docs: wave_period, wave_direction, swell_wave_height/
swell_wave_period/swell_wave_direction, ocean_current_direction, and sea_level_height
(tides, above global mean) are all available hourly at the same endpoint OpenMeteoClient
already calls. No water-quality, turbidity or chlorophyll variables exist: Open-Meteo cannot
replace §4.1. Adding the wave/tide fields is free (query-string change) and is what the detailed
block in §3 uses. FUTURE-WORK.md §1.3 wanted the same fields for surf scoring.
4.3 Reviewed and not picked¶
- Copernicus Marine Service (chlorophyll/turbidity layers): global, free registration, but
gridded NetCDF and a proxy for clarity, not sanitary fitness.
FUTURE-WORK.md§1.4 already scopes it as a "next-tier integration" for dive visibility. Not a substitute for IMA. - Other Brazilian states (INEA/RJ, CETESB/SP): same CONAMA classification, each its own portal
and format. Out of scope here; the trait in §5 is shaped so each becomes one more
WaterQualityClientwithout touchingRecommender. - Crowd reports:
SightingStoreexists. AddingSightingKind.Pollutionis a five-line complement (people see oil, foam, sewage before any bulletin does), included in §5 because it's nearly free, but it is not a data source for the score.
4.4 Sea lore — no third party; a curated, sourced file in the repo¶
No API serves "interesting true facts about the sea". The honest way to ship this without an LLM inventing marine biology is a hand-curated resource with a source URL per entry, shown verbatim. Seed list and selection rules in §5.4.
5. Design¶
5.1 New trait and model (core/)¶
// core/src/main/scala/marola/water/WaterQuality.scala
enum BathingCondition derives CanEqual:
case Proper, Improper, Unknown
final case class SamplingPoint(
id: String, beachName: String, pointName: String, location: String,
coordinates: Coordinates, latest: Option[WaterSample])
final case class WaterSample(
sampledOn: LocalDate, condition: BathingCondition, rain: Option[String],
enterococciPer100ml: Option[Int], waterTempC: Option[Double])
/** Per-beach verdict: every sampling point matched to the beach, worst-first. */
final case class WaterQuality(
points: List[SamplingPoint], source: String, bulletinDate: Option[LocalDate]):
def properCount: Int; def improperPoints: List[SamplingPoint]; def isStale(today: LocalDate): Boolean
trait WaterQualityClient:
/** All points in the provider's region — one call per run, cached in memory for that run. */
def samplingPoints: List[SamplingPoint] < Sync
Matching (pure, in water/WaterQualityMatcher): a point belongs to a beach if its normalised
BALNEARIO (accents stripped, "PRAIA DO/DA/DE" dropped, case-folded) equals the normalised OSM
name, or it lies within 2.5km of the beach centroid and no other beach's name matches it. Name
match wins; distance is the fallback for OSM/IMA naming drift ("Praia da Armação" vs "PRAIA DA
ARMAÇÃO DO PÂNTANO DO SUL"). Unit-tested against a checked-in fixture trimmed from the real feed.
5.2 Providers¶
local/:ImaScWaterQualityClient:Http.postForm(mapaUrl, Map.empty)(the endpoint takes an empty POST),JsonValueparse, strings → numbers withtoDoubleOption/toIntOption, dates viaDateTimeFormatter.ofPattern("dd/MM/yyyy"). A parse failure of one point drops that point, not the feed. Zero new dependencies.- Selection (
AppConfig):MAROLA_WATER_QUALITY_PROVIDER=ima-sc|none, defaultima-sconly when the origin lies inside Santa Catarina's bounding box (lat −29.4…−25.9, lon −53.9…−48.3), otherwisenonewith the note "no water-quality source for this region yet (MIP-0001 §11)". A futureinea-rjslots in the same way.
5.3 Wiring¶
Recommender.bestPerBeachTomorrowgainswaterQuality: Option[WaterQualityClient]; it fetches the points once, runs the matcher per beach, and passes the per-beachWaterQualityinto scoring.BestHourgainswaterQuality: Option[WaterQuality].OpenMeteoClientfetcheswave_period,wave_direction,swell_wave_height,swell_wave_period,sea_level_height;HourlyConditionsgains the matchingOption[Double]s. Tide "low/high" times for the block are the local minima/maxima ofsea_level_heightover tomorrow's 24 hours; no tide-table API needed.Main: the column and the detailed block in §3;--briefkeeps today's one-line format.- MCP:
get_swim_recommendationoutput gains awater_qualityobject per beach; new toolget_water_quality(lat, lon, radius_km)returning matched points, useful to an agent on its own. SightingKind.Pollutionadded;--report-sighting pollution <beach> [note]just works.
5.4 Sea lore¶
core/src/main/resources/sea_lore.json: a list of entries
{ id, kind: "secret" | "creature", text, source, region: ["BR-S", "global"], months: [..] | null }.
Selection is pure (lore/SeaLore.pick(today, beachName, regionTags)): filter by region and month,
then choose with a seeded shuffle (seed = today.toEpochDay * 31 + beachName.hashCode) so the same
beach shows the same entry all day and a different one tomorrow, and neighbouring beaches differ.
The text is appended verbatim: it never passes through the LLM, so it can't be paraphrased into
something the source doesn't say. Reviewer receives it as a lore input field only so it can
flag a summary that contradicts it.
Seed entries (sources to be attached and checked one by one before merge; that check is part of the implementation PR, not assumed here):
- creature: caravela-portuguesa (Physalia physalis) is a siphonophore colony, not a jellyfish; strands on SC beaches in winter/spring with onshore winds; sting active after death. Region BR-S, months 6-10.
- creature: baleia-franca (Eubalaena australis) calves in the shallows off Santa Catarina's south coast June-November; the APA da Baleia Franca protects the stretch from Florianópolis to Balneário Rincão. Region BR-S, months 6-11.
- creature: humpbacks (baleia-jubarte) migrate past the island July-November, the same fact
Swimability.whaleSightingLikelihoodalready uses. Region BR-S, months 7-11. - creature: green turtles (Chelonia mydas) graze year-round on the island's rocky points. Region BR-S.
- secret: sea foam is whipped-up dissolved organic matter (algal proteins, lipids), not pollution by itself, but persistent brown foam at a stream mouth is worth reporting. Global.
- secret: summer water in Santa Catarina can be colder than winter's on a NE-wind day: wind-driven upwelling brings South Atlantic Central Water to the surface. Region BR-S, months 11-3.
- secret: the ocean has absorbed roughly a quarter of the CO₂ humans emitted, and it is measurably more acidic for it. Global.
- secret: most of the light in the deep sea is made by animals: bioluminescence is the norm below 200m, not the exception. Global.
Eight is enough to rotate for a week without repeats; the file is meant to grow. Every entry needs a URL a human checked. An entry without one does not ship.
6. Scoring / safety impact¶
Deterministic, in Swimability, alongside the existing deltas:
| Matched points for the beach | Delta | Note |
|---|---|---|
| ≥ 1 point, all IMPRÓPRIO (fresh) | score forced to 0 | water unfit for bathing — IMA <bulletin date>, Ponto NN (<location>), <n> enterococci/100mL |
| mixed | −20 | <k>/<n> points PRÓPRIA — avoid <location of each IMPRÓPRIO point> |
| all PRÓPRIO (fresh) | 0 | water PRÓPRIA (<n> pts, <sample date>) |
no match, or provider none |
0 | no water quality data (no penalty: absence of data is not evidence of pollution) |
| any match but latest sample older than 45 days | treated as no match | water quality data stale (<date>) |
45 days covers the off-season monthly cadence (§4.1) with margin; in season it will never trigger.
The veto uses IMA's own CONDICAO, which already encodes CONAMA 274's five-sample rule; marola
does not re-derive it from RESULTADO. RESULTADO is shown, not scored.
7. Verification plan¶
Unit tests (all pure, no network):
WaterQualityMatcherSpec: fixture of the 12 Florianópolis points nearest Campeche from the real 2026-09-05 feed (checked in, trimmed); asserts Campeche gets its five points and none of Lagoa da Conceição's, Armação matches "PRAIA DA ARMAÇÃO DO PÂNTANO DO SUL" by name, a beach with no points getsNone.ImaScWaterQualityClientSpec: parses the fixture JSON; a point with a malformed date is dropped, the rest survive.SwimabilitySpecadditions: the four rows of §6, including the 45-day staleness boundary.SeaLoreSpec: same day + beach → same entry; next day → different entry; month filter excludes out-of-season creatures; every entry has a non-emptysource.OpenMeteoClientSpec(new, first test for that module): tide extrema from a fixture series.
Live checks (just run, just e2e):
- From Campeche: Ponto 73 appears as the IMPRÓPRIO note, score drops by 20, block shows tide times that agree with the Marinha do Brasil tide table for Florianópolis within ±30 min.
- From Rio (
--lat -22.9878 --lon -43.1913): provider auto-selectsnone, output says so, nothing else changes. - MCP:
get_water_qualityover stdio returns the five Campeche points.
Done means: all of the above green, ARCHITECTURE.md §5 table gains row 5g, RUN-LOCALLY.md's
expected output replaced with a run that shows the block, this MIP flipped to Implemented.
8. Risks, limitations, and honest caveats¶
- Undocumented endpoint. It can change or vanish without notice. Mitigation: everything
degrades to
Unknown/"no data"; the PDF path is the documented fallback; a checked-in fixture keeps the parser tested even if the live feed breaks. - Off-season staleness. A monthly sample says little about today after a storm. The block
shows the sample date and the
CHUVAflag; the 45-day rule turns very old data off entirely. This must be printed, not hidden. - A point is not a beach. Ponto 73 is a stream mouth; 300m away the water is fine. The mixed rule (−20 + named locations) exists precisely so one bad point doesn't erase a 4km beach, and so the reader knows where not to swim. Full veto only when every point agrees.
- Lagoon shores tagged as beaches. Lagoa da Conceição's IMPRÓPRIO points will match "beaches" Overpass returns on the lagoon. That's correct behaviour (those are the spots people actually swim), but the name-vs-distance matcher must not attach lagoon points to sea beaches.
- Lore correctness is a human job. The seeded shuffle and verbatim display are the technical guard; the source check is the real one. No entry without a checked URL.
- Regional. SC only, by design; everywhere else the feature is invisible except for one note.
9. Alternatives considered¶
- Do nothing. Leaves the Ponto 73 case unaddressed; rejected on safety grounds.
- Parse the PDF bulletins. Works, no coordinates, brittle layout, needs a PDF library. Kept as the fallback only.
- Let the LLM write the lore. Cheapest to build, and exactly what §9.1 and the
mipskill forbid: unsourced marine "facts" reaching users. Rejected. - Score
RESULTADOdirectly (e.g. penalise 100-800 enterococci as "borderline"). Tempting, but it second-guesses the agency's own five-sample classification with a single number. Rejected; show the number, score the classification.
11. Open questions¶
- Mixed-beach penalty size. −20 is a guess at "one bad stream mouth on a good beach"; is a flat −20 right, or should it scale with the share of IMPRÓPRIO points?
- Auto-select by bounding box vs. always-on. Bounding-box selection is invisible magic; an
explicit
MAROLA_WATER_QUALITY_PROVIDER=ima-scdefault with a clear "not your region" note may be more honest. Decide before implementation. - Cache the feed to disk? 207KB, weekly-changing data, fetched on every run today. A
./data/cache with a 24h TTL is trivial but is the first on-disk cache in the pipeline (Phase 4 territory). Proposal: in-run cache only for now; disk cache as a follow-up. - Lore language. Replies are English today; the lore about this coast reads more naturally
in Portuguese. Ship English entries first, add
langto the schema now so Portuguese can follow without a migration. - Contact IMA? Using an undocumented endpoint politely is defensible; asking whether they mind is better. Worth one email before this goes into a public bot (Phase 1).
Appendix¶
Real feed excerpt (2026-09-05), the five Campeche points nearest the RUN-LOCALLY.md origin,
distance from origin, latest sample:
1.0km PRAIA DO CAMPECHE (Ponto 89) Av. Jerônimo Venâncio Chagas, 113 25/08/2026 PRÓPRIO ent=10 chuva=Ausente T=16
1.8km PRAIA DO CAMPECHE (Ponto 75) Av. Campeche, 300, no mar 25/08/2026 PRÓPRIO ent=10 chuva=Ausente T=16
1.8km PRAIA DO CAMPECHE (Ponto 73) Av. Campeche, 300, no Riozinho 25/08/2026 IMPRÓPRIO ent=749 chuva=Ausente T=16
2.2km PRAIA DO CAMPECHE (Ponto 35) Av. Pequeno Príncipe, 3348 25/08/2026 PRÓPRIO ent=10 chuva=Ausente T=16
3.3km PRAIA DO CAMPECHE (Ponto 90) Rua Campos Limpos, 281 25/08/2026 PRÓPRIO ent=10 chuva=Ausente T=16
5.3km LAGOA DA CONCEIÇÃO (Ponto 72) Rua Canto da Amizade 25/08/2026 IMPRÓPRIO ent=573 chuva=Ausente T=16
One raw point, verbatim:
{"CODIGO":"415","MUNICIPIO_COD_IBGE":"4205407","MUNICIPIO":"FLORIANÓPOLIS","PONTO_NOME":"Ponto 98",
"BALNEARIO":"PRAIA DOS INGLESES","LOCALIZACAO":"Rua das Gaivotas, n°1380, na foz do Rio Capivari",
"LATITUDE":"-27.4261029","LONGITUDE":"-48.3996518",
"ANALISES":[{"DATA":"24/08/2026","CONDICAO":"IMPRÓPRIO","CHUVA":"Ausente","RESULTADO":"197","TEMP_AGUA":"16"},
{"DATA":"27/07/2026","CONDICAO":"IMPRÓPRIO","CHUVA":"Ausente","RESULTADO":"2098","TEMP_AGUA":"22"}, ...]}
Feed-wide, 2026-09-05: 260 points, 28 municipalities, ANALISES holds the last 5 samples (monthly
dates 28/04 → 25/08 off-season), CONDICAO ∈ {PRÓPRIO, IMPRÓPRIO}. Bulletin PDFs:
https://balneabilidade.ima.sc.gov.br/relatorio/downloadPDF/2026-09-03 (18 pages, no coordinates).
Open-Meteo Marine variable list checked at https://open-meteo.com/en/docs/marine-weather-api.