735 lines
26 KiB
Rust
735 lines
26 KiB
Rust
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<usize>,
|
|
}
|
|
|
|
impl PriceHistoryIndexes {
|
|
fn resolve(feature_name_to_index: &FxHashMap<String, usize>) -> Option<Self> {
|
|
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<Item = usize>,
|
|
data: &PropertyData,
|
|
idx: &PriceHistoryIndexes,
|
|
limit: usize,
|
|
) -> Vec<PricePoint> {
|
|
let mut points: Vec<PricePoint> = 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<String, usize>,
|
|
) -> Vec<PricePoint> {
|
|
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<String, usize>,
|
|
) -> (Vec<PricePoint>, Vec<PricePoint>) {
|
|
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<u64>,
|
|
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<u64> },
|
|
/// 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<usize, Vec<String>>,
|
|
feature_stats_data: &[FeatureStats],
|
|
fields_specified: bool,
|
|
field_set: &HashSet<String>,
|
|
) -> (Vec<NumericFeatureStats>, Vec<EnumFeatureStats>) {
|
|
let num_features = feature_names.len();
|
|
|
|
// Pre-allocate accumulators for all features
|
|
let mut accums: Vec<FeatureAccum> = (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<String, u64> = 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<HashMap<String, u64>> {
|
|
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<String>,
|
|
) -> Vec<CrimeYearStats> {
|
|
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<FxHashMap<i32, f64>> =
|
|
(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<i32, u32> = 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<CrimeYearPoint> = 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<i32> {
|
|
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<String>,
|
|
) -> (Option<String>, Option<String>, Vec<CrimeAreaAverage>) {
|
|
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<f32>>, idx: usize| -> Option<f32> {
|
|
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<String>,
|
|
) -> Vec<NumericFeatureStats> {
|
|
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, <missing>
|
|
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<String> = ["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());
|
|
}
|
|
}
|