perfect-postcode/server-rs/src/routes/postcode_stats.rs
Andras Schmelczer 4a0f00f2a4
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 8m43s
CI / Check (push) Failing after 8m49s
new demo mode & tenure
2026-06-17 07:54:30 +01:00

236 lines
8.7 KiB
Rust

use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use serde::Deserialize;
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::POSTCODE_SEARCH_OFFSET;
use crate::licensing::{check_license_point, resolve_share_code};
use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
use super::hexagon_stats::{
parse_area_stats_field_set, top_filter_exclusions, EnumFeatureStats, HexagonStatsResponse,
};
use super::stats;
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
#[derive(Deserialize)]
pub struct PostcodeStatsParams {
pub postcode: String,
pub filters: Option<String>,
/// `;;`-separated feature names to include in stats response.
/// Only listed features are computed. If absent, area stats default to
/// displayable groups; if empty, no feature stats are returned.
pub fields: Option<String>,
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
pub async fn get_postcode_stats(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<PostcodeStatsParams>,
) -> Result<Json<HexagonStatsResponse>, axum::response::Response> {
let state = shared.load_state();
let normalized = normalize_postcode(&params.postcode);
// Look up postcode centroid for spatial search
let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) {
Some(&idx) => idx,
None => {
warn!(postcode = %normalized, "Postcode not found");
return Err((
StatusCode::NOT_FOUND,
format!("Postcode not found: {}", normalized),
)
.into_response());
}
};
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
// License check using postcode centroid
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_point(
&user.0,
centroid_lat as f64,
centroid_lon as f64,
geo.free_zone,
share_bounds,
)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
&state.data.poi_metrics.name_to_index,
&poi_quant,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len() + parsed_poi_filters.len();
let filters_str = params.filters;
let has_poi_filters = !parsed_poi_filters.is_empty();
let (fields_specified, field_set) = parse_area_stats_field_set(params.fields.as_deref());
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let postcode_str = normalized;
let response = tokio::task::spawn_blocking(move || {
let start_time = std::time::Instant::now();
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
let has_travel = !travel_entries.is_empty();
// Search around centroid (generous for a postcode)
let offset: f64 = POSTCODE_SEARCH_OFFSET;
let min_lat = centroid_lat as f64 - offset;
let max_lat = centroid_lat as f64 + offset;
let min_lon = centroid_lon as f64 - offset;
let max_lon = centroid_lon as f64 + offset;
let mut area_rows: Vec<usize> = Vec::new();
let mut matching_rows: Vec<usize> = Vec::new();
state
.grid
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
let row = row_idx as usize;
let row_postcode = state.data.postcode(row);
if row_postcode != postcode_str {
return;
}
area_rows.push(row);
if row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
) && (!has_poi_filters
|| row_passes_poi_filters(row, &parsed_poi_filters, &state.data.poi_metrics))
{
if has_travel
&& !row_passes_travel_filters(row_postcode, &travel_entries, &travel_data)
{
return;
}
matching_rows.push(row);
}
});
let total_count = matching_rows.len();
let filter_exclusions = if total_count == 0 {
top_filter_exclusions(
&area_rows,
&parsed_filters,
&parsed_enum_filters,
&parsed_poi_filters,
&travel_entries,
&travel_data,
&state.data,
)
} else {
Vec::new()
};
let price_history =
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
let crime_by_year = stats::compute_crime_by_year(
&matching_rows,
&state.data,
&state.crime_by_year,
fields_specified,
&field_set,
);
let (mut numeric_features, mut enum_features_out) = stats::compute_feature_stats(
&matching_rows,
&state.data,
&state.data.feature_names,
&state.data.enum_values,
&state.data.feature_stats,
fields_specified,
&field_set,
);
numeric_features.extend(stats::compute_poi_feature_stats(
&matching_rows,
&state.data.poi_metrics,
fields_specified,
&field_set,
));
// The council-house count is an attribute of the postcode itself, not of
// the currently filter-matching subset: recompute it over every property
// in the postcode so toggling filters never changes it. (When no filters
// are applied, `area_rows == matching_rows` and this is a no-op.)
if let Some(&council_idx) = state
.feature_name_to_index
.get(crate::consts::FORMER_COUNCIL_HOUSE_FEATURE)
{
enum_features_out.retain(|f| f.name != crate::consts::FORMER_COUNCIL_HOUSE_FEATURE);
let included = !fields_specified
|| field_set.contains(crate::consts::FORMER_COUNCIL_HOUSE_FEATURE);
if included {
if let Some(counts) =
stats::compute_enum_feature_counts(&area_rows, &state.data, council_idx)
{
if !counts.is_empty() {
enum_features_out.push(EnumFeatureStats {
name: crate::consts::FORMER_COUNCIL_HOUSE_FEATURE.to_string(),
counts,
});
}
}
}
}
let elapsed = start_time.elapsed();
info!(
postcode = %postcode_str,
total_count,
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
travel_entries = travel_entries.len(),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/postcode-stats"
);
let crime_latest_year = if crime_by_year.is_empty() {
None
} else {
stats::crime_latest_available_year(&state.crime_by_year)
};
Ok(HexagonStatsResponse {
count: total_count,
numeric_features,
enum_features: enum_features_out,
price_history,
crime_by_year,
crime_latest_year,
central_postcode: None,
filter_exclusions,
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(response))
}