lgtm
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 18m12s
CI / Check (push) Failing after 23m38s

This commit is contained in:
Andras Schmelczer 2026-06-22 22:12:27 +01:00
parent f7e0814a38
commit fd2860070a
55 changed files with 4084 additions and 186 deletions

View file

@ -26,7 +26,9 @@ use rustc_hash::FxHashMap;
use serde::Serialize;
use crate::consts::NAN_U16;
use crate::data::area_crime_averages::{AreaCrimeAverages, AVG_YR_SUFFIX};
use crate::data::spill::SpillVec;
use crate::utils::{postcode_outcode, postcode_sector};
#[derive(Serialize, Clone)]
pub struct RenovationEvent {
@ -224,6 +226,109 @@ impl PropertyData {
num_numeric: self.num_numeric,
}
}
/// Precompute mean headline crime rates nationally and per outcode / postcode
/// sector.
///
/// Crime values are identical for every property in a postcode (the pipeline
/// merges them on the postcode key), so each postcode is sampled once from
/// its first row and property-weighted by its row count. All three scopes use
/// the same exact property-weighted estimator over the same row universe as
/// the per-selection mean, so the four numbers shown in a crime row (this
/// selection / sector / outcode / nation) are directly comparable — without
/// the upward bias of the histogram-bin national average.
pub fn compute_area_crime_averages(&self) -> AreaCrimeAverages {
// Crime headline columns are exactly the " (avg/yr)" features.
let crime_indices: Vec<usize> = self
.feature_names
.iter()
.enumerate()
.filter(|(_, name)| name.ends_with(AVG_YR_SUFFIX))
.map(|(idx, _)| idx)
.collect();
if crime_indices.is_empty() {
return AreaCrimeAverages::empty();
}
let crime_types: Vec<String> = crime_indices
.iter()
.map(|&idx| {
self.feature_names[idx]
.strip_suffix(AVG_YR_SUFFIX)
.unwrap_or(&self.feature_names[idx])
.to_string()
})
.collect();
let n = crime_indices.len();
// (weighted value sum, weight) accumulators per crime type.
let mut nat_sums = vec![0.0f64; n];
let mut nat_weights = vec![0u64; n];
let mut out_acc: FxHashMap<String, (Vec<f64>, Vec<u64>)> = FxHashMap::default();
let mut sec_acc: FxHashMap<String, (Vec<f64>, Vec<u64>)> = FxHashMap::default();
for (key, rows) in &self.postcode_row_index {
let Some(&first) = rows.first() else { continue };
let count = rows.len() as u64;
let postcode = self.postcode_interner.resolve(key);
let outcode = postcode_outcode(postcode);
let sector = postcode_sector(postcode);
for (j, &fi) in crime_indices.iter().enumerate() {
// A NaN value is "no crime data for this postcode" — skip it so
// it dilutes neither the sum nor the weight (a genuine gap, not
// a zero), exactly as the global histogram excludes it.
let value = self.get_feature(first as usize, fi);
if !value.is_finite() {
continue;
}
let weighted = value as f64 * count as f64;
// National counts every postcode (the population the global mean
// is built over); outcode/sector only when the postcode parses.
nat_sums[j] += weighted;
nat_weights[j] += count;
if let Some(outcode) = outcode {
let acc = out_acc
.entry(outcode.to_string())
.or_insert_with(|| (vec![0.0; n], vec![0; n]));
acc.0[j] += weighted;
acc.1[j] += count;
}
if let Some(sector) = sector {
let acc = sec_acc
.entry(sector.to_string())
.or_insert_with(|| (vec![0.0; n], vec![0; n]));
acc.0[j] += weighted;
acc.1[j] += count;
}
}
}
let means_of = |sums: &[f64], weights: &[u64]| -> Vec<f32> {
sums.iter()
.zip(weights.iter())
.map(|(&sum, &weight)| {
if weight == 0 {
f32::NAN
} else {
(sum / weight as f64) as f32
}
})
.collect()
};
let finalize =
|acc: FxHashMap<String, (Vec<f64>, Vec<u64>)>| -> FxHashMap<String, Vec<f32>> {
acc.into_iter()
.map(|(area, (sums, weights))| (area, means_of(&sums, &weights)))
.collect()
};
AreaCrimeAverages {
crime_types,
national: means_of(&nat_sums, &nat_weights),
by_outcode: finalize(out_acc),
by_sector: finalize(sec_acc),
}
}
}
#[cfg(test)]