Compliance Dashboards for Spatial Catalogs

A catalog with ten thousand layers can be individually compliant dataset by dataset and still be impossible to govern, because no one can answer the questions leadership and auditors actually ask: what fraction of the catalog carries a valid CRS, how many datasets have a resolvable license, is schema conformance trending up or down. A compliance dashboard turns thousands of per-dataset check results into a handful of catalog-wide metrics with history behind them. This guide, part of Spatial Data Audit Reporting & Compliance Governance, builds that pipeline: run per-dataset checks, persist the outcomes in SQLite, aggregate them with pandas, and render a static HTML dashboard that regenerates deterministically on every scan.

From atomic checks to a dashboard nobody has to interpretNamed checks write per-dataset results, results are aggregated into catalogue metrics, and a static dashboard renders them with a threshold gate.Named checksone concern eachpersistResults tabledataset, check, verdict, runaggregateCatalogue metricscoverage, pass rate, trendrenderStatic dashboardplus a threshold gate
Named atomic checks at the left end are what make every number at the right end explainable.

Prerequisites

  1. Python 3.11+ in an isolated virtual environment (python -m venv .venv)
  2. pandas>=2.1 — aggregation and pivoting of per-dataset results
  3. jinja2>=3.1 — templating the static HTML dashboard
  4. geopandas>=0.14 and pyproj>=3.6 — the CRS/license/schema checks that feed the dashboard
  5. jsonschema>=4.21 — schema-conformance checks against a catalog profile
  6. Python’s standard-library sqlite3 (no install needed) for the results store
  7. Environment variable CATALOG_DB pointing at the SQLite results file, e.g. ./compliance.db
  8. A dataset inventory (CSV, catalog export, or STAC) listing every layer’s id, path, and collection

Install the stack:

pip install "pandas>=2.1" "jinja2>=3.1" "geopandas>=0.14" \
    "pyproj>=3.6" "jsonschema>=4.21"


Concept & Spec Reference

A compliance dashboard is a deterministic projection of a per-dataset results table. The unit of measurement is the check: a named boolean predicate applied to one dataset, such as “has a valid, resolvable CRS.” Each check produces one row; the dashboard aggregates rows into pass rates. Keeping checks atomic and independently named is what lets you pivot the same data by obligation, by collection, and over time. The individual predicates reuse validators you have already built — CRS resolution via pyproj, license presence from your metadata schema validation and linting rules, and geometry integrity from automated topology and geometry validation.

Metric definitions

Metric Predicate Pass condition
valid_crs CRS resolves via pyproj.CRS.from_user_input Authority code recognized, not undefined
has_license dct:license present and non-empty Resolves to a known SPDX or CC URI
schema_pass Attributes validate against the catalog JSON Schema Zero schema violations
has_lineage A PROV provenance graph exists for the dataset ≥1 prov:wasGeneratedBy triple
bbox_present Spatial extent declared Non-null, west < east, south < north
not_stale dateModified within policy window Updated within the last N months
Three metrics that are routinely confusedCoverage, pass rate and compliance rate defined against their denominators, with the misreading each invites.DenominatorMisread asCoveragedatasets in the catalogue'we are 60% compliant'Pass ratedatasets actually checked'the catalogue is healthy'Compliance ratedatasets in the cataloguethe only one that means itException ratefailing checks'no failures'
Same numerator, three denominators. Reporting one and calling it another is the most common dashboard error.

Results table schema

Column Type Meaning
dataset_id TEXT Stable catalog identifier
collection TEXT Grouping (team, theme, or STAC collection)
check_name TEXT One of the metric names above
passed INTEGER 1 for pass, 0 for fail
detail TEXT Human-readable reason on failure
run_at TEXT ISO 8601 timestamp of the scan

Implementation Walkthrough

Step 1 — Define atomic, named checks

Each check is a small function returning (passed, detail). Keeping them independent means a new obligation is a new function, not a rewrite. The rationale: atomic checks are individually testable and their results pivot cleanly.

import pyproj

def check_valid_crs(record: dict) -> tuple[bool, str]:
    """Pass if the declared CRS resolves to a known authority code."""
    crs_value = record.get("crs")
    if not crs_value:
        return False, "no CRS declared"
    try:
        crs = pyproj.CRS.from_user_input(crs_value)
    except pyproj.exceptions.CRSError as exc:
        return False, f"unresolvable CRS: {exc}"
    return bool(crs.to_authority()), (
        "ok" if crs.to_authority() else "CRS has no authority code"
    )

def check_has_license(record: dict) -> tuple[bool, str]:
    """Pass if a non-empty license identifier is present."""
    lic = (record.get("license") or "").strip()
    return (bool(lic), "ok" if lic else "missing license")

Step 2 — Persist per-dataset results in SQLite

Write outcomes to a durable table so the dashboard becomes a historical record. The rationale: recomputation gives you a snapshot; persistence gives you a trend and an auditable point-in-time answer.

import sqlite3
import datetime as dt

SCHEMA = """
CREATE TABLE IF NOT EXISTS check_results (
    dataset_id TEXT NOT NULL,
    collection TEXT,
    check_name TEXT NOT NULL,
    passed     INTEGER NOT NULL,
    detail     TEXT,
    run_at     TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_results_run ON check_results(run_at);
"""

CHECKS = {
    "valid_crs": check_valid_crs,
    "has_license": check_has_license,
}

def run_and_store(db_path: str, inventory: list[dict]) -> str:
    """Run all checks over the inventory and persist results. Returns run_at."""
    run_at = dt.datetime.now(dt.timezone.utc).isoformat()
    conn = sqlite3.connect(db_path)
    try:
        conn.executescript(SCHEMA)
        rows = []
        for record in inventory:
            for name, fn in CHECKS.items():
                passed, detail = fn(record)
                rows.append((
                    record["dataset_id"], record.get("collection"),
                    name, int(passed), detail, run_at,
                ))
        conn.executemany(
            "INSERT INTO check_results "
            "(dataset_id, collection, check_name, passed, detail, run_at) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            rows,
        )
        conn.commit()
    finally:
        conn.close()
    return run_at

Step 3 — Aggregate to catalog metrics with pandas

Load the latest run into a DataFrame and pivot. The rationale: a single grouped aggregation yields both the by-check and by-collection views the dashboard needs.

import pandas as pd
import sqlite3

def aggregate_latest(db_path: str) -> dict:
    """Return pass rates by check and by collection for the newest run."""
    conn = sqlite3.connect(db_path)
    try:
        latest = conn.execute(
            "SELECT MAX(run_at) FROM check_results"
        ).fetchone()[0]
        df = pd.read_sql_query(
            "SELECT * FROM check_results WHERE run_at = ?",
            conn, params=(latest,),
        )
    finally:
        conn.close()

    by_check = (
        df.groupby("check_name")["passed"]
        .mean().mul(100).round(1)
        .rename("pass_rate_pct").reset_index()
    )
    by_collection = (
        df.groupby("collection")["passed"]
        .mean().mul(100).round(1)
        .rename("pass_rate_pct").reset_index()
    )
    return {
        "run_at": latest,
        "datasets": int(df["dataset_id"].nunique()),
        "by_check": by_check.to_dict("records"),
        "by_collection": by_collection.to_dict("records"),
    }

Step 4 — Render a static dashboard

Template the metrics into self-contained HTML. The rationale: a static file is archivable, diffable, and needs no server, so it can be attached to an audit package unchanged.

from jinja2 import Environment, BaseLoader

DASHBOARD_TMPL = """<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>Catalog Compliance — {{ run_at }}</title></head>
<body>
<h1>Catalog Compliance Dashboard</h1>
<p>{{ datasets }} datasets evaluated at {{ run_at }}.</p>
<h2>By check</h2>
<table><tr><th>Check</th><th>Pass rate</th></tr>
{% for row in by_check %}<tr><td>{{ row.check_name }}</td>
<td>{{ row.pass_rate_pct }}%</td></tr>{% endfor %}
</table>
<h2>By collection</h2>
<table><tr><th>Collection</th><th>Pass rate</th></tr>
{% for row in by_collection %}<tr><td>{{ row.collection }}</td>
<td>{{ row.pass_rate_pct }}%</td></tr>{% endfor %}
</table>
</body></html>
"""

def render_dashboard(metrics: dict, out_path: str) -> None:
    """Write a self-contained HTML dashboard from aggregated metrics."""
    env = Environment(loader=BaseLoader(), autoescape=True)
    html = env.from_string(DASHBOARD_TMPL).render(**metrics)
    with open(out_path, "w", encoding="utf-8") as fh:
        fh.write(html)

Validation & CI Integration

Guard against regressions with a threshold gate

A dashboard is descriptive; a gate is prescriptive. Fail CI when any catalog metric drops below its policy floor so compliance cannot silently erode between releases.

What a threshold gate should compare againstThree comparisons — absolute floor, previous run, and trend — decide whether a dashboard gate should fail.Below the absolute floor?a level agreed in advanceFailthe catalogue is out of policyyesnoWorse than the previous run?Fail on regressionname the datasets that changedyesnoDeclining across three runs?Warn on trendno single run is at faultyesnoPassstable or improving
Comparing against the previous run catches the regression an absolute floor lets through.
import sys

THRESHOLDS = {"valid_crs": 95.0, "has_license": 90.0, "schema_pass": 98.0}

def enforce_thresholds(metrics: dict) -> int:
    """Exit non-zero if any check's pass rate is below its threshold."""
    failures = []
    rates = {r["check_name"]: r["pass_rate_pct"] for r in metrics["by_check"]}
    for check, floor in THRESHOLDS.items():
        actual = rates.get(check, 0.0)
        if actual < floor:
            failures.append(f"{check}: {actual}% < {floor}% floor")
    for msg in failures:
        print(f"THRESHOLD BREACH: {msg}")
    return 1 if failures else 0

GitHub Actions workflow

# .github/workflows/compliance-dashboard.yml
name: Catalog compliance dashboard
on:
  schedule:
    - cron: '0 6 * * 1'
  workflow_dispatch:
jobs:
  dashboard:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install "pandas>=2.1" "jinja2>=3.1" "pyproj>=3.6"
      - run: python scripts/build_dashboard.py --db compliance.db --out public/compliance.html
      - uses: actions/upload-artifact@v4
        with:
          name: compliance-dashboard
          path: public/compliance.html

Pytest coverage

import pandas as pd
from scripts.build_dashboard import aggregate_latest, enforce_thresholds

def test_thresholds_pass_when_rates_high():
    metrics = {"by_check": [
        {"check_name": "valid_crs", "pass_rate_pct": 99.0},
        {"check_name": "has_license", "pass_rate_pct": 92.0},
        {"check_name": "schema_pass", "pass_rate_pct": 99.5},
    ]}
    assert enforce_thresholds(metrics) == 0

def test_thresholds_fail_on_low_crs():
    metrics = {"by_check": [{"check_name": "valid_crs", "pass_rate_pct": 80.0}]}
    assert enforce_thresholds(metrics) == 1

Derivative & Lineage Management

A dashboard metric is only as honest as the check behind it, and checks drift as datasets are transformed. When a pipeline reprojects, clips, or joins a layer, re-run its checks and write fresh rows rather than editing old ones — the history of a dataset’s compliance state is itself audit evidence. Tie the has_lineage metric to the spatial data lineage and provenance tracking graphs so a derived layer with no provenance is visibly counted as non-compliant. When a metric improves after a remediation sprint, the timestamped rows in SQLite are the proof; export the underlying results into a formal automated compliance report when an auditor needs the detail behind a headline number.

Pitfalls & Resolution

Pitfall Root Cause Resolution Strategy
Dashboard shows 100% but datasets are non-compliant Checks silently error and are counted as passing Have every check return an explicit (passed, detail); treat exceptions as failures, never as skips
Pass rates jump between runs for no reason Inventory size changed; denominator shifted Record dataset count per run and display it; compare rates only across runs with comparable coverage
History lost after each scan Results table overwritten instead of appended Insert new rows keyed by run_at; never DELETE prior runs
CRS check passes on EPSG:0 or empty string Truthiness check without authority resolution Resolve with pyproj.CRS.from_user_input and require a non-null to_authority()
Collection metrics skewed by one huge collection Simple mean weights every dataset equally within a lopsided catalog Report both unweighted per-collection rates and the dataset counts so skew is visible
Dashboard HTML drifts from the data File hand-edited after generation Regenerate from SQLite on every scan; never edit the rendered HTML
Stale-data metric always fails after year boundary Naive date parsing ignores timezone Parse dateModified as timezone-aware and compare against a timezone-aware now()
Threshold gate blocks on a check that was never run Missing check treated as absent, not failing Default a missing check’s rate to 0.0 in the gate so gaps fail loudly rather than pass silently

Designing for the Reader Who Will Act

A compliance dashboard has exactly one job: cause the right dataset to be fixed by the right person. Most dashboards instead optimise for summarising, which is a different job, and the symptom is that the numbers are watched for months while nothing changes.

Three design choices separate the two.

Show the work list, not the score. A compliance rate of 74% is a fact about the catalogue that nobody can act on. “Eleven datasets owned by Planning are missing a licence field” is a task. The score belongs somewhere on the page, but the primary content should be the shortest list that, if cleared, would move the number most — and it should be sorted so the top of the list is where to start.

Attribute every failure to an owner. A dataset with no owner will not be fixed, and a dashboard that lists unowned failures is generating work for whoever reads it, which is usually the person who built the dashboard. Where ownership is unrecorded, that absence is itself the first finding, and it belongs at the top of the list rather than in a footnote.

Show movement, not just level. A team that has fixed nine of twenty problems this month is doing well and a static percentage will not say so. A dashboard that shows only current state produces the same emotional response every week regardless of effort, and effort that produces no visible response stops.

Two anti-patterns are worth naming. The first is the traffic light with no threshold — a red indicator whose threshold nobody agreed, which generates argument rather than action. The second is the dashboard nobody is responsible for reading: a page that exists, updates nightly, and has no named audience and no moment in anyone’s week when it is opened. Both are common, and both are cheaper to prevent than to fix, because the fix requires admitting the thing was never used.

The test for whether a dashboard is working is not whether the numbers improve. It is whether anyone has opened it and then changed a dataset. If that has not happened in a month, the problem is the dashboard rather than the catalogue.

Why the Dashboard Should Be Static

There is a persistent pull towards making a compliance dashboard a live application — a service that queries the results store on request. For this particular job, a file generated on a schedule is almost always the better answer.

A static dashboard has no runtime to keep alive, no credentials to the results store sitting in a web tier, and no query load on a database that is also serving the pipeline. It can be archived: the dashboard as it stood at the end of the reporting period is a file, retained with the report, and openable in five years. It can be emailed to someone who does not have access to the internal network, which is frequently what a reviewer needs.

The cost is freshness, and freshness is worth less here than it seems. Compliance state changes when datasets change, which is daily at most for the catalogues this applies to. A page regenerated after each pipeline run is as current as the underlying data, and it never shows a spinner.

Retention of the Dashboard Itself

Because the dashboard is the artifact a reviewer is most likely to be shown, it should be retained on the same schedule as the reports it summarises rather than being treated as a transient view. Keeping one generated copy per reporting period — not per run — gives a series that shows the catalogue improving or not, at a granularity anyone can read, without accumulating a file per nightly build.

The series is also the cheapest possible answer to the question of whether the governance programme is working. Six dashboards, one per quarter, side by side, make the trend obvious in a way that no single current-state page can.