MIP-0067: INEA bathing water beyond one PDF — discovery, per-zone layouts, Niterói and Cabo Frio¶
| Status | Draft |
| Author | Claude Sonnet 5.5, for W. Macedo (brainstorm of 2026-09-28: "Rio data is not matching and weird points") |
| Created | 2026-09-28 |
| Phase | 0 — CLI and board data only; no earlier-phase prerequisite is missing, no cloud spend |
| Related | MIP-0031 (the INEA parser, the Rio coordinate table, IneaRjWaterQualityClient), MIP-0056 §5.6 (inea-rj is the store's second adapter and wants the same discovery step), MIP-0001 (WaterQuality.fresh), #487/PR #489 (parser reads the header row), #488 (discovery and real date) |
| Effort | L — four bulletin layouts beyond the one MIP-0031 parses, per-city coordinate tables (~100 points), a new site area, and a change to what a sample's date means; no new dependency |
| Gain | user value — Rio shows current verdicts for all four zones, and Niterói and Cabo Frio get one at all; user value (safety) — a stale "PRÓPRIA" stops looking current |
| Effort vs Gain | do next — #487 and #488 are already filed and unblock the rest; the per-zone work is mechanical once they land |
| Depends on | MIP-0031 (built on its parser and tables). MIP-0056's inea-rj adapter should reuse this MIP's discovery and layouts rather than grow its own; coordination, not a blocker. No Phase 1 gate, no paid resource. |
| Blocked by | none |
| Risk | INEA's PDFs are Excel exports whose layout moved ~55pt between June and September 2026 and differs per zone; a fifth silent shift returns "no data" for a city until someone notices |
| Cost so far | — |
1. Summary¶
MIP-0031 shipped one INEA parser and pointed it at one hardcoded June PDF. Verified 2026-09-28, that is not enough: the parser returned 0 of 39 rows on INEA's current bulletin (fixed by
487), the client stamps every reading with today's date so the 45-day freshness gate never¶
fires, and four more INEA bulletins in Rio plus Niterói and Cabo Frio each have their own layout. This MIP makes the INEA source current (discover the newest bulletin per zone, use its real date), complete (Rio's four zones, Niterói, Cabo Frio) and honest (one coordinate per point, no verdict older than the freshness window).
2. Motivation¶
What the brainstorm found, all verified 2026-09-28 (Appendix):
IneaRjWaterQualityClient.DefaultEndpointis.../2026/06/Zona-sudoeste-e-Zona-sul-17-06-26.pdf, three months old, one of Rio's zones. MIP-0031 task 6 said "find the newest PDF"; the code does not.IneaRjWaterQualityClient.sampleOfsetssampledOn = LocalDate.now().WaterQuality.fresh(MaxSampleAgeDays = 45, used bySwimability) therefore cannot age an INEA sample out.sampling_points_rj.jsonholds 29 points, one coordinate per beach (the three Ipanema points and four Barra da Tijuca points share a coordinate). The bulletin has 39.- The other four bulletins parse to 0 rows: Ilha do Governador e Ramos and Paquetá put
PONTO COLETAbetween the beach and location columns and useGL0001-style codes; Niterói (GR000) and Cabo Frio (CF0001) differ again. Corrected 2026-09-29: with #489's header-row parser Niterói's 24-09-26 bulletin parses all 29 rows, verdicts matchingpdftotext; Ilha, Paquetá and Cabo Frio still give 0. - Niterói's PDF header reads "24 de SETEMBRO de 2025" on a 2026 bulletin.
3. User-visible change¶
Illustrative shape and values (--brief), for beaches in zones that have no data today:
Praia de Icaraí (Niterói) 62/100 at 09:00 | water: 1/6 IMPRÓPRIA — IC003 (24 Sep) | ...
Praia de Paquetá — | water: PRÓPRIA — Grossa (21 Sep) | ...
The date is the bulletin's, not today's. A reading older than the freshness window shows "no data", as an IMA/SC one does. Cabo Frio appears as a fourth area on the map.
4. Data sources and dependencies reviewed¶
4.1 INEA's city pages (the pick)¶
https://www.inea.rj.gov.br/ar-agua-e-solo/balneabilidade-das-praias/ links one page per
municipality. Checked 2026-09-28: /rio-de-janeiro/ (81 file links), /niteroi/ (24),
/cabo-frio/ (21). Each holds, for the current period, one dated bulletin per zone, named
<Zone>-DD-MM-YY.pdf, plus year-by-year *_historico* PDFs and Qualificação-anual PDFs:
| City | Zone | Current bulletin | Layout (header row) |
|---|---|---|---|
| Rio | Zona Sudoeste e Sul | …-21-09-26 (Nº 38) |
PRAIAS · LOCALIZAÇÃO · Ponto Coleta · CONAMA, codes BG00 |
| Rio | Ilha do Governador e Ramos | …-14-09-26 (Nº 16) |
PRAIAS · PONTO COLETA · LOCALIZAÇÃO · CONAMA, GL0001 |
| Rio | Paquetá | …-21-09-26 (Nº 35) |
same as Ilha, IU0000 |
| Rio | Sepetiba | Sepetiba-28-11-24 |
not updated since Nov 2024 |
| Niterói | Niterói | Niteroi-24-09-26 (Nº 65) |
code before location, GR000 |
| Cabo Frio | Cabo Frio | Cabo-Frio-05-08-26 (Nº 10) |
code before location, CF0001 |
The -N suffix on historico files (barra_e_zona_sul_historico-3.pdf) is WordPress's
re-upload counter, not a week. The same filename convention held for the 17 Jun and 21 Sep Rio
bulletins.
4.2 The historico PDFs and the xlsx (not the pick, kept for MIP-0056)¶
barra_e_zona_sul_historico-3.pdf is a year-to-date matrix, one column per weekly collection,
dated in its header (Jul 8 … Sep 21), with a third code format (BD005). It is not what the
parser reads. .../2026/08/Ultima-Atualizacao-25.08.2026.xlsx (41 sheets, 1.5 MB) holds
enterococci counts (NMP/100 mL) per point with real dates (Barra e Z. Sul to 2026-08-19), since 2005
or 2011 by sheet; it has no coordinates and a fourth code format (CF-01, GR00). That is
the "history export" MIP-0056 §5.6 row 2 says it does not know of; see §11.
4.3 Rejected¶
- Brasil.IO (auth wall, frozen 2020) — MIP-0031 §4.1.
- Any third-party aggregator: a second-hand, later verdict is the wrong shape for a swim-safety flag, and none was found that INEA does not already feed.
- Niterói's municipal ArcGIS map — MIP-0031 §11, still not fetched (§11 here).
5. Design¶
Landed in #488 (ahead of this MIP's tasks): discovery over /rio-de-janeiro/ and /niteroi/
inside IneaRjWaterQualityClient (no separate IneaBulletinIndex), filename dates, and a bulletin
already past 45 days is not downloaded (Sepetiba). Niterói's 29 points went into
sampling_points_rj.json as per-beach OSM centroids, like Rio's. Still open here: per-point
geocoding, the Ilha/Paquetá/Cabo Frio layouts, the missing Rio points and the Cabo Frio area.
Discovery (local/…/water/IneaBulletinIndex.scala, pure over the page HTML): from a city
page, every href whose filename matches -(\d{2})-(\d{2})-(\d{2})\.pdf$ and is not
historico/Qualifica*; the zone is the filename before the date and the sample date is
20YY-MM-DD from it, never from the PDF text (§2, Niterói's 2025). One fetch of the page, one of
each PDF. Any failure returns no points and logs why, never a stale hardcoded PDF.
Layouts (IneaPdfParser): #487 already bounds the beach-name column from the header row.
This MIP generalises it to a small Layout read from the same row — which of code and location
comes first, and a code pattern [A-Z]{2,4}[0-9]{2,4} — chosen per bulletin from its header, so
Ilha, Paquetá, Niterói and Cabo Frio are fixtures, not four parsers. A header it cannot
place yields no rows and a warning, as in #487.
Freshness: the client sets WaterSample.sampledOn to the bulletin's date; the existing
WaterQuality.fresh (45 days) then applies unchanged. No new window is introduced (§11 asks
whether 45 is right for a weekly agency).
Clients: IneaRjWaterQualityClient becomes one client over a list of city pages and their
coordinate tables; AppConfig's Auto case and WaterProvider.IneaRj stay. Niterói and Cabo
Frio sit inside the existing Rio-state bounding box (coversOrigin), so no new provider.
Coordinates: sampling_points_rj.json gains the missing Rio points; new
sampling_points_nit.json and sampling_points_cf.json, same code/beach_hint/lat/lon/
source shape, one coordinate per point geocoded from its LOCALIZAÇÃO text (a street
number, a landmark), each with its source. Rows without a coordinate are dropped, never guessed.
Cabo Frio area: a fourth entry in site/areas.json (about 120 km east of rio, by
coordinate arithmetic, not surveyed), so Cabo Frio beaches are found by BeachFinder.
Deterministic throughout; nothing goes through the LLM.
6. Scoring / safety impact¶
Swimability.waterVerdict is unchanged. Three behaviours change, all toward more honest:
- More beaches get a real IMPRÓPRIA veto (Niterói, Cabo Frio, Rio's other zones).
- INEA samples now age out at 45 days. Today they never do, so this removes a verdict from the screen when its bulletin is old, e.g. Cabo Frio's 5 Aug bulletin (54 days on 28 Sep).
- The date shown is the collection bulletin's, so a June reading no longer reads as today's.
7. Verification plan¶
IneaBulletinIndexSpec: a saved/rio-de-janeiro/page yields exactly the four dated links and ignoreshistoricoandQualifica*; Sepetiba's date is 2024-11-28.IneaPdfParserSpec: a fixture per zone (Ilha e Ramos, Paquetá, Niterói, Cabo Frio) parses every row, checked against an independentpdftotextcount, as #487 did for Sudoeste/Sul.IneaRjWaterQualityClientSpec:sampledOnequals the filename date, nevernow(); a 54-day bulletin yields no fresh points.SamplingPointCoordinatesSpec: every bulletin code resolves, and a code absent from the table is dropped.- Live, before merge:
just run -- --brief --lat -22.9027 --lon -43.1029(Niterói) and Cabo Frio's origin each show a dated water line for at least one beach. - Done: no INEA zone parses to 0 rows, and a bulletin with an unplaceable header is a logged warning in CI's smoke run, not a silent "no data".
8. Risks, limitations, and honest caveats¶
- Layout drift is the standing risk. It has already happened once in three months. The
header-derived layout survives a shift; it does not survive a redesign. The per-zone fixtures
are the tripwire, and a periodic live parse (like
just smoke) is the alarm. - Coordinates are still hand-curated and will drift as INEA adds points (MIP-0031 §8).
- Cadence differs per zone: Sudoeste/Sul and Paquetá were both issued 21 Sep, Niterói 24 Sep, Ilha e Ramos 14 Sep (the maintainer says about twice a month; not verified), Cabo Frio 5 Aug (54 days old). A single 45-day window is lenient for the weekly ones (§11).
- One point per bulletin row, one verdict: the bulletin carries no enterococci count or collection hour, so nothing here says how bad an IMPRÓPRIA is (§11's follow-up).
9. Alternatives considered¶
- Do nothing. Rio keeps a frozen June verdict that reads as today's. Rejected: it is the unsafe direction.
- A parser per zone. Four near-identical files that drift apart. Rejected for a header-derived layout.
- Parse the
historicoPDFs instead. One file per zone-year and a third code format, but the newest column is the same information as the bulletin, harder to extract. Rejected as the runtime source; useful only as backfill. - URL guessing from the
-Nsuffix or the upload month. Rejected: the suffix is a re-upload counter and the folder is the upload month (§4.1).
11. Open questions¶
- Freshness window: reuse 45 days, or set one per agency (a weekly bulletin 45 days old is long stale)? A person decides; the design reads the constant from one place either way.
- Does INEA publish point coordinates anywhere (a geoportal, a GIS layer)? The xlsx does not. Not searched beyond the city pages and the xlsx.
- Should Sepetiba be excluded by name or left to the freshness rule? The rule alone drops it.
- Follow-up MIP: use the xlsx's enterococci history (real NMP/100 mL and dates, 2005/2011–
2026) as MIP-0056's
inea-rjbackfill and for a "last 5 campaigns" check, which the Niterói bulletin's footer states as INEA's Imprópria rule (latest above 400, or two of the last five above 100). Needs the next MIP number after this one. - Follow-up (not a MIP): junk OSM beaches such as
parquinho(way/1202822690, tagsnameandnatural=beachonly, a playground in Botafogo) reach the map becauseBeachFinderapplies no filter. A story, not a design.
Appendix¶
Checked live¶
All 2026-09-28 unless stated.
GET .../ar-agua-e-solo/balneabilidade-das-praias/,/rio-de-janeiro/,/niteroi/,/cabo-frio/→ 200; link lists as in §4.1.GET .../2026/09/Zona-sudoeste-e-Zona-sul-21-09-26.pdf→ 200, 218 KB, 39 code+verdict rows bypdftotext; headerPRAIAS x=215, LOCALIZAÇÃO x=366vs June's155/305.IneaPdfParser(pre-#487) on that file → 0 rows; on the 17 Jun fixture → 39.- The same parser on Ilha e Ramos, Paquetá, Niterói, Cabo Frio bulletins → 0 rows; extracted headers and code shapes as in §4.1.
GET .../2026/09/barra_e_zona_sul_historico-3.pdf→ 200, 2 pages, columns Jul 8 … Sep 21.GET .../2026/08/Ultima-Atualizacao-25.08.2026.xlsx→ 200, 41 sheets, no latitude/longitude string among 781 shared strings; Barra e Z. Sul dates run to 2026-08-19.- OSM Overpass,
natural=beachwithin 2500 m of -22.95,-43.185 →way/1202822690,name=parquinho, no other tag, at -22.9506,-43.1949. core/…/WaterQuality.scala:MaxSampleAgeDays = 45;IneaRjWaterQualityClient.sampleOfusesLocalDate.now().
Not checked¶
- Whether Ilha e Ramos, Paquetá, Niterói and Cabo Frio parse once the layout is generalised; only their extracted geometry was read.
- Whether Sepetiba is discontinued or merely late; only its filename date was read. The maintainer reads it as abandoned (2026-09-29); either way #488 skips it by date, not by name.
- INEA's publishing cadence per zone beyond the dates seen on 2026-09-28 (Cabo Frio "roughly monthly" is one 54-day gap).
- The distance from the
rioarea to Cabo Frio (about 120 km is coordinate arithmetic). - A Niterói or INEA coordinate source (§11).