Two coverage measurements over California's public wildfire datasets, published as counts.
The pages are served: the index,
historical fire perimeter completeness,
and damage inspection coverage.
.github/workflows/pages.yml publishes site/ from main, running html-validate and
axe-core over the exact bytes it is about to serve rather than over a rebuild of them.
CAL FIRE and FRAP document the limits of these datasets carefully, in their own metadata. What is not published alongside them is the arithmetic behind those sentences: how many records per year, how many carry a federal identifier, how many cells in each field hold a value, how many hold a code meaning the value could not be determined, and how many hold nothing at all. This project counts exactly that and publishes it beside the sentence it answers.
Unofficial. Not affiliated with or endorsed by CAL FIRE, FRAP, or any California state agency. This is not a review of an agency's work. Every measurement operationalizes a limitation the publisher already states.
Status: Beta. Version 0.1.0, first signed tag not yet cut. Both measurements are
computed, tested, and published against pinned dataset retrievals (FRAP firep25_1 and CAL
FIRE DINS, retrieved 2026-08-07). The figures move only when those retrievals are deliberately
refreshed.
Historical fire perimeter completeness (site/perimeters.html) over FRAP's
California Historical Fire Perimeters, version firep25_1, retrieved 2026-08-07:
- 23,334 perimeter records, covering fire years 1878 to 2025. 77 records carry no year and are counted as their own cohort rather than being attached to a neighbouring year.
- 3,633 records carry an IRWIN ID (15.6%), reported per year so the transition is visible rather than asserted. FRAP's own release note describes the new Global ID as covering records "pre-dating IRWIN IDs".
- Cause has no empty cells and 10,514 records carrying the published code for
Unknown / Unidentified, so it is 54.9% recorded rather than complete. Collection method is the same shape: no empty cells, 15,081 recorded asUnknown, 35.4% recorded. Counting only nulls would call both fields complete. - 12,469 records carry an all-zeros local incident number, counted apart from the 9,910
recorded numbers. FRAP publishes no domain for that field, so this reading is an
inference and the page says so. Reading the value as a number instead would report
12,230 records as sharing an incident key rather than 376. Both counts are published;
the evidence is in
docs/MARKERS.md. - Records sharing an identifier, counted as candidates: 8 IRWIN IDs used by more than one record, covering 23 records.
- Surviving records counted per decade against the 10, 50 and 300 acre figures in FRAP's published collection criteria.
Damage inspection coverage (site/dins.html) over CAL FIRE's DINS data, retrieved
2026-08-07:
- 132,522 structure records across 451 incidents.
- Damage is recorded on every record. 54,414 of them (41.1%) say
No Damage, which is an inspection finding rather than a null and rather than a zero. 591 sayInaccessible: identified, could not be reached. - Field completeness reported separately for assessed records and inaccessible ones, so a blank on a structure nobody could get to is not counted as the same fact as a blank on one that was inspected. Both are counted from the damage field, so a record whose damage field recorded nothing is in neither, and that third population is counted and published per field too. There are none in this retrieval, and the page says so rather than leaving a reader to add the two columns up and find the file short.
- Construction attributes are where the three-state split matters most. Eaves is recorded
on 56.6% of records, carries
Unknownon 52,364, and is blank on 5,214. - Per-incident completeness for every incident, because an average across the file describes no incident in particular.
- The distance from a residence to a utility or miscellaneous structure is recorded on
37,783 records (28.5%). CAL FIRE publishes Not Applicable for that field spelled
NAand publishes it for the propane-tank field spelledN/A; the file carries both spellings in the utility field, in eras that do not overlap, and both are counted as the published finding. Seedocs/MARKERS.md.
Every measured cell is counted in one of three states, and the three are never collapsed:
| State | Meaning |
|---|---|
| Recorded value | A value the agency wrote down. Includes a genuine zero, and includes findings of absence such as No Damage, No Eaves or Not Applicable, which are observations. |
| Recorded as unknown | A published code or a reviewed marker meaning the value could not be determined. Somebody recorded that determination failed. |
| Empty cell | Nothing was written. CAL FIRE states for DINS that "Attributes with null values could not be determined." |
A null is not a zero. A structure not inspected is not a structure without damage. Where a share would have no denominator, the output says so in words instead of printing a number.
The build fails closed. A column this project measures going missing raises
SchemaDriftError. A cell holding something that reads like a missing-data marker, in a
field that has not declared that exact marker, raises SentinelDriftError rather than
being guessed at. Every marker currently declared was read in its own field's context and
carries a note saying why: None is a published street-type finding, while None in a
parcel APN is a marker standing in for an absent parcel match.
An ordinary value outside a published domain is deliberately not an error. It is counted
and published as outside_published_domain, because it is a real thing about the file and
crashing on it would hide it.
Twenty-eight of the fifty-four measured fields declare a marker, a code or a finding of absence. Twelve of those are published: the value is in the layer's own coded-value domain, or in FRAP's metadata document, or in CAL FIRE's DINS database dictionary. The other sixteen are inferred: the field is free text, or the value is one the published domain does not carry, and this project read it off the acquired file.
A zero is a judgment call of the same kind, and it is now audited as one. Six numeric
fields hold recorded zeros. In five of them a zero is a measurement: no outbuildings
damaged, no cars on the property. In YEARBUILT it is not, because no structure was
built in year 0, and the 12,148 records holding one are counted as recorded-as-unknown
rather than as a construction year. That moves the field from 77.0% recorded to 67.9%.
Both are counted the same way, and neither is a defect in either dataset. Both publishers
document the domains they constrain and say which fields are free text. What differs is
how much weight a reader should put on the call, so every field carries its basis in
schema.py, in the JSON artifacts as marker_basis, and on the pages beside its marker
list.
docs/MARKERS.md is the audit: per field, the declared values, the evidence, the URL it
can be checked against, the effect on the published figures, and a confidence. Where a
call has a counterfactual worth counting, it is counted rather than described, and
published in the artifact under marker_counterfactuals.
uv sync
npm ci
make verify # lockfile, lint, format, types, tests, SCA, page checks, determinism
make site-offline # build from committed fixtures; runs anywhere, no networkmake verify includes make pages, which builds the pages from the committed fixtures and
checks them four ways: html-validate for HTML conformance and the markup-level
accessibility rules, axe-core in a headless DOM for the WCAG 2.0, 2.1 and 2.2 A and AA
rule sets, the same axe rule sets again in Chromium where no rule is undecidable, and
WCAG 2.2 SC 1.4.10 Reflow at a 320 by 256 viewport. Nothing is served and nothing is
deployed; every checker reads the files off disk, the browser included, as file://
URLs. The same gate runs in CI, which fetches a browser binary and nothing else.
Gated, in CI:
- HTML conformance, heading order, duplicate ids, landmark structure,
lang, and every table header carrying ascopeand every table a caption. Checked twice, byhtml-validateand by parser-based assertions intests/test_pages_html.py. - The WCAG A and AA rule sets that axe-core can decide in a DOM with no layout. A rule
axe runs and cannot decide comes back
incomplete, notviolation, and that is not a pass:tools/a11y.mjsfails on any undecided rule that is not declared in it with a reason and with where the rule's subject is checked instead, and it prints every undecided rule on every run, passing or failing. Three are declared and land on all three pages (color-contrast,landmark-one-main,page-has-heading-one); the latter two are decided from the markup intests/test_pages_html.pyinstead.tests/test_a11y_gate.pyruns the gate against pages that should fail it. - The same rule sets again, in Chromium, where nothing is undecidable. That run declares
no exceptions at all: a violation fails it and so does an undecided rule. It is not a
formality over the headless one. It found the scroll containers holding the wide tables
were not keyboard reachable, a serious WCAG 2.1.1 failure invisible to a DOM-only
engine, because whether a container scrolls depends on layout. Each is now a named
<section>withtabindex="0". - WCAG 2.2 SC 1.4.10 Reflow, at 320 by 256, which is 1280 by 1024 at 400% zoom. The
document must not scroll horizontally and no element may spill past the viewport,
counting both a box that is too wide and a box that fits around content that does not.
Content inside a scroll container is exempt, because that is the conforming pattern
rather than the failure.
tests/test_a11y_browser_gate.pyruns both browser specs against pages that should fail them, including an empty directory and a missing one. - Contrast. Both palettes are data in
render.py, so every foreground and background the stylesheet puts together is measured against the WCAG thresholds, in both themes, arithmetically. This is the one criterion axe cannot check headlessly, because jsdom paints nothing. - Every number in a table cell or a tile traces to the JSON artifact, and every number in
prose is either one of those, part of a quote from the publisher, or on a reviewed list
in
tests/test_pages_html.pywith a reason.
Not checked, and needing human eyes:
- Visual layout. A browser now lays the pages out, and asserts things about the result, but nobody looks at it. Column widths, the tile grid at its wrapping points and whether the sticky table headers behave are all unverified. What is no longer on this list is horizontal overflow, which is measured rather than eyeballed.
- Print. No print stylesheet is defined and no print output has been looked at.
- Focus appearance in practice. A focus ring is defined and the skip link is present and points at the main landmark, but SC 2.4.11 is about how the indicator looks against what is behind it, which needs a person looking. There are now more focusable things on these pages than there were, since every scroll container is one, so this matters more than it did.
- A screen reader. Conformant markup is not the same as a good listening experience. Nothing here substitutes for reading a page with one. The scroll containers are named from their table captions, and how that sounds in practice is unverified.
To build from CAL FIRE's real files, acquire them first (see PROVENANCE.md):
uv run python -m perimeter.acquire --out data/raw
make siteOutput is deterministic: the same inputs produce byte-identical JSON and HTML. There is no
wall clock anywhere in the artifacts, retrieval dates come from the reviewed constants in
src/perimeter/sources.py, and every published share is computed with integer arithmetic.
make determinism is the check behind that sentence: two builds into two directories,
compared by tools/determinism.sh, which refuses an empty or missing tree rather than
calling it a match. tests/test_determinism_gate.py runs that script against trees that
should fail it, so the gate is known to be able to fail.
data/raw/ is gitignored and CI never touches the network. A build from fixtures stamps
is_fixture: true and publishes null for every acquisition fact, so fixture output
cannot pass itself off as a measurement of the real files.
Coverage measurement only. This project does not model fire risk, does not track incidents, does not compute damage or loss totals, and does not republish any address, parcel number or assessed value. Those lanes are well served by others. What is measured here is how much of each published field is actually filled in, and what the blanks mean.
| Path | |
|---|---|
src/perimeter/cells.py |
The three states, and the rule that a non-present cell will not hand over a value |
src/perimeter/schema.py |
The reviewed field registry: every domain, marker and finding-of-absence, per field |
src/perimeter/records.py |
Classify every measured cell at the edge, or refuse the file |
src/perimeter/perimeters.py |
FRAP year cohorts, duplicate signals, acreage against the published criteria |
src/perimeter/dins.py |
DINS incident grouping and the assessed/inaccessible split |
src/perimeter/coverage.py |
The two reports |
src/perimeter/artifacts.py |
Deterministic JSON |
src/perimeter/render.py |
The static pages |
src/perimeter/acquire.py |
The only code that touches the network. Run by hand, never in CI |
tools/a11y.mjs |
axe-core over the built pages in a headless DOM; an undecided rule is not a pass |
tools/determinism.sh |
Compare two build trees; refuse an empty or missing one |
site/ |
The built pages and their JSON artifacts. Tracked in git, and published by .github/workflows/pages.yml |
PROVENANCE.md |
Per-source detail, quoted caveats, and what is excluded |
docs/MARKERS.md |
The marker audit: every judgment call, its evidence, and what it costs |
This repository is held to the portfolio's shared engineering standards, pinned in
.standards-version to v2.0.0. Every row states what is true on 2026-08-27, not what is
intended. "Applies (not met)" is a recorded gap; a blank state is a defect and
tests/test_standards_conformance.py fails on one.
Which standards bind this repository is the registry's decision, and the registry has
made it. The applicability manifest carries an entry for perimeter, added
2026-08-15. Read 2026-08-27, it records the archetype as civic-data-tool, publication
as cleared and public since 2026-08-08, the tier as B+C (the published Pages site is
the B surface, the local Python and Node build is the C one), and the flags as
html: true, hosted: true, dockerfile: false, llm: false, bilingual: false. Of
the fifteen standards it marks fourteen as applying and AI Evaluation as N/A, for
"deterministic coverage counting over published CAL FIRE/FRAP datasets; no LLM/model
component".
This table used to open by saying the manifest had no entry and that the scoping below
was this table's reading rather than the registry's. That was true when it was written
and stopped being true the same week, and nothing here noticed, because the only test
reading the sentence was one asserting it was still present. The scoping now matches the
registry on all fifteen rows. One row moved to get there: Observability was recorded as
Tier C, and the registry says B+C.
The registry lives in another repository, so this is a transcription with a date, the
same way docs/MARKERS.md transcribes CAL FIRE's documents rather than reading them at
build time. Nothing in CI can check it against the source.
| Standard | State |
|---|---|
| Code Quality | Applies: uv, ruff, mypy --strict, pytest with branch coverage at 100% over src/ with nothing omitted, against a 90% floor. make lock-check runs uv lock --check, because CQ-09's prescribed uv sync --frozen exits 0 on lockfile drift (measured 2026-08-15) |
| Security & Supply-Chain | Applies: semgrep, gitleaks, pip-audit, npm audit, CodeQL over actions/python/javascript, every action SHA-pinned, permissions: contents: read at the top of every workflow, persist-credentials: false on every checkout. Not met: no SBOM, no OpenSSF Scorecard workflow, no osv-scanner alongside pip-audit (SEC-11, SEC-13), no scheduled trufflehog run (SEC-19), and Dependabot alerts are disabled on the repository, so SEC-15 has nothing to read |
| CI/CD | Applies (not met). main has no ruleset and no branch protection, so the gates report and block nothing. The protect-main profile is committed at .github/rulesets/main.json and deliberately not applied; applying it is a live repository setting |
| Observability | Applies (Tier B+C), which is what the manifest records and what OBS section 0 asks a repo with two surfaces to state. Tier C is the library and CLI writing to stdout: OTel is out of scope with no network surface, which section 10 allows a Tier C surface to declare. Tier B is the published Pages site. Not met, and the B half is the larger gap: section 8 asks a Tier B frontend for a Core Web Vitals RUM beacon, which means shipping a script that reports readers of a civic-data page back to somebody, and these pages ship no script and this project takes no telemetry; that refusal is a position, not an oversight, and the standard has no N/A for it. Also not met: the Lighthouse-CI lab gate on LCP, INP and CLS, which does not need a beacon and is the half that could be built; no docs/ROADMAP.md carrying the tier declaration section 0 requires; and no operations runbook |
| Accessibility | Applies: html-validate and axe-core over the built pages in CI, in jsdom and again in Chromium, plus SC 1.4.10 Reflow at 320 by 256, contrast measured arithmetically over both palettes, and the same structural floor asserted from Python. An axe rule that comes back undecided fails the jsdom gate unless it is declared with a reason and with where it is checked instead; the browser run declares nothing and fails on any undecided rule. Nothing is suppressed anywhere, so there is no waivers.yml and A11Y-06 and A11Y-09 are met rather than waived. Not met: no ACR, and the manual checks README names under "What still needs a person" are unverified. Measured 2026-08-27: the browser run found scrollable-region-focusable, serious, on both measurement pages, which the jsdom run cannot see; the scroll containers are now named sections with tabindex="0" |
| Internationalization | Applies (not met). Civic data presented to the public is in scope per I18N section 1, and these pages are English only with no catalog and no docs/I18N.md declaration |
| AI Evaluation | N/A (no model, no LLM, no generated text anywhere in the pipeline or the pages) |
| Quality & Metrics | Applies: fail-closed gates throughout, covering schema drift, sentinel drift, the coverage floor, and page checks that fail on a number no pipeline produced. Not met: no Definition of Done and no metrics ledger |
| Documentation | Applies: README, PROVENANCE.md, docs/MARKERS.md, CONTRIBUTING.md, SECURITY.md, CHANGELOG.md, CITATION.cff, a docs/adr/ log, this table, and a .standards-version pin that tests/test_standards_conformance.py reads |
| Release & Versioning | Applies (not met). Version 0.1.0 in pyproject.toml and CITATION.cff, no tag cut, no signed tag, no release workflow, no published artifact |
| Responsible-Tech Framework | Applies: unofficial framing on every served page, no claim about any agency's infrastructure or security posture, no address, parcel number or assessed value republished, and an acquisition path that stops rather than routing around an access control. Not met: no dated ethics, transparency or residual-risk artifacts |
| Performance | Applies (not met). Three static pages, no script shipped, no web font; that is a good starting position and it is not a measurement. No budget recorded and no Lighthouse run |
| Incident Response | Applies: SECURITY.md routes reports to GitHub private vulnerability reporting with a 72-hour acknowledgment SLA. Not met: no severity convention, no secret-leak runbook, no committed-postmortem requirement |
| Data Governance | Applies (L1): openly licensed public civic data, republished only as counts, handled defensively above the tier because the DINS file carries site addresses and parcel numbers (data/raw/ gitignored, fixtures hand-written rather than sampled, no identifying field republished). A data card per source under docs/data/ carries the seven rows section 1 requires, including the refresh cadence, the staleness SLA and the tier, which were the gap this row recorded (DG-01). DG-04 is a gate: tests/test_data_cards.py fails the build when a retrieval is older than the SLA its card states, and the clock lives there rather than in the artifacts, which have none. DG-03 is require_columns and FieldSpec.classify, which refuse a file per row rather than validating the first one. Not met: DG-02 is satisfied at the file level and not the record level, since no record is republished and every count's artifact names the source, version, retrieval date and hash; and DG-19 has nothing to link, because there has been one retrieval |
| AI Development Measurement | Applies (not met). No baseline and no outcome metrics recorded for this repository's development stream |
Apache-2.0. Source data is published by CAL FIRE under a Creative Commons Attribution licence and is reproduced here only as counts.