privatewelldata / Data rules
Data rules (deterministic; no AI in the data path)
Hard rule: every cleaning / normalising / mapping step is a script in scripts/. The live site uses no AI at all. AI never writes,
rewrites, translates or maps a data field; the only AI use is an offline draft of French interface text (see the end), pending human review.
The French cross-review covers UI strings (lib/i18n.ts) and guide prose only.
Provenance
Every labs row stores source (name), source_url, license, fetched_at (raw file mtime = download date) and updated (ingest date).
Raw official files are kept in data/raw/ (git-ignored) so every value can be re-checked.
Validation and quarantine (`scripts/ingest_labs.py`, `validate()`)
A row is flagged=1 with a flag_reason when any of these fail; flagged rows are stored but never published
(site queries read the pub_labs view = labs WHERE flagged = 0):
- name present, ≥ 3 chars; source_url and fetched_at present
- phone, if present, has 10 digits (11 with leading 1) or a recognisable NNN-NNN-NNNN pattern
- coordinates, if present, inside the country box (US 18–72°N, 180–64°W; CA 41–84°N, 142–52°W)
- US postal = 5 digits; Canadian postal =
A1A 1A1 - city or address present
Test badges (`TESTS` regexes in `ingest_labs.py`)
A badge appears only when the official scope text names the analyte (case-insensitive):
| badge | pattern |
|---|---|
| bacteria | coliform, e. coli, escherichia, heterotroph / hétérotroph |
| nitrate | nitrat, nitrit |
| arsenic | arsenic |
| lead | lead, plomb (whole word) |
| metals | iron/fer, mangan, copper/cuivre, uranium, antimon, barium/baryum, mercur, cadmium, chrom, selen/sélén |
| organics | organic (not "inorganic"), VOC, volatile, pesticid, herbicid, atrazine, benzene, hydrocarbures, trihalom, EPA 524/525 |
| pfas | pfas, pfoa, pfos, perfluor, EPA 533/537 |
| radio | radio, gross alpha / alpha brut, radium, tritium, gross beta |
Category words alone ("Inorganic", "Microbiological") do NOT create analyte badges. Ontario licence classes are categories,
so Ontario labs show their classes as text and only get lead/radio badges where the class names them.
Quebec badges come from the decoded parameter names of each accredited domain code (below).
Public-system-only flag (`public_only`)
public_only=1 if (a) the source's own lab-type field says "Public Water System", "Public Wastewater System", "Treatment, Storage" or "Municipal"
(California ELAP SLABTYPE, first 120 chars of scope), or (b) the lab name matches PUBLIC_RX
(water plant/treatment/utility/authority/district, wastewater, sewer, public works, city/town/village/county water, WTP/WWTP, municipal…).
Override: if the source states the lab accepts samples from private citizens (Ontario field), public_only=0.
These labs are hidden from search/featured by default and labelled when shown. It is a heuristic label, shown as "likely".
Quebec domain codes (`scripts/build_qc_domains.py`)
Decoded from the official MELCCFP/CEAEQ document *Champs et domaines d'accréditation en vigueur (DR-12-CDA)*
(data/raw/cda.pdf → pdftotext -layout). Parser rules are in the script docstring; output data/qc_domains.json
plus data/qc_domains.meta.json (source URL, document date, fetch date). The script fails loudly if any code in the page-1 index is not decoded.
Verification (`scripts/check_labs.py`, run after every load)
D1 row count == seed count; zero rows without provenance; published == total − flagged; 20 random published labs per database
re-checked against the raw official file (name, phone digits, city, postal must appear verbatim).
PDF state lists (`scripts/parse_states.py` → `data/states/*.json` → `ingest_labs.py`)
Parsing rules are in each parser's docstring (pdftotext -layout; column/position rules only). Each record keeps the exact source text
block (raw) for review. Extra validation for PDF records: street required, and a name ending in a bare number (name/address boundary
unclear) is flagged. Badges:
- PA (DEP Accredited Labs Report, drinking-water matrix only):
TESTSregexes over the listed analyte names. - MN (MDH private-well list): the five table columns map 1:1 (coliform→bacteria, nitrate, arsenic, lead, manganese→metals).
X*
(subcontracted, per legend) still counts and is stated in the scope text; a cell printed as both "X" and "cell" is ambiguous and not claimed.
All MN labs are marked "accepts private samples" because that is the list's definition.
- OH (Ohio EPA combined list): bacteria only from the microbiological methods (legend: E. coli and Total Coliform by MMO-MUG,
Quanti-Tray, Membrane Filtration); nitrate only when the category literally says Nitrate; pfas when "PFAS". Broad categories
("Standard Chemistry", "Trace Metals") are shown as text but do not create analyte badges.
CALA (BC, AB, NS, NB)
Lab list = the JS objects embedded in https://directory.cala.ca/directory-search; scope = each lab's public-scope page (fetched 1 at a time,
3 s gaps, 120 s back-off, stop after 3 failures). Stored: name, address, city, province, postal, phone, water appendices, valid-to date.
Never stored: emails, contact names. A lab is listed only if at least one appendix has a Matrix containing "Water" (an appendix without a
Matrix line inherits the previous one, as printed). "Water" in CALA includes wastewater and non-potable water, so the label says
"CALA accreditation (water)" and the call-ahead warning applies. clientsServed = "Specified Clients"/"Parent Institution Only" → public_only.
ME / NJ / MA / NH / MI (browser downloads, 2026-10-17)
- ME: AllLabs.xlsx (Maine CDC) + CommercialListMaineLabs.pdf (5/16/2026). Badges = TESTS regexes over active drinking-water analytes.
- NJ: NJDEP Data Miner export. No street/ZIP in source; located by municipality centroid. No badges.
- MA: MassDEP private-well survey (12/18/2025). Microbiology→bacteria, Radiochemistry→radio; accepts_private from the "residential customers" column.
- NH: NHDES labs-private-wells.pdf. accepts_private=1. Two labs printed with no blank line are split at a second phone number. City only accepted when the last address chunk is exactly "City, ST"; otherwise flagged.
- MI: EGLE chemistry/micro/lead-copper PDFs plus radiological, out-of-state, PFAS (in/out-of-state) and Cryptosporidium PDFs (second layout, parse_states.mi_extra), merged by EGLE lab number. Badges = TESTS over analytes; being on the PFAS list adds pfas, on the radiological list adds radio. PFAS analyte cells wrap across lines, so only the list name is kept for those files. Out-of-state labs keep their own state as region. PDF bullet glyph U+F0B7 is stripped from streets.
- ZIP checks: ZIP's Census ZCTA state must equal the stated state ("ZIP belongs to another state").
- geo seed uses INSERT OR REPLACE: d1.py retries a batch after a connection error, and a batch that had already committed then hit the primary key.
- New flag for every US record: ZIP's Census gazetteer place is >40 km from the stated city → "ZIP does not match city".
County well-water pages (US, /us/county/)
Built by scripts/build_county_risk.py from Water Quality Portal Result downloads (per county, siteType=Well; WQX 2.2 narrowResult, plus WQX 3.0
basicPhysChem when the box can download it). Unit/speciation conversions, non-detect handling, dedupe, "latest result per well" and the
10-well minimum are in the script docstring. Each row keeps the WQP query URL and download date. Wells include monitoring/research wells, and
the pages say so. Plausibility ceiling: arsenic 1000 µg/L, nitrate 100 mg/L as N; when a well's latest value is above it the county
"Highest" is withheld and the page says how many wells were set aside for review (they still count as above the limit). Spot-check: Adams County WI arsenic recomputed by hand from the raw CSV (10 wells, 5 non-detects, median 0.34, max 2.0) matched.
Quebec municipal well pages (/ca/quebec-wells/)
Built by scripts/build_qc_wells.py from the PACES compiled hydrogeological database (Données Québec, CC-BY 4.0). All 2019 PACES regional
files carry the same compilation (12 projects), so one file is read; the 2025–2026 files (Estrie, Laurentides, Lanaudière, Mauricie est,
BSL_KRT) use other schemas and are not included yet. Analytes and Quebec norms (RQEP Q-2 r.40, annexe 1): arsenic 0.010, manganese 0.12,
uranium 0.020, fluoride 1.50, barium 1.0 mg/L. Nitrate is not summarised (compiled fields do not state N vs NO3). Value parsing,
non-detects (incl. the negative-value "-DL" convention), placeholder dates (1900/1901/9000), dedupe, latest-per-well, the 10-well minimum and
the plausibility ceiling (100x norm, fluoride 10x; max withheld) are in the script docstring. Owner names and addresses in the source are
never read into the output. Spot-check: Val-d'Or arsenic recomputed from the raw rows (55 wells, 2 above 0.010 mg/L: 0.018 and 0.021) matched.
Quebec PACES 2025–26 files (added 2026-10-18)
Estrie and Laurentides-Les Moulins are merged into qc_wells (rules in scripts/build_qc_wells.py docstring). Lanaudière and Mauricie-Est result
tables have empty parameter and site columns, and BSL_KRT has maps only, so they are not used. Spot-checks against the raw GeoPackages:
Mascouche barium (17 wells, 1 above 1.0 mg/L, max 3.0, Laurentides file) and Austin arsenic (1 Estrie site + 9 compiled sites) matched.
Prices (/[loc]/test-costs/)
scripts/prices.py fetches fixed official pages and stores a price only when a fixed regex matches the page text; the matched text is kept as
the quote with URL and date. No match = hidden (WSLH homeowner package is currently hidden this way). Nova Scotia: the NSH web page lists
bacteria P/A at $34.50 while the January 2025 requisition PDF lists $30.00; the web page is used.
Translations
scripts/ai_fr.py: DeepSeek draft + second DeepSeek review pass against docs/glossaire-qc.md, then OQLF typography fixed by script.
Used only for UI strings and legal/guide prose (i18n/*_extra.json, i18n/*_legal.json). Data fields are never translated.