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

@ -22,6 +22,10 @@ pub const PLACES_LIMIT: usize = 20;
/// feature name in `features.rs`.
pub const FORMER_COUNCIL_HOUSE_FEATURE: &str = "Former council house";
pub const PRICE_HISTORY_POINTS_LIMIT: usize = 5000;
/// Downsample cap for the wider-area (sector / outcode) price-history charts.
/// Lower than the selection's own cap because these render below it: three full
/// scatters at 5000 points each would flood the DOM.
pub const AREA_PRICE_HISTORY_POINTS_LIMIT: usize = 1500;
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;

View file

@ -12,6 +12,16 @@ use crate::utils::{normalize_postcode, GridIndex, InternedColumn};
const GRID_CELL_SIZE: f32 = 0.01;
/// One observation on a listing's accruing asking-price timeline, recovered from
/// the scraper's forward-only store (finder/price_history.py). `reason` is one of
/// "listed" | "reduced" | "increased"; `date` is `YYYY-MM-DD`.
#[derive(Serialize, Clone)]
pub struct PriceHistoryPoint {
pub date: String,
pub price: i64,
pub reason: String,
}
#[derive(Serialize, Clone)]
pub struct ActualListing {
pub lat: f32,
@ -32,6 +42,10 @@ pub struct ActualListing {
pub listing_status: Option<String>,
pub listing_date_iso: Option<String>,
pub features: Vec<String>,
/// Accrued asking-price observations, oldest -> newest. Empty until the
/// scraper's forward-only store has recorded at least one run for this id.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub price_history: Vec<PriceHistoryPoint>,
}
pub struct ActualListingData {
@ -54,6 +68,8 @@ pub struct ActualListingData {
pub listing_status: InternedColumn,
pub listing_date_iso: Vec<Option<String>>,
pub features: Vec<Vec<String>>,
/// Per-row accrued asking-price series (empty when absent from the parquet).
pub price_history: Vec<Vec<PriceHistoryPoint>>,
/// Row-major feature matrix aligned with PropertyData::feature_names.
///
/// Rows start from a best-effort address/postcode join to the historical property
@ -102,6 +118,7 @@ impl ActualListingData {
let listing_status_raw = extract_opt_str(&df, "Listing status")?;
let listing_date_iso = extract_opt_datetime_iso(&df, "Listing date")?;
let features = extract_str_list(&df, "Listing features")?;
let price_history = extract_price_history(&df)?;
let postcode: Vec<String> = postcode_raw.iter().map(|s| normalize_postcode(s)).collect();
@ -146,6 +163,7 @@ impl ActualListingData {
listing_status,
listing_date_iso,
features,
price_history,
filter_feature_data,
poi_filter_feature_data,
grid,
@ -172,6 +190,7 @@ impl ActualListingData {
listing_status: opt_from_interned(&self.listing_status, row),
listing_date_iso: self.listing_date_iso[row].clone(),
features: self.features[row].clone(),
price_history: self.price_history[row].clone(),
}
}
}
@ -629,6 +648,64 @@ fn extract_str_list(df: &DataFrame, name: &str) -> Result<Vec<Vec<String>>> {
Ok(out)
}
/// Extract `price_history: List<Struct{date: Utf8, price: Int64, reason: Utf8}>`.
///
/// Schema-gated: a parquet written before the column existed simply yields an
/// empty series per row (the column is dropped from serialized listings). A
/// null/empty list is likewise an empty series.
fn extract_price_history(df: &DataFrame) -> Result<Vec<Vec<PriceHistoryPoint>>> {
let row_count = df.height();
let Ok(column) = df.column("price_history") else {
return Ok(vec![Vec::new(); row_count]);
};
let list_ca = column
.list()
.context("price_history is not a list column")?;
let mut out: Vec<Vec<PriceHistoryPoint>> = Vec::with_capacity(row_count);
for row in 0..row_count {
let mut points = Vec::new();
if let Some(inner) = list_ca.get_as_series(row) {
if !inner.is_empty() {
let structs = inner
.struct_()
.context("price_history inner is not a struct")?;
let dates_s = structs
.field_by_name("date")
.context("Missing 'date' field in price_history struct")?;
let date_ca = dates_s.str().context("price_history.date is not a string")?;
let prices_s = structs
.field_by_name("price")
.context("Missing 'price' field in price_history struct")?;
let prices_i64 = prices_s
.cast(&DataType::Int64)
.context("price_history.price is not castable to Int64")?;
let price_ca = prices_i64.i64().context("price_history.price is not Int64")?;
let reasons_s = structs
.field_by_name("reason")
.context("Missing 'reason' field in price_history struct")?;
let reason_ca = reasons_s
.str()
.context("price_history.reason is not a string")?;
for idx in 0..inner.len() {
let (Some(date), Some(price), Some(reason)) =
(date_ca.get(idx), price_ca.get(idx), reason_ca.get(idx))
else {
continue;
};
points.push(PriceHistoryPoint {
date: date.to_string(),
price,
reason: reason.to_string(),
});
}
}
}
out.push(points);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@ -663,4 +740,30 @@ mod tests {
assert!(any_listing.lon.is_finite());
assert!(!any_listing.listing_url.is_empty());
}
#[test]
fn extracts_price_history_from_parquet() {
let path = PathBuf::from("src/data/testdata/listings_price_history.parquet");
if !path.exists() {
eprintln!("price_history fixture not present; skipping");
return;
}
let data = ActualListingData::load_inner(&path, None).expect("listings load");
assert_eq!(data.price_history.len(), data.lat.len());
// Row 0: two points, listed -> reduced, oldest first.
let r0 = data.listing_at(0);
assert_eq!(r0.price_history.len(), 2);
assert_eq!(r0.price_history[0].date, "2026-07-01");
assert_eq!(r0.price_history[0].price, 500_000);
assert_eq!(r0.price_history[0].reason, "listed");
assert_eq!(r0.price_history[1].price, 480_000);
assert_eq!(r0.price_history[1].reason, "reduced");
// Row 1: a single "listed" point.
assert_eq!(data.listing_at(1).price_history.len(), 1);
// Row 2 (empty list) and row 3 (null) carry no points.
assert!(data.listing_at(2).price_history.is_empty());
assert!(data.listing_at(3).price_history.is_empty());
}
}

View file

@ -903,6 +903,40 @@ impl PropertyData {
for rows in postcode_row_index.values_mut() {
rows.shrink_to_fit();
}
// Group postcode keys by sector ("E14 2") and outward code ("E14") so the
// wider-area price-history charts can enumerate a sector's / outcode's
// properties. Storing keys (one per postcode) rather than rows keeps this
// an order of magnitude smaller than the row index.
let mut sector_postcode_index: FxHashMap<String, Vec<lasso::Spur>> = FxHashMap::default();
let mut outcode_postcode_index: FxHashMap<String, Vec<lasso::Spur>> = FxHashMap::default();
for &key in postcode_row_index.keys() {
let postcode = postcode_rodeo.resolve(&key);
if let Some(sector) = crate::utils::postcode_sector(postcode) {
sector_postcode_index
.entry(sector.to_string())
.or_default()
.push(key);
}
if let Some(outcode) = crate::utils::postcode_outcode(postcode) {
outcode_postcode_index
.entry(outcode.to_string())
.or_default()
.push(key);
}
}
for keys in sector_postcode_index.values_mut() {
keys.shrink_to_fit();
}
for keys in outcode_postcode_index.values_mut() {
keys.shrink_to_fit();
}
tracing::info!(
sectors = sector_postcode_index.len(),
outcodes = outcode_postcode_index.len(),
"Sector/outcode postcode indexes built"
);
let postcode_interner = postcode_rodeo.into_reader();
let row_to_poi_metric_idx: Vec<u32> = if poi_metrics.is_empty() {
@ -1122,6 +1156,8 @@ impl PropertyData {
postcode_interner,
postcode_keys,
postcode_row_index,
sector_postcode_index,
outcode_postcode_index,
address_token_index,
address_prefix_index,
address_search_interner,

View file

@ -18,7 +18,7 @@ mod quant;
mod stats;
pub use h3::precompute_h3;
pub use poi_metrics::PostcodePoiMetrics;
pub use poi_metrics::{PostcodePoiMetrics, COMBINED_STATION_CATEGORY};
pub use quant::QuantRef;
pub use stats::{FeatureStats, Histogram};
@ -90,6 +90,14 @@ pub struct PropertyData {
postcode_keys: SpillVec<lasso::Spur>,
/// Rows for each postcode, keyed by the interned postcode key.
postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>>,
/// Postcode keys for each postcode sector (e.g. "E14 2"). Stores interned
/// keys, not rows, so the wider-area price-history charts can enumerate a
/// sector's properties (via `postcode_row_index`) for ~1/13th the memory of
/// duplicating the row ids.
sector_postcode_index: FxHashMap<String, Vec<lasso::Spur>>,
/// Postcode keys for each outward code (e.g. "E14"). Same rationale as
/// `sector_postcode_index`.
outcode_postcode_index: FxHashMap<String, Vec<lasso::Spur>>,
/// Inverted index from address tokens to property rows.
address_token_index: FxHashMap<String, Vec<u32>>,
/// Prefix lookup from typed address-token prefix to indexed full address tokens.
@ -156,6 +164,31 @@ impl PropertyData {
.unwrap_or(&[])
}
/// All property rows in a postcode sector (e.g. "E14 2"), gathered across the
/// sector's postcodes. Empty if the sector is unknown. Materializes a new Vec
/// since the rows live in per-postcode buckets; callers downsample it.
pub fn rows_for_sector(&self, sector: &str) -> Vec<u32> {
self.rows_for_area(self.sector_postcode_index.get(sector))
}
/// All property rows in an outward code (e.g. "E14"). See `rows_for_sector`.
pub fn rows_for_outcode(&self, outcode: &str) -> Vec<u32> {
self.rows_for_area(self.outcode_postcode_index.get(outcode))
}
fn rows_for_area(&self, postcode_keys: Option<&Vec<lasso::Spur>>) -> Vec<u32> {
let Some(keys) = postcode_keys else {
return Vec::new();
};
let mut rows = Vec::new();
for key in keys {
if let Some(bucket) = self.postcode_row_index.get(key) {
rows.extend_from_slice(bucket);
}
}
rows
}
/// Get the is_approx_build_date flag for a given row (bit-packed).
pub fn is_approx_build_date(&self, row: usize) -> bool {
let byte = self.approx_build_date_bits[row / 8];
@ -253,6 +286,8 @@ impl PropertyData {
postcode_interner: lasso::Rodeo::default().into_reader(),
postcode_keys: SpillVec::owned(Vec::new()),
postcode_row_index: FxHashMap::default(),
sector_postcode_index: FxHashMap::default(),
outcode_postcode_index: FxHashMap::default(),
address_token_index: FxHashMap::default(),
address_prefix_index: FxHashMap::default(),
address_search_interner: lasso::Rodeo::default().into_resolver(),

View file

@ -44,7 +44,7 @@ impl PostcodePoiMetrics {
pub(super) fn from_postcode_df(
df: &DataFrame,
feature_names: Vec<String>,
mut feature_names: Vec<String>,
) -> anyhow::Result<Self> {
if feature_names.is_empty() {
return Ok(Self::empty(0));
@ -56,7 +56,7 @@ impl PostcodePoiMetrics {
"Building postcode POI metric side table"
);
let col_major: Vec<Vec<f32>> = feature_names
let mut col_major: Vec<Vec<f32>> = feature_names
.par_iter()
.map(|name| {
let column = df
@ -66,6 +66,8 @@ impl PostcodePoiMetrics {
})
.collect::<anyhow::Result<Vec<_>>>()?;
append_combined_station_distance(&mut feature_names, &mut col_major);
let feature_stats: Vec<FeatureStats> = col_major
.par_iter()
.enumerate()
@ -198,3 +200,140 @@ impl PostcodePoiMetrics {
self.decode_raw(metric_idx, self.raw_for_property_row(row, metric_idx))
}
}
/// Category name of the synthetic "nearest of any rail mode" distance feature.
pub const COMBINED_STATION_CATEGORY: &str = "Any station";
/// Per-mode transport categories folded into [`COMBINED_STATION_CATEGORY`].
const COMBINED_STATION_SOURCE_CATEGORIES: &[&str] = &[
"Rail station",
"Tube station",
"DLR station",
"Tram & Metro stop",
];
fn poi_distance_feature_name(category: &str) -> String {
format!("Distance to nearest amenity ({category}) (km)")
}
/// Append a synthetic combined-station distance column: the elementwise minimum
/// of the per-mode rail/tube/tram/DLR distance columns that are present. This
/// lets users filter on the nearest station of any rail mode without a data
/// rebuild, since the source columns are already loaded. NaN (no station) rows
/// stay NaN only when every source is missing.
fn append_combined_station_distance(
feature_names: &mut Vec<String>,
col_major: &mut Vec<Vec<f32>>,
) {
let combined_name = poi_distance_feature_name(COMBINED_STATION_CATEGORY);
// Guard against re-synthesis or a pipeline that already provides the column.
if feature_names.iter().any(|name| name == &combined_name) {
return;
}
let source_indices: Vec<usize> = COMBINED_STATION_SOURCE_CATEGORIES
.iter()
.filter_map(|category| {
let name = poi_distance_feature_name(category);
feature_names.iter().position(|existing| existing == &name)
})
.collect();
if source_indices.is_empty() {
return;
}
let row_count = col_major.first().map_or(0, Vec::len);
let combined: Vec<f32> = (0..row_count)
.into_par_iter()
.map(|row| {
let nearest = source_indices
.iter()
.map(|&idx| col_major[idx][row])
.filter(|value| value.is_finite())
.fold(f32::INFINITY, f32::min);
if nearest.is_finite() {
nearest
} else {
f32::NAN
}
})
.collect();
tracing::info!(
sources = source_indices.len(),
"Synthesized combined-station POI distance column"
);
feature_names.push(combined_name);
col_major.push(combined);
}
#[cfg(test)]
mod tests {
use super::*;
fn dist_name(category: &str) -> String {
format!("Distance to nearest amenity ({category}) (km)")
}
#[test]
fn combined_station_is_elementwise_min_ignoring_nan() {
let mut names = vec![
dist_name("Rail station"),
dist_name("Tube station"),
dist_name("DLR station"),
dist_name("Tram & Metro stop"),
];
let nan = f32::NAN;
let mut cols = vec![
vec![2.0, nan, 5.0], // Rail
vec![1.0, nan, nan], // Tube
vec![3.0, 4.0, nan], // DLR
vec![nan, nan, nan], // Tram & Metro
];
append_combined_station_distance(&mut names, &mut cols);
assert_eq!(names.last().unwrap(), &dist_name(COMBINED_STATION_CATEGORY));
let combined = cols.last().unwrap();
assert_eq!(combined[0], 1.0); // min(2, 1, 3), tram NaN ignored
assert_eq!(combined[1], 4.0); // only DLR present
assert_eq!(combined[2], 5.0); // only Rail present
}
#[test]
fn combined_station_row_with_no_station_stays_nan() {
let mut names = vec![dist_name("Rail station")];
let mut cols = vec![vec![f32::NAN, 2.0]];
append_combined_station_distance(&mut names, &mut cols);
let combined = cols.last().unwrap();
assert!(combined[0].is_nan());
assert_eq!(combined[1], 2.0);
}
#[test]
fn no_source_categories_appends_nothing() {
let mut names = vec![dist_name("Café"), dist_name("Pub")];
let mut cols = vec![vec![1.0], vec![2.0]];
append_combined_station_distance(&mut names, &mut cols);
assert_eq!(names.len(), 2);
assert_eq!(cols.len(), 2);
}
#[test]
fn combined_station_is_not_duplicated() {
let mut names = vec![
dist_name("Rail station"),
dist_name(COMBINED_STATION_CATEGORY),
];
let mut cols = vec![vec![1.0], vec![1.0]];
append_combined_station_distance(&mut names, &mut cols);
assert_eq!(names.len(), 2);
assert_eq!(cols.len(), 2);
}
}

View file

@ -685,6 +685,14 @@ async fn main() -> anyhow::Result<()> {
&cli.google_oauth_client_secret,
)
.await?;
pocketbase::ensure_password_reset_template(
&http_client,
&cli.pocketbase_url,
&cli.pocketbase_admin_email,
&cli.pocketbase_admin_password,
&cli.public_url,
)
.await?;
info!("Gemini configured (model: {})", cli.gemini_model);
let tt_path = &cli.travel_times;
if !tt_path.exists() {

View file

@ -171,6 +171,15 @@ fn seo_page_for_path(path: &str) -> Option<SeoPage> {
description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.",
indexable: false,
}),
// The password-reset email links here (see pocketbase::ensure_password_reset_template).
// Without this entry should_return_404 would 404 the SPA route and the emailed link
// would dead-end, which is the whole bug this page exists to fix.
"/reset-password" => Some(SeoPage {
canonical_path: "/reset-password",
title: "Reset your password | Perfect Postcode",
description: "Choose a new password for your Perfect Postcode account.",
indexable: false,
}),
_ if path.starts_with("/invite/") => Some(SeoPage {
canonical_path: "/invite",
title: "You're invited to Perfect Postcode",
@ -508,6 +517,14 @@ mod tests {
assert!(should_return_404("/definitely-not-a-real-page"));
}
#[test]
fn reset_password_page_resolves_and_is_not_404() {
// The PocketBase reset email links to this SPA route (the middleware sees only the
// path, not the ?token= query), so it must render rather than 404.
assert!(seo_page_for_path("/reset-password").is_some());
assert!(!should_return_404("/reset-password"));
}
#[test]
fn indexable_pages_emit_hreflang() {
let page = seo_page_for_path("/terms").expect("terms page");

View file

@ -61,6 +61,11 @@ pub struct EnumFeatureStats {
pub struct PricePoint {
pub year: f32,
pub price: f32,
/// Sale price divided by the property's EPC floor area (the "Price per sqm"
/// feature). Absent where no floor area is recorded, so the per-m² view drops
/// those points.
#[serde(skip_serializing_if = "Option::is_none")]
pub price_per_sqm: Option<f32>,
}
#[derive(Serialize)]
@ -143,6 +148,15 @@ pub struct HexagonStatsResponse {
pub enum_features: Vec<EnumFeatureStats>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub price_history: Vec<PricePoint>,
/// Price history for every sale in the selection's postcode sector (e.g.
/// "E14 2"), independent of the active filters: a wider-area reference for
/// the selection's own chart.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sector_price_history: Vec<PricePoint>,
/// Price history for every sale in the selection's outward code (e.g. "E14"),
/// independent of the active filters.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub outcode_price_history: Vec<PricePoint>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub crime_by_year: Vec<CrimeYearStats>,
/// Latest year in the crime dataset as a whole. When a selection's series
@ -642,6 +656,12 @@ pub async fn get_hexagon_stats(
let price_history =
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
// Sector/outcode price histories are a postcode-selection feature only: a
// hexagon straddles arbitrary postcodes, so its central postcode's
// sector/outcode is not a meaningful aggregation unit. The frontend hides
// them for hexagons, so skip the (potentially large) outcode scan here.
let sector_price_history: Vec<PricePoint> = Vec::new();
let outcode_price_history: Vec<PricePoint> = Vec::new();
let crime_by_year = stats::compute_crime_by_year(
&matching_rows,
@ -733,6 +753,8 @@ pub async fn get_hexagon_stats(
numeric_features,
enum_features: enum_features_out,
price_history,
sector_price_history,
outcode_price_history,
crime_by_year,
crime_latest_year,
crime_outcode,

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.