This commit is contained in:
Andras Schmelczer 2026-07-12 20:30:19 +01:00
parent 6df2812a4e
commit 9e4e65fa2a
35 changed files with 1172 additions and 70 deletions

View file

@ -4,7 +4,7 @@ use metrics::counter;
use rustc_hash::FxHashMap;
use tracing::error;
use crate::consts::PRICE_HISTORY_POINTS_LIMIT;
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};
@ -15,47 +15,122 @@ use super::hexagon_stats::{
NumericFeatureStats, PricePoint,
};
/// Extract price history (year, price) pairs from matching rows, downsampled if needed.
/// 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> {
let year_idx = feature_name_to_index
.get("Date of last transaction")
.copied();
match year_idx {
Some(yi) => {
let mut points: Vec<PricePoint> = matching_rows
.iter()
.filter_map(|&row| {
let year = data.get_feature(row, yi);
let price = data.last_known_price_raw(row);
if year.is_finite() && price.is_finite() {
Some(PricePoint { year, price })
} else {
None
}
})
.collect();
if points.len() > PRICE_HISTORY_POINTS_LIMIT {
let step = points.len() as f64 / PRICE_HISTORY_POINTS_LIMIT as f64;
points = (0..PRICE_HISTORY_POINTS_LIMIT)
.map(|i| {
let idx = (i as f64 * step) as usize;
PricePoint {
year: points[idx].year,
price: points[idx].price,
}
})
.collect();
}
points
}
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.