Add postcodes

This commit is contained in:
Andras Schmelczer 2026-02-04 21:35:12 +00:00
parent 004948385d
commit ce4c0cc08c
5 changed files with 325 additions and 1 deletions

View file

@ -1,5 +1,7 @@
mod poi;
mod postcodes;
mod property;
pub use poi::{POICategoryGroup, POIData};
pub use postcodes::PostcodeData;
pub use property::{precompute_h3, Histogram, PropertyData};

View file

@ -1,6 +1,7 @@
mod consts;
mod data;
mod features;
mod metrics;
mod og_middleware;
pub mod parsing;
mod routes;
@ -35,6 +36,14 @@ struct Cli {
#[arg(long)]
pois: PathBuf,
/// Path to the postcode boundaries directory
#[arg(long)]
postcodes: PathBuf,
/// Path to the PMTiles file for map tiles
#[arg(long)]
tiles: PathBuf,
/// Path to the frontend dist directory
#[arg(long)]
dist: Option<PathBuf>,
@ -61,6 +70,10 @@ async fn main() -> anyhow::Result<()> {
.with_ansi(true)
.init();
// Initialize Prometheus metrics
let metrics_handle = metrics::init_metrics();
info!("Prometheus metrics initialized");
let cli = Cli::parse();
let parquet_path = &cli.data;
@ -107,6 +120,33 @@ async fn main() -> anyhow::Result<()> {
let poi_grid =
utils::GridIndex::build(&poi_data.lat, &poi_data.lng, consts::GRID_CELL_SIZE);
// Load postcode boundaries
let postcodes_path = &cli.postcodes;
if !postcodes_path.exists() {
bail!(
"Postcode boundaries not found: {}",
postcodes_path.display()
);
}
info!(
"Loading postcode boundaries from {}",
postcodes_path.display()
);
let postcode_data = data::PostcodeData::load(postcodes_path)?;
info!(
postcodes = postcode_data.postcodes.len(),
"Postcode boundaries loaded"
);
// Initialize tile reader
let tiles_path = &cli.tiles;
if !tiles_path.exists() {
bail!("PMTiles file not found: {}", tiles_path.display());
}
info!("Loading PMTiles from {}", tiles_path.display());
let tile_reader = Arc::new(routes::init_tile_reader(tiles_path).await?);
info!("PMTiles loaded successfully");
let min_keys: Vec<String> = property_data
.feature_names
.iter()
@ -165,12 +205,20 @@ async fn main() -> anyhow::Result<()> {
"Precomputed features response"
);
// Record data loading metrics
metrics::record_data_stats(
property_data.lat.len(),
poi_data.lat.len(),
postcode_data.postcodes.len(),
);
let state = Arc::new(AppState {
data: property_data,
grid,
h3_cells,
poi_data,
poi_grid,
postcode_data,
min_keys,
max_keys,
poi_category_groups,
@ -188,6 +236,7 @@ async fn main() -> anyhow::Result<()> {
let state_features = state.clone();
let state_hexagons = state.clone();
let state_postcodes = state.clone();
let state_pois = state.clone();
let state_poi_categories = state.clone();
let state_hexagon_properties = state.clone();
@ -204,6 +253,10 @@ async fn main() -> anyhow::Result<()> {
"/api/hexagons",
get(move |query| routes::get_hexagons(state_hexagons.clone(), query)),
)
.route(
"/api/postcodes",
get(move |query| routes::get_postcodes(state_postcodes.clone(), query)),
)
.route(
"/api/pois",
get(move |query| routes::get_pois(state_pois.clone(), query)),
@ -227,6 +280,22 @@ async fn main() -> anyhow::Result<()> {
get(move |query| routes::get_og_image(state_og_image.clone(), query)),
);
// Add tile routes
let reader_tile = tile_reader.clone();
let reader_style = tile_reader.clone();
let api = api
.route(
"/api/tiles/{z}/{x}/{y}",
get(move |path| routes::get_tile(axum::extract::State(reader_tile.clone()), path)),
)
.route(
"/api/tiles/style.json",
get(move |headers, query| {
routes::get_style(axum::extract::State(reader_style.clone()), headers, query)
}),
)
.route("/metrics", get(move || metrics::metrics_handler(metrics_handle.clone())));
let app = if frontend_dist.exists() {
api.fallback_service(ServeDir::new(&frontend_dist))
} else {
@ -234,6 +303,7 @@ async fn main() -> anyhow::Result<()> {
};
let app = app
.layer(middleware::from_fn(metrics::track_metrics))
.layer(middleware::from_fn(
move |req: axum::extract::Request, next: middleware::Next| {
let st = state_crawler.clone();

View file

@ -3,11 +3,15 @@ mod hexagon_stats;
pub(crate) mod hexagons;
mod og_image;
mod pois;
mod postcodes;
pub(crate) mod properties;
mod tiles;
pub use features::{build_features_response, get_features, FeaturesResponse};
pub use hexagon_stats::get_hexagon_stats;
pub use hexagons::get_hexagons;
pub use og_image::get_og_image;
pub use pois::{get_poi_categories, get_pois};
pub use postcodes::get_postcodes;
pub use properties::get_hexagon_properties;
pub use tiles::{get_style, get_tile, init_tile_reader};

View 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))
}

View file

@ -1,4 +1,4 @@
use crate::data::{POICategoryGroup, POIData, PropertyData};
use crate::data::{POICategoryGroup, POIData, PostcodeData, PropertyData};
use crate::routes::FeaturesResponse;
use crate::utils::GridIndex;
@ -10,6 +10,8 @@ pub struct AppState {
pub h3_cells: Vec<u64>,
pub poi_data: POIData,
pub poi_grid: GridIndex,
/// Postcode boundary data for high-zoom rendering
pub postcode_data: PostcodeData,
/// Precomputed JSON key names: "min_{feature_name}" for each feature
pub min_keys: Vec<String>,
/// Precomputed JSON key names: "max_{feature_name}" for each feature