use std::collections::{HashMap, HashSet}; use metrics::counter; use rustc_hash::FxHashMap; use tracing::error; use crate::consts::{AREA_PRICE_HISTORY_POINTS_LIMIT, PRICE_HISTORY_POINTS_LIMIT}; use crate::data::area_crime_averages::AreaCrimeAverages; use crate::data::crime_by_year::CrimeByYearData; use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData}; use crate::utils::{postcode_outcode, postcode_sector}; use super::hexagon_stats::{ CrimeAreaAverage, CrimeYearPoint, CrimeYearStats, EnumFeatureStats, HistogramStats, NumericFeatureStats, PricePoint, }; /// Feature indexes needed to build price-history points, resolved once. struct PriceHistoryIndexes { year: usize, /// "Price per sqm" (last sale price / EPC floor area), when the feature /// exists. Populates each point's optional `price_per_sqm`. price_per_sqm: Option, } impl PriceHistoryIndexes { fn resolve(feature_name_to_index: &FxHashMap) -> Option { Some(Self { year: feature_name_to_index .get("Date of last transaction") .copied()?, price_per_sqm: feature_name_to_index.get("Price per sqm").copied(), }) } } /// Build (year, price, price_per_sqm) points from an iterator of rows, dropping /// rows without a finite year or price, then stride-downsampling to `limit`. fn build_price_points( rows: impl Iterator, data: &PropertyData, idx: &PriceHistoryIndexes, limit: usize, ) -> Vec { let mut points: Vec = rows .filter_map(|row| { let year = data.get_feature(row, idx.year); let price = data.last_known_price_raw(row); if !(year.is_finite() && price.is_finite()) { return None; } let price_per_sqm = idx .price_per_sqm .map(|pi| data.get_feature(row, pi)) .filter(|v| v.is_finite() && *v > 0.0); Some(PricePoint { year, price, price_per_sqm, }) }) .collect(); if points.len() > limit { let step = points.len() as f64 / limit as f64; points = (0..limit) .map(|i| { let src = &points[(i as f64 * step) as usize]; PricePoint { year: src.year, price: src.price, price_per_sqm: src.price_per_sqm, } }) .collect(); } points } /// Extract price history (year, price, price_per_sqm) pairs from matching rows, /// downsampled if needed. pub fn extract_price_history( matching_rows: &[usize], data: &PropertyData, feature_name_to_index: &FxHashMap, ) -> Vec { match PriceHistoryIndexes::resolve(feature_name_to_index) { Some(idx) => build_price_points( matching_rows.iter().copied(), data, &idx, PRICE_HISTORY_POINTS_LIMIT, ), None => Vec::new(), } } /// Price histories for the selection's postcode sector and outward code, taken /// over *every* sale in those areas (filter-independent), as wider-area context /// for the selection's own chart. Returns `(sector_history, outcode_history)`. pub fn extract_area_price_histories( central_postcode: Option<&str>, data: &PropertyData, feature_name_to_index: &FxHashMap, ) -> (Vec, Vec) { let (Some(postcode), Some(idx)) = ( central_postcode, PriceHistoryIndexes::resolve(feature_name_to_index), ) else { return (Vec::new(), Vec::new()); }; let sector = postcode_sector(postcode) .map(|s| { build_price_points( data.rows_for_sector(s).into_iter().map(|r| r as usize), data, &idx, AREA_PRICE_HISTORY_POINTS_LIMIT, ) }) .unwrap_or_default(); let outcode = postcode_outcode(postcode) .map(|o| { build_price_points( data.rows_for_outcode(o).into_iter().map(|r| r as usize), data, &idx, AREA_PRICE_HISTORY_POINTS_LIMIT, ) }) .unwrap_or_default(); (sector, outcode) } /// Per-feature accumulator kind, determined once before the row loop. enum FeatureAccum { /// Numeric: track count, min, max, sum, histogram bins. Numeric { count: usize, min_value: f32, max_value: f32, sum: f64, bins: Vec, p1: f32, p99: f32, middle_width: f32, num_bins: usize, global_min: f32, global_max: f32, }, /// Enum: count occurrences per variant index. Enum { value_counts: Vec }, /// Feature skipped (not in field_set). Skip, } /// Compute per-feature stats (numeric histograms + enum counts) for the given rows. /// Single-pass: iterates rows in the outer loop for cache-friendly row-major access. #[allow(clippy::too_many_arguments)] pub fn compute_feature_stats( matching_rows: &[usize], data: &PropertyData, feature_names: &[String], enum_values: &FxHashMap>, feature_stats_data: &[FeatureStats], fields_specified: bool, field_set: &HashSet, ) -> (Vec, Vec) { let num_features = feature_names.len(); // Pre-allocate accumulators for all features let mut accums: Vec = (0..num_features) .map(|fi| { let feature_name = &feature_names[fi]; if fields_specified && !field_set.contains(feature_name.as_str()) { return FeatureAccum::Skip; } if let Some(ev) = enum_values.get(&fi) { FeatureAccum::Enum { value_counts: vec![0u64; ev.len()], } } else { let global_hist = &feature_stats_data[fi].histogram; let p1 = global_hist.p1; let p99 = global_hist.p99; let num_bins = global_hist.counts.len(); let middle_bins = num_bins.saturating_sub(2); let middle_width = if middle_bins > 0 && p99 > p1 { (p99 - p1) / middle_bins as f32 } else { 0.0 }; FeatureAccum::Numeric { count: 0, min_value: f32::INFINITY, max_value: f32::NEG_INFINITY, sum: 0.0, bins: vec![0u64; num_bins], p1, p99, middle_width, num_bins, global_min: global_hist.min, global_max: global_hist.max, } } }) .collect(); // Single pass: outer loop = rows, inner loop = features (cache-friendly row-major access) for &row in matching_rows { for (fi, accum) in accums.iter_mut().enumerate() { match accum { FeatureAccum::Skip => {} FeatureAccum::Enum { value_counts } => { let value = data.get_feature(row, fi); if value.is_finite() { // Reject negatives, NaN-via-large-cast, and any out-of-range // index. A schema/data mismatch is a critical data-integrity // bug: skip the row, count it, and surface as error so // monitoring catches it. let len = value_counts.len(); let idx_ok = value >= 0.0 && (value as usize) < len; if idx_ok { value_counts[value as usize] += 1; } else { counter!("stats_enum_oob_total").increment(1); error!( feature = feature_names[fi].as_str(), value, max = len, "Enum index out of bounds: data/schema mismatch" ); } } } FeatureAccum::Numeric { count, min_value, max_value, sum, bins, p1, p99, middle_width, num_bins, .. } => { let value = data.get_feature(row, fi); if value.is_finite() { *count += 1; if value < *min_value { *min_value = value; } if value > *max_value { *max_value = value; } *sum += value as f64; let bin = if value < *p1 { 0 } else if value >= *p99 { *num_bins - 1 } else if *middle_width > 0.0 { let middle_bin = ((value - *p1) / *middle_width) as usize; (1 + middle_bin).min(*num_bins - 2) } else { *num_bins / 2 }; bins[bin] += 1; } } } } } // Build response structs from accumulators let mut numeric_features = Vec::new(); let mut enum_features_out = Vec::new(); for (fi, accum) in accums.into_iter().enumerate() { match accum { FeatureAccum::Skip => {} FeatureAccum::Enum { value_counts } => { let ev = &enum_values[&fi]; let counts: HashMap = value_counts .iter() .enumerate() .filter(|(_, &count)| count > 0) .map(|(idx, &count)| (ev[idx].clone(), count)) .collect(); if !counts.is_empty() { enum_features_out.push(EnumFeatureStats { name: feature_names[fi].clone(), counts, }); } } FeatureAccum::Numeric { count, min_value, max_value, sum, bins, p1, p99, global_min, global_max, .. } => { if count > 0 { numeric_features.push(NumericFeatureStats { name: feature_names[fi].clone(), count, min: min_value as f64, max: max_value as f64, mean: sum / count as f64, histogram: HistogramStats { min: global_min as f64, max: global_max as f64, p1: p1 as f64, p99: p99 as f64, counts: bins, }, }); } } } } (numeric_features, enum_features_out) } /// Count occurrences of each variant of a single enum feature across `rows`. /// /// Unlike [`compute_feature_stats`], which is driven by the filter-matching /// subset, this lets a caller compute a count that should reflect a whole area /// regardless of the active filters (e.g. the council-house count, which is an /// attribute of the postcode itself, not of the currently filtered properties). /// Returns `None` if `feature_idx` is not an enum feature. pub fn compute_enum_feature_counts( rows: &[usize], data: &PropertyData, feature_idx: usize, ) -> Option> { let variants = data.enum_values.get(&feature_idx)?; let mut value_counts = vec![0u64; variants.len()]; for &row in rows { let value = data.get_feature(row, feature_idx); if value.is_finite() && value >= 0.0 && (value as usize) < value_counts.len() { value_counts[value as usize] += 1; } } Some( value_counts .iter() .enumerate() .filter(|(_, &count)| count > 0) .map(|(idx, &count)| (variants[idx].clone(), count)) .collect(), ) } /// Compute property-weighted per-year crime means across the selection. /// /// Each matching property contributes its postcode's per-year counts (incidents /// near that postcode); this is the same property-weighted-average shape used /// elsewhere in the right pane. /// /// Denominators are COVERAGE-AWARE: police.uk has multi-year publication gaps /// for whole forces (e.g. Greater Manchester from 2019-07), and the pipeline /// emits a `covered_years` calendar per postcode. A postcode only counts toward /// a year's denominator if its force published that year, and only then does /// its missing bar mean a genuine zero. Years no selected postcode covers are /// omitted entirely (charted as gaps, not zeros). Postcodes without coverage /// info (legacy parquet without the column) count toward every year, restoring /// the previous behaviour. pub fn compute_crime_by_year( matching_rows: &[usize], data: &PropertyData, crime_by_year: &CrimeByYearData, fields_specified: bool, field_set: &HashSet, ) -> Vec { if crime_by_year.crime_types.is_empty() || matching_rows.is_empty() { return Vec::new(); } let num_types = crime_by_year.crime_types.len(); let mut per_type_year_sums: Vec> = (0..num_types).map(|_| FxHashMap::default()).collect(); // Per-year denominator parts: rows whose coverage calendar includes the // year, plus rows with no calendar at all (legacy: covered everywhere). let mut covered_counts: FxHashMap = FxHashMap::default(); let mut fully_covered_rows: u32 = 0; for &row in matching_rows { let postcode = data.postcode(row); match crime_by_year.covered_years_by_postcode.get(postcode) { Some(years) => { // An empty list (force gap for the whole window / unusable // boundary geometry) adds nothing: the postcode's crime // picture is unknown and must not dilute any year's mean. for &year in years { *covered_counts.entry(year).or_insert(0) += 1; } } None => fully_covered_rows += 1, } // A postcode with a row but no series for a given type had no recorded // incidents of that type: it contributes 0 to the sums, and its covered // years still count in the denominator: a genuine zero. Uncovered // years are excluded via the denominators instead. if let Some(series_list) = crime_by_year.series_by_postcode.get(postcode) { for series in series_list { let acc = &mut per_type_year_sums[series.type_idx as usize]; for point in &series.points { *acc.entry(point.year).or_insert(0.0) += point.count as f64; } } } } let mut out = Vec::new(); for (type_idx, name) in crime_by_year.crime_types.iter().enumerate() { // Crime types in the by-year side table are bare (e.g. "Burglary"), while // the configured feature names carry a window suffix ("Burglary (/yr, // 7y)"). Emit the bare-type trend if the bare name is requested directly or // any of its windowed features is. if fields_specified { let prefix = format!("{name} ("); if !field_set.contains(name.as_str()) && !field_set.iter().any(|f| f.starts_with(&prefix)) { continue; } } let years = crime_by_year .years_by_type .get(type_idx) .map(Vec::as_slice) .unwrap_or(&[]); if years.is_empty() { continue; } let sums = &per_type_year_sums[type_idx]; let points: Vec = years .iter() .filter_map(|&year| { let denom = fully_covered_rows + covered_counts.get(&year).copied().unwrap_or(0); if denom == 0 { // No selected postcode has published data for this year. return None; } Some(CrimeYearPoint { year, count: (sums.get(&year).copied().unwrap_or(0.0) / denom as f64) as f32, }) }) .collect(); if points.is_empty() { continue; } out.push(CrimeYearStats { name: name.clone(), points, }); } out } /// Latest year present anywhere in the by-year crime dataset. The client /// compares each selection's last charted year against this to caption /// force-level publication gaps (e.g. Greater Manchester ends mid-2019) as /// stale data instead of letting old numbers read as current. pub fn crime_latest_available_year(crime_by_year: &CrimeByYearData) -> Option { crime_by_year.years_by_type.iter().flatten().copied().max() } /// Per-crime-type national/outcode/sector averages for a selection, keyed off /// its central postcode. Returns `(outcode, sector, per_type_averages)` where /// the outcode/sector strings are present only when matching averages exist. /// /// Honours the same `fields` filtering as [`compute_crime_by_year`]: when fields /// are specified, only the requested crime types are emitted, and a type is /// omitted only when none of its national/outcode/sector averages are known. pub fn area_crime_averages_for( central_postcode: Option<&str>, averages: &AreaCrimeAverages, fields_specified: bool, field_set: &HashSet, ) -> (Option, Option, Vec) { let none = || (None, None, Vec::new()); let Some(postcode) = central_postcode else { return none(); }; if averages.crime_types.is_empty() { return none(); } let outcode = postcode_outcode(postcode); let sector = postcode_sector(postcode); let outcode_means = outcode.and_then(|code| averages.by_outcode.get(code)); let sector_means = sector.and_then(|code| averages.by_sector.get(code)); // National is always available, so we still emit it (to override the // histogram national average for crime) even when this postcode's outcode // and sector both lack precomputed data. let finite_at = |means: Option<&Vec>, idx: usize| -> Option { means .and_then(|m| m.get(idx).copied()) .filter(|v| v.is_finite()) }; let mut out = Vec::new(); for (idx, name) in averages.crime_types.iter().enumerate() { // `name` is the full crime-feature name here (e.g. "Burglary (/yr, // 7y)"), matching exactly the feature fields the caller requests. if fields_specified && !field_set.contains(name.as_str()) { continue; } let national_val = finite_at(Some(&averages.national), idx); let outcode_val = finite_at(outcode_means, idx); let sector_val = finite_at(sector_means, idx); if national_val.is_none() && outcode_val.is_none() && sector_val.is_none() { continue; } out.push(CrimeAreaAverage { name: name.clone(), national: national_val, outcode: outcode_val, sector: sector_val, }); } ( outcode .filter(|_| outcode_means.is_some()) .map(str::to_string), sector .filter(|_| sector_means.is_some()) .map(str::to_string), out, ) } pub fn compute_poi_feature_stats( matching_rows: &[usize], poi_metrics: &PostcodePoiMetrics, fields_specified: bool, field_set: &HashSet, ) -> Vec { let mut out = Vec::new(); for (metric_idx, name) in poi_metrics.feature_names.iter().enumerate() { if fields_specified && !field_set.contains(name.as_str()) { continue; } let global_hist = &poi_metrics.feature_stats[metric_idx].histogram; let p1 = global_hist.p1; let p99 = global_hist.p99; let num_bins = global_hist.counts.len(); let middle_bins = num_bins.saturating_sub(2); let middle_width = if middle_bins > 0 && p99 > p1 { (p99 - p1) / middle_bins as f32 } else { 0.0 }; let mut count = 0usize; let mut min_value = f32::INFINITY; let mut max_value = f32::NEG_INFINITY; let mut sum = 0.0f64; let mut bins = vec![0u64; num_bins]; for &row in matching_rows { let value = poi_metrics.get_for_property_row(row, metric_idx); if !value.is_finite() { continue; } count += 1; if value < min_value { min_value = value; } if value > max_value { max_value = value; } sum += value as f64; let bin = if value < p1 { 0 } else if value >= p99 { num_bins - 1 } else if middle_width > 0.0 { let middle_bin = ((value - p1) / middle_width) as usize; (1 + middle_bin).min(num_bins - 2) } else { num_bins / 2 }; bins[bin] += 1; } if count > 0 { out.push(NumericFeatureStats { name: name.clone(), count, min: min_value as f64, max: max_value as f64, mean: sum / count as f64, histogram: HistogramStats { min: global_hist.min as f64, max: global_hist.max as f64, p1: p1 as f64, p99: p99 as f64, counts: bins, }, }); } } out } #[cfg(test)] mod tests { use super::*; use crate::consts::NAN_U16; use crate::data::spill::SpillVec; fn enum_data(values: &[u16]) -> PropertyData { let mut data = PropertyData::empty_for_tests(); data.num_features = 1; data.num_numeric = 0; // single enum feature at index 0 data.feature_data = SpillVec::owned(values.to_vec()); data.enum_values .insert(0, vec!["Yes".to_string(), "No".to_string()]); data } #[test] fn enum_counts_tally_only_given_rows() { // Rows: Yes, No, Yes, let data = enum_data(&[0, 1, 0, NAN_U16]); let all = compute_enum_feature_counts(&[0, 1, 2, 3], &data, 0).unwrap(); assert_eq!(all.get("Yes"), Some(&2)); assert_eq!(all.get("No"), Some(&1)); // A filter-matching subset would yield a different tally, confirming // the count is driven purely by the rows passed in (so callers can pass // the full area to make it filter-independent). let subset = compute_enum_feature_counts(&[0, 3], &data, 0).unwrap(); assert_eq!(subset.get("Yes"), Some(&1)); assert_eq!(subset.get("No"), None); } #[test] fn enum_counts_none_for_non_enum_feature() { let data = enum_data(&[0, 1]); assert!(compute_enum_feature_counts(&[0, 1], &data, 1).is_none()); } fn sample_averages() -> AreaCrimeAverages { let mut by_outcode = rustc_hash::FxHashMap::default(); by_outcode.insert("E14".to_string(), vec![10.0, f32::NAN]); let mut by_sector = rustc_hash::FxHashMap::default(); by_sector.insert("E14 2".to_string(), vec![5.0, 7.0]); AreaCrimeAverages { crime_types: vec![ "Burglary (/yr, 7y)".to_string(), "Robbery (/yr, 7y)".to_string(), ], national: vec![8.0, 6.0], by_outcode, by_sector, } } #[test] fn area_crime_averages_populate_national_outcode_sector() { let avgs = sample_averages(); let (outcode, sector, out) = area_crime_averages_for(Some("E14 2DG"), &avgs, false, &HashSet::new()); assert_eq!(outcode.as_deref(), Some("E14")); assert_eq!(sector.as_deref(), Some("E14 2")); assert_eq!(out.len(), 2); let burglary = out.iter().find(|c| c.name == "Burglary (/yr, 7y)").unwrap(); assert_eq!(burglary.national, Some(8.0)); assert_eq!(burglary.outcode, Some(10.0)); assert_eq!(burglary.sector, Some(5.0)); let robbery = out.iter().find(|c| c.name == "Robbery (/yr, 7y)").unwrap(); assert_eq!(robbery.national, Some(6.0)); // The outcode value was NaN, dropped to None; the sector value is finite. assert_eq!(robbery.outcode, None); assert_eq!(robbery.sector, Some(7.0)); } #[test] fn area_crime_averages_respect_fields_filter() { let avgs = sample_averages(); // Area averages are keyed by the full crime-feature name. let fields: HashSet = ["Burglary (/yr, 7y)".to_string()].into_iter().collect(); let (_, _, out) = area_crime_averages_for(Some("E14 2DG"), &avgs, true, &fields); assert_eq!(out.len(), 1); assert_eq!(out[0].name, "Burglary (/yr, 7y)"); } #[test] fn area_crime_averages_unknown_area_still_emits_national() { let avgs = sample_averages(); let (outcode, sector, out) = area_crime_averages_for(Some("ZZ9 9ZZ"), &avgs, false, &HashSet::new()); // The area is absent from both maps: no codes, but national still applies. assert!(outcode.is_none()); assert!(sector.is_none()); assert_eq!(out.len(), 2); assert!(out .iter() .all(|c| c.outcode.is_none() && c.sector.is_none())); assert_eq!(out[0].national, Some(8.0)); } #[test] fn area_crime_averages_none_without_postcode() { let avgs = sample_averages(); let (outcode, sector, out) = area_crime_averages_for(None, &avgs, false, &HashSet::new()); assert!(outcode.is_none() && sector.is_none() && out.is_empty()); } }