Add postcodes
This commit is contained in:
parent
004948385d
commit
ce4c0cc08c
5 changed files with 325 additions and 1 deletions
246
server-rs/src/routes/postcodes.rs
Normal file
246
server-rs/src/routes/postcodes.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tracing::info;
|
||||
|
||||
use crate::parsing::{parse_bounds, parse_filters, row_passes_filters};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PostcodesResponse {
|
||||
features: Vec<Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PostcodeParams {
|
||||
bounds: Option<String>,
|
||||
/// Comma-separated filters: `name:min:max,...`
|
||||
filters: Option<String>,
|
||||
/// Comma-separated feature names to include in min/max aggregation.
|
||||
fields: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-postcode accumulator for aggregating features.
|
||||
struct PostcodeAgg {
|
||||
count: u32,
|
||||
mins: Box<[f32]>,
|
||||
maxs: Box<[f32]>,
|
||||
}
|
||||
|
||||
impl PostcodeAgg {
|
||||
fn new(num_features: usize) -> Self {
|
||||
PostcodeAgg {
|
||||
count: 0,
|
||||
mins: vec![f32::INFINITY; num_features].into_boxed_slice(),
|
||||
maxs: vec![f32::NEG_INFINITY; num_features].into_boxed_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn add_row(&mut self, feature_data: &[f32], row: usize, num_features: usize) {
|
||||
self.count += 1;
|
||||
let base = row * num_features;
|
||||
let row_slice = &feature_data[base..base + num_features];
|
||||
for (feat_index, &value) in row_slice.iter().enumerate() {
|
||||
if value.is_finite() {
|
||||
if value < self.mins[feat_index] {
|
||||
self.mins[feat_index] = value;
|
||||
}
|
||||
if value > self.maxs[feat_index] {
|
||||
self.maxs[feat_index] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn add_row_selective(
|
||||
&mut self,
|
||||
feature_data: &[f32],
|
||||
row: usize,
|
||||
num_features: usize,
|
||||
indices: &[usize],
|
||||
) {
|
||||
self.count += 1;
|
||||
let base = row * num_features;
|
||||
for &feat_index in indices {
|
||||
let value = feature_data[base + feat_index];
|
||||
if value.is_finite() {
|
||||
if value < self.mins[feat_index] {
|
||||
self.mins[feat_index] = value;
|
||||
}
|
||||
if value > self.maxs[feat_index] {
|
||||
self.maxs[feat_index] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_postcodes(
|
||||
state: Arc<AppState>,
|
||||
Query(params): Query<PostcodeParams>,
|
||||
) -> Result<Json<PostcodesResponse>, (StatusCode, String)> {
|
||||
let bounds_str = params.bounds.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"bounds parameter is required".into(),
|
||||
))?;
|
||||
|
||||
let (south, west, north, east) = parse_bounds(&bounds_str)?;
|
||||
|
||||
let filters_str = params.filters.clone();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.data.feature_names,
|
||||
&state.data.enum_values,
|
||||
);
|
||||
let num_filters = parsed_filters.len() + parsed_enum_filters.len();
|
||||
|
||||
// Parse optional `fields` param into feature indices
|
||||
let field_indices: Option<Vec<usize>> = params.fields.as_ref().map(|fields_str| {
|
||||
if fields_str.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
fields_str
|
||||
.split(',')
|
||||
.filter_map(|name| {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
state
|
||||
.data
|
||||
.feature_names
|
||||
.iter()
|
||||
.position(|feat| feat == name)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || -> Result<PostcodesResponse, String> {
|
||||
let postcode_data = &state.postcode_data;
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
let num_features = state.data.num_features;
|
||||
let feature_data = &state.data.feature_data;
|
||||
let min_keys = &state.min_keys;
|
||||
let max_keys = &state.max_keys;
|
||||
|
||||
let has_selective = field_indices.is_some();
|
||||
let sel_indices = field_indices.as_deref().unwrap_or(&[]);
|
||||
|
||||
// Step 1: Find postcodes within bounds using spatial grid on centroids
|
||||
let postcode_indices: Vec<u32> = postcode_data.grid.query(south, west, north, east);
|
||||
|
||||
// Step 2: For each postcode, aggregate properties
|
||||
let mut postcode_aggs: FxHashMap<usize, PostcodeAgg> = FxHashMap::default();
|
||||
|
||||
// Build postcode -> rows mapping by iterating properties in bounds
|
||||
// and grouping by their postcode
|
||||
let mut postcode_rows: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
|
||||
|
||||
state
|
||||
.grid
|
||||
.for_each_in_bounds(south, west, north, east, |row_idx| {
|
||||
let row = row_idx as usize;
|
||||
if !row_passes_filters(
|
||||
row,
|
||||
&parsed_filters,
|
||||
&parsed_enum_filters,
|
||||
feature_data,
|
||||
num_features,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get postcode for this property
|
||||
let postcode = state.data.postcode(row);
|
||||
if let Some(&pc_idx) = postcode_data.postcode_to_idx.get(postcode) {
|
||||
postcode_rows.entry(pc_idx).or_default().push(row);
|
||||
}
|
||||
});
|
||||
|
||||
// Now aggregate for each postcode that's in bounds and has properties
|
||||
for &pc_idx in &postcode_indices {
|
||||
let idx = pc_idx as usize;
|
||||
if let Some(rows) = postcode_rows.get(&idx) {
|
||||
let agg = postcode_aggs
|
||||
.entry(idx)
|
||||
.or_insert_with(|| PostcodeAgg::new(num_features));
|
||||
for &row in rows {
|
||||
if has_selective {
|
||||
agg.add_row_selective(feature_data, row, num_features, sel_indices);
|
||||
} else {
|
||||
agg.add_row(feature_data, row, num_features);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build response
|
||||
let mut features = Vec::with_capacity(postcode_aggs.len());
|
||||
|
||||
for (pc_idx, aggregation) in postcode_aggs {
|
||||
if aggregation.count == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut map = Map::new();
|
||||
map.insert(
|
||||
"postcode".into(),
|
||||
Value::String(postcode_data.postcodes[pc_idx].clone()),
|
||||
);
|
||||
map.insert("count".into(), Value::Number(aggregation.count.into()));
|
||||
|
||||
// Add vertices as array of [lon, lat] pairs
|
||||
let vertices_array: Vec<Value> = postcode_data.vertices[pc_idx]
|
||||
.iter()
|
||||
.map(|[lon, lat]| Value::Array(vec![Value::from(*lon as f64), Value::from(*lat as f64)]))
|
||||
.collect();
|
||||
map.insert("vertices".into(), Value::Array(vertices_array));
|
||||
|
||||
let iter: Box<dyn Iterator<Item = usize>> = if let Some(idx) = field_indices.as_ref() {
|
||||
Box::new(idx.iter().copied())
|
||||
} else {
|
||||
Box::new(0..num_features)
|
||||
};
|
||||
|
||||
for feat_index in iter {
|
||||
if aggregation.mins[feat_index].is_finite()
|
||||
&& aggregation.maxs[feat_index].is_finite()
|
||||
{
|
||||
if let (Some(min_num), Some(max_num)) = (
|
||||
serde_json::Number::from_f64(aggregation.mins[feat_index] as f64),
|
||||
serde_json::Number::from_f64(aggregation.maxs[feat_index] as f64),
|
||||
) {
|
||||
map.insert(min_keys[feat_index].clone(), Value::Number(min_num));
|
||||
map.insert(max_keys[feat_index].clone(), Value::Number(max_num));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
features.push(map);
|
||||
}
|
||||
|
||||
let t_total = t0.elapsed();
|
||||
info!(
|
||||
postcodes = features.len(),
|
||||
filters = num_filters,
|
||||
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
||||
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
|
||||
"GET /api/postcodes"
|
||||
);
|
||||
|
||||
Ok(PostcodesResponse { features })
|
||||
})
|
||||
.await
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?
|
||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error))?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue