"""Precompute per-outcode / per-postcode-sector / national mean headline crime counts for the right pane's area comparison. The right pane shows each crime metric next to its area context: the mean average-annual count (``"X (/yr, 7y)"``) across the selection's postcode sector (e.g. ``"E14 2"``), its outcode (e.g. ``"E14"``), and the nation. Crime is constant within a postcode (the merge keys it on the postcode), so each postcode contributes its single value weighted by how many properties sit in it, keeping every scope on the same property-weighted basis as the per-selection mean, so the four numbers (this selection / sector / outcode / nation) are directly comparable. The national figure here is an EXACT property-weighted mean, which is why it overrides the upward-biased histogram-bin national average for crime. This used to be recomputed inside the server on every boot from the loaded property matrix. It is a pure function of the two merge outputs, so it belongs in the data build; the server now just loads the parquet this writes. Reading the crime values from ``postcode.parquet`` and the per-postcode property weights from ``properties.parquet`` mirrors exactly the two inputs the server loads, so the result matches what the server used to compute (minus its u16 quantization loss). Output schema, one row per area: scope : ``"national"`` | ``"outcode"`` | ``"sector"`` area : the outcode (``"E14"``) / sector (``"E14 2"``); ``""`` for the single national row `` (/yr, 7y|2y)`` : Float32 property-weighted mean crime count per year (null = the scope has no data for that type) """ import argparse from pathlib import Path import polars as pl # Filterable crime columns are the average-annual incident counts and carry this # marker in postcode.parquet (e.g. "Burglary (/yr, 7y)"). We average those. The # full column name is kept; the server discovers and keys area averages by the # same names. COUNT_MARKER = " (/yr, " # `scope` discriminator values. The server's loader dispatches on these. SCOPE_NATIONAL = "national" SCOPE_OUTCODE = "outcode" SCOPE_SECTOR = "sector" # Area label on the national row. It spans the whole country, so it has no code. NATIONAL_AREA = "" # Both merge outputs key on the canonical NSPL `pcds` postcode (spaced, e.g. # "E14 2DG"). POSTCODE_COLUMN = "Postcode" # Internal weight / split columns dropped before write. _WEIGHT_COLUMN = "_weight" _OUTCODE_COLUMN = "_outcode" _SECTOR_COLUMN = "_sector" def _crime_columns(columns: list[str]) -> list[str]: crime_cols = [name for name in columns if COUNT_MARKER in name] if not crime_cols: raise ValueError( f"postcode parquet has no '*{COUNT_MARKER}*' crime count columns to average" ) return crime_cols def _weighted_mean(column: str) -> pl.Expr: """Property-weighted mean of ``column`` that excludes nulls from BOTH the value sum and the weight. A null crime value is a genuine gap (the postcode's police force published no usable data), not zero crime, so it must dilute neither the numerator nor the denominator, exactly as the server's former estimator skipped NaN values. Yields null when no postcode in the group has data for this type. """ weight = pl.col(_WEIGHT_COLUMN) numerator = (pl.col(column) * weight).sum() denominator = weight.filter(pl.col(column).is_not_null()).sum() return ( pl.when(denominator > 0) .then(numerator / denominator) .otherwise(None) .cast(pl.Float32) .alias(column) ) def compute_area_crime_averages( postcodes: pl.LazyFrame, properties: pl.LazyFrame ) -> pl.DataFrame: """Build the national / per-outcode / per-sector crime-average table. ``postcodes`` is the merge's postcode output (one row per active postcode, carrying the ``"* (/yr, *)"`` crime count columns); ``properties`` is the merge's per-property output, used only to weight each postcode by its property count. """ crime_cols = _crime_columns(postcodes.collect_schema().names()) # Property weight per postcode = how many property rows the server indexes # under it. The inner join keeps only postcodes that actually carry # properties, matching the server's per-postcode row index (a postcode with # no properties never contributed to any average). weights = properties.group_by(POSTCODE_COLUMN).agg(pl.len().alias(_WEIGHT_COLUMN)) # Outcode / sector of the spaced `pcds` postcode, matching the server's # postcode_outcode / postcode_sector (split on the single space; sector = # outcode + space + first inward character). Null where the form has no # inward code, so such rows drop out of the per-area groups. parts = pl.col(POSTCODE_COLUMN).str.splitn(" ", 2).struct outward = parts.field("field_0") inward = parts.field("field_1") base = ( postcodes.select(POSTCODE_COLUMN, *crime_cols) .join(weights, on=POSTCODE_COLUMN, how="inner") .with_columns( pl.when(inward.is_not_null()) .then(outward) .otherwise(None) .alias(_OUTCODE_COLUMN), pl.when(inward.str.len_chars() >= 1) .then(outward + pl.lit(" ") + inward.str.slice(0, 1)) .otherwise(None) .alias(_SECTOR_COLUMN), ) ) mean_exprs = [_weighted_mean(column) for column in crime_cols] national = base.select( pl.lit(SCOPE_NATIONAL).alias("scope"), pl.lit(NATIONAL_AREA).alias("area"), *mean_exprs, ) by_outcode = ( base.drop_nulls(_OUTCODE_COLUMN) .group_by(_OUTCODE_COLUMN) .agg(mean_exprs) .select( pl.lit(SCOPE_OUTCODE).alias("scope"), pl.col(_OUTCODE_COLUMN).alias("area"), *crime_cols, ) ) by_sector = ( base.drop_nulls(_SECTOR_COLUMN) .group_by(_SECTOR_COLUMN) .agg(mean_exprs) .select( pl.lit(SCOPE_SECTOR).alias("scope"), pl.col(_SECTOR_COLUMN).alias("area"), *crime_cols, ) ) result = pl.concat([national, by_outcode, by_sector], how="vertical").collect( engine="streaming" ) # Drop per-area rows where every crime type is null: the server only created # a map entry once a scope had at least one finite value, so an all-null # outcode/sector reported no code at all. The national row is always kept (it # always has data, and is emitted even for areas absent from both maps). has_any = pl.any_horizontal(pl.col(column).is_not_null() for column in crime_cols) return result.filter((pl.col("scope") == SCOPE_NATIONAL) | has_any) def main() -> None: parser = argparse.ArgumentParser( description=( "Precompute national / per-outcode / per-sector mean headline crime " "counts from the merge outputs" ) ) parser.add_argument( "--postcodes", type=Path, required=True, help="postcode.parquet (area features, incl. the '* (/yr, *)' crime columns)", ) parser.add_argument( "--properties", type=Path, required=True, help="properties.parquet (per-property rows; supplies postcode property weights)", ) parser.add_argument( "--output", type=Path, required=True, help="Output area_crime_averages.parquet path", ) args = parser.parse_args() result = compute_area_crime_averages( pl.scan_parquet(args.postcodes), pl.scan_parquet(args.properties) ) outcodes = result.filter(pl.col("scope") == SCOPE_OUTCODE).height sectors = result.filter(pl.col("scope") == SCOPE_SECTOR).height print( f"Area crime averages: {result.height} rows " f"({outcodes} outcodes, {sectors} sectors, " f"{len(_crime_columns(result.columns))} crime types)" ) args.output.parent.mkdir(parents=True, exist_ok=True) result.write_parquet(args.output, compression="zstd") print(f"Saved to {args.output}") if __name__ == "__main__": main()