1139 lines
50 KiB
Rust
1139 lines
50 KiB
Rust
//! Static feature configuration. Every numeric and enum column in wide.parquet
|
|
//! must be declared here. Unknown columns cause a startup panic.
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub enum Bounds {
|
|
/// Fixed min/max values for the slider
|
|
Fixed { min: f32, max: f32 },
|
|
/// Compute percentile from data at startup
|
|
Percentile { low: f64, high: f64 },
|
|
}
|
|
|
|
pub struct FeatureConfig {
|
|
/// Must match parquet column name exactly (also used as display label)
|
|
pub name: &'static str,
|
|
pub bounds: Bounds,
|
|
/// Slider step size. Controls the granularity of the range slider in the UI.
|
|
pub step: f32,
|
|
/// Short one-line description shown in the filter sidebar
|
|
pub description: &'static str,
|
|
/// Longer description explaining methodology, data source, and caveats
|
|
pub detail: &'static str,
|
|
/// Data source slug for linking to /learn#<slug>
|
|
pub source: &'static str,
|
|
/// Display prefix (e.g. "£")
|
|
pub prefix: &'static str,
|
|
/// Display suffix (e.g. " mins")
|
|
pub suffix: &'static str,
|
|
/// If true, show full integer (no k/M abbreviation)
|
|
pub raw: bool,
|
|
/// If true, the slider uses absolute min/max/step instead of percentile scaling
|
|
pub absolute: bool,
|
|
}
|
|
|
|
/// Features whose histogram bins should be exactly 1 unit wide (one per integer).
|
|
/// p1/p99 are snapped to integer boundaries before binning.
|
|
pub const INTEGER_BIN_FEATURES: &[&str] = &["Number of bedrooms & living rooms"];
|
|
|
|
pub struct EnumFeatureConfig {
|
|
pub name: &'static str,
|
|
/// If set, values are presented in this order instead of alphabetical.
|
|
/// Values not listed are appended alphabetically after the ordered ones.
|
|
pub order: Option<&'static [&'static str]>,
|
|
/// Short one-line description shown in the filter sidebar
|
|
pub description: &'static str,
|
|
/// Longer description explaining methodology, data source, and caveats
|
|
pub detail: &'static str,
|
|
/// Data source slug for linking to /learn#<slug>
|
|
pub source: &'static str,
|
|
}
|
|
|
|
/// Wrapper enum allowing numeric and enum features to be interleaved
|
|
/// in a single ordered array. The position in the array determines
|
|
/// the display order on the frontend.
|
|
pub enum Feature {
|
|
Numeric(FeatureConfig),
|
|
Enum(EnumFeatureConfig),
|
|
}
|
|
|
|
pub struct FeatureGroup {
|
|
pub name: &'static str,
|
|
pub features: &'static [Feature],
|
|
}
|
|
|
|
pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|
FeatureGroup {
|
|
name: "Properties",
|
|
features: &[
|
|
Feature::Enum(EnumFeatureConfig {
|
|
name: "Property type",
|
|
order: Some(&["Detached", "Semi-Detached", "Terraced", "Flats/Maisonettes", "Other"]),
|
|
description: "Type of property: detached, semi-detached, terraced, flat/maisonette, or other",
|
|
detail: "From HM Land Registry Price Paid data and EPC certificates. Detached, Semi-Detached, Terraced (includes all terrace sub-types), Flats/Maisonettes, or Other (bungalows, park homes, etc.).",
|
|
source: "price-paid",
|
|
}),
|
|
Feature::Enum(EnumFeatureConfig {
|
|
name: "Leasehold/Freehold",
|
|
order: Some(&["Freehold", "Leasehold"]),
|
|
description: "Whether the property is leasehold or freehold",
|
|
detail: "From HM Land Registry Price Paid data. Freehold means you own the building and the land it stands on. Leasehold means you own the building but not the land: you have a lease from the freeholder for a set number of years.",
|
|
source: "price-paid",
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Total floor area (sqm)",
|
|
bounds: Bounds::Percentile {
|
|
low: 0.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Internal floor area from the EPC survey",
|
|
detail: "Total useful floor area in square metres as measured during the Energy Performance Certificate assessment. Includes all habitable rooms but excludes garages, outbuildings, and external areas.",
|
|
source: "epc",
|
|
prefix: "",
|
|
suffix: " sqm",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Number of bedrooms & living rooms",
|
|
bounds: Bounds::Fixed {
|
|
min: 1.0,
|
|
max: 12.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Count of habitable rooms from the EPC survey",
|
|
detail: "Total number of habitable rooms (bedrooms plus living rooms) as recorded in the Energy Performance Certificate. Kitchens and bathrooms are typically excluded unless they are large enough to count as habitable rooms.",
|
|
source: "epc",
|
|
prefix: "",
|
|
suffix: " rooms",
|
|
raw: false,
|
|
absolute: true,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Construction year",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 2026.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Estimated year of construction from the EPC",
|
|
detail: "Derived from the construction age band in the EPC (e.g. '1930-1949') by taking the midpoint. Less precise for older buildings where the age band spans several decades.",
|
|
source: "epc",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: true,
|
|
absolute: false,
|
|
}),
|
|
Feature::Enum(EnumFeatureConfig {
|
|
name: "Former council house",
|
|
order: Some(&["Yes", "No"]),
|
|
description: "Whether the property was ever recorded as social housing",
|
|
detail: "Derived from the TENURE field in Energy Performance Certificate data. If any EPC certificate for this property recorded the tenure as social rental, it indicates the property was council or housing-association stock at the time of that inspection. Properties that were later sold (e.g. via Right to Buy) retain this flag.",
|
|
source: "epc",
|
|
}),
|
|
Feature::Enum(EnumFeatureConfig {
|
|
name: "Current energy rating",
|
|
order: Some(&["A", "B", "C", "D", "E", "F", "G"]),
|
|
description: "Current EPC energy efficiency rating (A = best, G = worst)",
|
|
detail: "The current energy efficiency rating from the Energy Performance Certificate. Ranges from A (most efficient) to G (least efficient). Based on the property's energy use per square metre of floor area.",
|
|
source: "epc",
|
|
}),
|
|
Feature::Enum(EnumFeatureConfig {
|
|
name: "Potential energy rating",
|
|
order: Some(&["A", "B", "C", "D", "E", "F", "G"]),
|
|
description: "Potential EPC rating if all recommended improvements were made",
|
|
detail: "The potential energy efficiency rating from the Energy Performance Certificate if all cost-effective improvements recommended in the EPC report were carried out. Ranges from A (most efficient) to G (least efficient).",
|
|
source: "epc",
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Interior height (m)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 0.1,
|
|
description: "Average storey height from the EPC survey",
|
|
detail: "Average internal floor-to-ceiling height in metres as recorded during the Energy Performance Certificate assessment. Calculated by dividing the total internal volume by the total floor area.",
|
|
source: "epc",
|
|
prefix: "",
|
|
suffix: " m",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
],
|
|
},
|
|
FeatureGroup {
|
|
name: "Defining characteristics",
|
|
features: &[
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Street tree density percentile",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Estimated tree canopy coverage percentile for the property's postcode",
|
|
detail: "Approximate tree canopy coverage around the postcode centroid, derived from Forest Research's 2025 Trees Outside Woodland map. Tree canopy polygons for lone trees and groups of trees are counted within 50m of each postcode centroid, then converted to a percentile across English postcodes. This is a postcode-centroid proxy, not an exact property or street-segment measurement.",
|
|
source: "forest-research-tow",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: true,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Noise (dB)",
|
|
bounds: Bounds::Fixed {
|
|
min: 50.0,
|
|
max: 80.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Maximum transport noise level near the postcode in decibels (Lden)",
|
|
detail: "Maximum road, rail, or airport noise level in decibels (Lden, a 24-hour weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.",
|
|
source: "noise",
|
|
prefix: "",
|
|
suffix: " dB",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
],
|
|
},
|
|
FeatureGroup {
|
|
name: "Property prices",
|
|
features: &[
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Estimated current price",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 2_500_000.0,
|
|
},
|
|
step: 10000.0,
|
|
description: "Modelled estimate of the current property value",
|
|
detail: "Based on the last sale price, local repeat-sales price movement, and nearby recently sold properties. The repeat-sales index is tracked by postcode sector and property type, with smoothing and neighbour blending where data is sparse. Recent sales stay close to the recorded price; older sales depend more on the model.",
|
|
source: "price-paid",
|
|
prefix: "£",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: true,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Est. price per sqm",
|
|
bounds: Bounds::Percentile {
|
|
low: 0.0,
|
|
high: 98.0,
|
|
},
|
|
step: 100.0,
|
|
description: "Estimated current price divided by total floor area",
|
|
detail: "Calculated by dividing the modelled estimated current price by the total floor area from the EPC certificate. Provides a more up-to-date price-per-area comparison than the historical sale price per sqm.",
|
|
source: "price-paid",
|
|
prefix: "£",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Estimated monthly rent",
|
|
bounds: Bounds::Percentile { low: 2.0, high: 98.0 },
|
|
step: 25.0,
|
|
description: "Mean monthly private rent for the local area",
|
|
detail: "Mean monthly rental price from ONS Price Index of Private Rents (PIPR), matched by local authority and bedroom count.",
|
|
source: "ons-rental",
|
|
prefix: "£",
|
|
suffix: "/mo",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Last known price",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 2_500_000.0,
|
|
},
|
|
step: 10000.0,
|
|
description: "Most recent sale price from the Land Registry",
|
|
detail: "The last recorded sale price for this property from HM Land Registry Price Paid data. Covers residential sales in England. May be years old if the property hasn't sold recently.",
|
|
source: "price-paid",
|
|
prefix: "£",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: true,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Price per sqm",
|
|
bounds: Bounds::Percentile {
|
|
low: 0.0,
|
|
high: 98.0,
|
|
},
|
|
step: 100.0,
|
|
description: "Sale price divided by total floor area",
|
|
detail: "Calculated by dividing the last known sale price by the total floor area from the EPC certificate. Useful for comparing value across different-sized properties. Only available where both price and floor area data exist.",
|
|
source: "price-paid",
|
|
prefix: "£",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Date of last transaction",
|
|
bounds: Bounds::Fixed {
|
|
min: 1995.0,
|
|
max: 2026.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Date of the most recent sale from the Land Registry",
|
|
detail: "The date of the most recent recorded sale for this property from HM Land Registry Price Paid data. Stored as a datetime in the data; converted to fractional year for filtering and charting.",
|
|
source: "price-paid",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: true,
|
|
absolute: false,
|
|
}),
|
|
],
|
|
},
|
|
FeatureGroup {
|
|
name: "Schools",
|
|
features: &[
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Good+ primary schools within 2km",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 10.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Primary schools rated Good or Outstanding by Ofsted within 2km",
|
|
detail: "State-funded primary schools within 2km with a current Ofsted rating of Good or Outstanding. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Good+ secondary schools within 2km",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 5.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Secondary schools rated Good or Outstanding by Ofsted within 2km",
|
|
detail: "State-funded secondary schools within 2km with a current Ofsted rating of Good or Outstanding. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Outstanding primary schools within 2km",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 10.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Primary schools rated Outstanding by Ofsted within 2km",
|
|
detail: "State-funded primary schools within 2km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Outstanding secondary schools within 2km",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 5.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Secondary schools rated Outstanding by Ofsted within 2km",
|
|
detail: "State-funded secondary schools within 2km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Good+ primary schools within 5km",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 30.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Primary schools rated Good or Outstanding by Ofsted within 5km",
|
|
detail: "State-funded primary schools within 5km with a current Ofsted rating of Good or Outstanding. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Good+ secondary schools within 5km",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 15.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Secondary schools rated Good or Outstanding by Ofsted within 5km",
|
|
detail: "State-funded secondary schools within 5km with a current Ofsted rating of Good or Outstanding. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Outstanding primary schools within 5km",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 30.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Primary schools rated Outstanding by Ofsted within 5km",
|
|
detail: "State-funded primary schools within 5km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Outstanding secondary schools within 5km",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 15.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Secondary schools rated Outstanding by Ofsted within 5km",
|
|
detail: "State-funded secondary schools within 5km with a current Ofsted rating of Outstanding. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Education, Skills and Training Score",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Education and skills deprivation percentile (higher = less deprived)",
|
|
detail: "From the English Indices of Deprivation, converted to a national percentile where 0% is most deprived and 100% is least deprived. Covers school attainment, entry to higher education, adult qualifications, and English language proficiency.",
|
|
source: "iod",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: true,
|
|
absolute: true,
|
|
}),
|
|
],
|
|
},
|
|
FeatureGroup {
|
|
name: "Area development",
|
|
features: &[
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Income Score",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Income deprivation percentile (higher = less deprived)",
|
|
detail: "From the English Indices of Deprivation, converted to a national percentile where 0% is most income deprived and 100% is least income deprived. Based on Income Support, income-based Jobseeker's Allowance, income-based Employment and Support Allowance, Pension Credit, Working Tax Credit and Child Tax Credit, Universal Credit, and asylum seekers.",
|
|
source: "iod",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: true,
|
|
absolute: true,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Employment Score",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Employment deprivation percentile (higher = less deprived)",
|
|
detail: "From the English Indices of Deprivation, converted to a national percentile where 0% is most employment deprived and 100% is least employment deprived. Based on claimants of Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance, and relevant Universal Credit claimants.",
|
|
source: "iod",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: true,
|
|
absolute: true,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Health Deprivation and Disability Score",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Health and disability deprivation percentile (higher = better outcomes)",
|
|
detail: "From the English Indices of Deprivation, converted to a national percentile where 0% is most health deprived and 100% is least health deprived. Derived from years of potential life lost, comparative illness and disability ratio, acute morbidity, and mood and anxiety disorders.",
|
|
source: "iod",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: true,
|
|
absolute: true,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Housing Conditions Score",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Housing conditions percentile (higher = better conditions)",
|
|
detail: "From the English Indices of Deprivation, Living Environment domain, converted to a national percentile where 0% is most deprived and 100% is least deprived. Measures the quality of housing stock: central heating availability, housing condition, and Decent Homes standards.",
|
|
source: "iod",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: true,
|
|
absolute: true,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Air Quality and Road Safety Score",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Air quality and road safety percentile (higher = better conditions)",
|
|
detail: "From the English Indices of Deprivation, Living Environment domain, converted to a national percentile where 0% is most deprived and 100% is least deprived. Measures the outdoor living environment through air quality indicators and road traffic accident casualties involving pedestrians and cyclists.",
|
|
source: "iod",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: true,
|
|
absolute: true,
|
|
}),
|
|
],
|
|
},
|
|
FeatureGroup {
|
|
name: "Crime",
|
|
features: &[
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Serious crime per 1k residents (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 0.1,
|
|
description: "Serious crime rate per 1,000 residents per year",
|
|
detail: "Violence, robbery, burglary, and weapons possession per 1,000 usual residents per year in the LSOA. Uses police.uk street-level crime data and Census 2021 population counts. Normalises for population density so areas are comparable regardless of size.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Minor crime per 1k residents (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 0.1,
|
|
description: "Minor crime rate per 1,000 residents per year",
|
|
detail: "Anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per 1,000 usual residents per year in the LSOA. Uses police.uk street-level crime data and Census 2021 population counts. Normalises for population density so areas are comparable regardless of size.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Serious crime (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Aggregate of serious crime categories per year",
|
|
detail: "Sum of violence, robbery, burglary, and weapons possession per year in the LSOA, from police.uk street-level crime data. Provides a single serious crime metric.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Minor crime (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Aggregate of minor crime categories per year",
|
|
detail: "Sum of anti-social behaviour, shoplifting, bicycle theft, and other lower-severity crime per year in the LSOA, from police.uk street-level crime data. Provides a single minor crime metric.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Violence and sexual offences (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly violent and sexual offences in the area",
|
|
detail: "Average number of violence and sexual offences per year in the LSOA, from police.uk street-level crime data. Includes assault, harassment, and sexual offences.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Burglary (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly burglary offences in the area",
|
|
detail: "Average number of burglary offences per year in the LSOA, from police.uk street-level crime data. Includes residential and commercial burglary.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Robbery (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly robbery offences in the area",
|
|
detail: "Average number of robbery offences per year in the LSOA, from police.uk street-level crime data. Robbery involves theft with force or threat of force.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Vehicle crime (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly vehicle crime in the area",
|
|
detail: "Average number of vehicle crime incidents per year in the LSOA, from police.uk street-level crime data. Includes theft of and from vehicles.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Anti-social behaviour (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly anti-social behaviour incidents in the area",
|
|
detail: "Average number of anti-social behaviour incidents per year in the LSOA, from police.uk street-level crime data. Includes nuisance, environmental, and personal anti-social behaviour.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Criminal damage and arson (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly criminal damage and arson in the area",
|
|
detail: "Average number of criminal damage and arson incidents per year in the LSOA, from police.uk street-level crime data.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Other theft (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly other theft offences in the area",
|
|
detail: "Average number of 'other theft' offences per year in the LSOA, from police.uk street-level crime data. Includes theft not classified under burglary, vehicle crime, shoplifting, or bicycle theft.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Theft from the person (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly theft from the person in the area",
|
|
detail: "Average number of theft from the person offences per year in the LSOA, from police.uk street-level crime data. Includes pickpocketing and bag snatching without force.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Shoplifting (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly shoplifting offences in the area",
|
|
detail: "Average number of shoplifting offences per year in the LSOA, from police.uk street-level crime data.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Bicycle theft (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly bicycle theft in the area",
|
|
detail: "Average number of bicycle theft offences per year in the LSOA, from police.uk street-level crime data.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Drugs (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly drug offences in the area",
|
|
detail: "Average number of drug offences per year in the LSOA, from police.uk street-level crime data. Includes possession and trafficking offences.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Possession of weapons (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly weapons possession offences in the area",
|
|
detail: "Average number of possession of weapons offences per year in the LSOA, from police.uk street-level crime data.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Public order (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly public order offences in the area",
|
|
detail: "Average number of public order offences per year in the LSOA, from police.uk street-level crime data. Includes causing fear, alarm, or distress.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Other crime (avg/yr)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Average yearly other crime in the area",
|
|
detail: "Average number of other crime offences per year in the LSOA, from police.uk street-level crime data. A catch-all category for offences not classified elsewhere.",
|
|
source: "crime",
|
|
prefix: "",
|
|
suffix: "/yr",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
],
|
|
},
|
|
FeatureGroup {
|
|
name: "Neighbours",
|
|
features: &[
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Median age",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 0.5,
|
|
description: "Median age of the local population",
|
|
detail: "From the 2021 Census (TS007A). Median age of usual residents in the LSOA, computed by linear interpolation from five-year age band counts. Areas with younger populations tend to be urban, university towns, or have more families; older medians are typical in rural and coastal areas.",
|
|
source: "census-2021",
|
|
prefix: "",
|
|
suffix: " years",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% White",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Percentage of population identifying as White",
|
|
detail: "From the 2021 Census. Percentage of the local authority population identifying as White (English, Welsh, Scottish, Northern Irish, British, Irish, Gypsy or Irish Traveller, Roma, or any other White background).",
|
|
source: "ethnicity",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% South Asian",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 0.1,
|
|
description: "Percentage of population identifying as South Asian",
|
|
detail: "From the 2021 Census. Percentage of the local authority population identifying as Indian, Pakistani, Bangladeshi, or any other Asian background.",
|
|
source: "ethnicity",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% Black",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 0.1,
|
|
description: "Percentage of population identifying as Black",
|
|
detail: "From the 2021 Census. Percentage of the local authority population identifying as Black, Black British, Caribbean, or African.",
|
|
source: "ethnicity",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% East Asian",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 0.1,
|
|
description: "Percentage of population identifying as East Asian",
|
|
detail: "From the 2021 Census. Percentage of the local authority population identifying as Chinese.",
|
|
source: "ethnicity",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% Mixed",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 0.1,
|
|
description: "Percentage of population identifying as Mixed or Multiple ethnic groups",
|
|
detail: "From the 2021 Census. Percentage of the local authority population identifying as Mixed or Multiple ethnic groups (White and Black Caribbean, White and Black African, White and Asian, or any other Mixed or Multiple background).",
|
|
source: "ethnicity",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% Other",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 0.1,
|
|
description: "Percentage of population identifying as Other ethnic group",
|
|
detail: "From the 2021 Census. Percentage of the local authority population identifying as Other ethnic group (Arab or any other ethnic group not covered by the main categories).",
|
|
source: "ethnicity",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% Labour",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Labour vote share in the 2024 General Election",
|
|
detail: "Percentage of valid votes cast for the Labour Party in the constituency covering this postcode, from the July 2024 UK General Election. Includes votes for all Labour candidates where multiple stood.",
|
|
source: "election-results",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% Conservative",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Conservative vote share in the 2024 General Election",
|
|
detail: "Percentage of valid votes cast for the Conservative Party in the constituency covering this postcode, from the July 2024 UK General Election.",
|
|
source: "election-results",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% Liberal Democrat",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Liberal Democrat vote share in the 2024 General Election",
|
|
detail: "Percentage of valid votes cast for the Liberal Democrats in the constituency covering this postcode, from the July 2024 UK General Election.",
|
|
source: "election-results",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% Reform UK",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Reform UK vote share in the 2024 General Election",
|
|
detail: "Percentage of valid votes cast for Reform UK in the constituency covering this postcode, from the July 2024 UK General Election.",
|
|
source: "election-results",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% Green",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Green Party vote share in the 2024 General Election",
|
|
detail: "Percentage of valid votes cast for the Green Party in the constituency covering this postcode, from the July 2024 UK General Election.",
|
|
source: "election-results",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "% Other parties",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Combined vote share of all other parties and independents",
|
|
detail: "Percentage of valid votes cast for parties other than Labour, Conservative, Liberal Democrat, Reform UK, and Green in the constituency covering this postcode. Includes independents, the Speaker, and smaller parties.",
|
|
source: "election-results",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Voter turnout (%)",
|
|
bounds: Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
},
|
|
step: 0.5,
|
|
description:
|
|
"Percentage of registered voters who voted in the 2024 General Election",
|
|
detail: "The proportion of the registered electorate who cast a valid vote in the July 2024 UK General Election. Calculated as valid votes divided by electorate size. Higher turnout generally correlates with more affluent areas and closer contests.",
|
|
source: "election-results",
|
|
prefix: "",
|
|
suffix: "%",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
],
|
|
},
|
|
FeatureGroup {
|
|
name: "Amenities",
|
|
features: &[
|
|
Feature::Enum(EnumFeatureConfig {
|
|
name: "Max available download speed (Mbps)",
|
|
order: Some(&["10", "30", "100", "300", "1000"]),
|
|
description: "Maximum broadband download speed available at the postcode",
|
|
detail: "Maximum fixed broadband download speed available from any provider, from Ofcom Connected Nations 2025. Represents theoretical maximum, not achieved speeds. 10 Mbps = basic, 30 = superfast, 100+ = ultrafast, 1000 = gigabit.",
|
|
source: "broadband",
|
|
}),
|
|
],
|
|
},
|
|
];
|
|
|
|
/// Flat ordered list of all numeric feature names (follows group order).
|
|
pub fn all_numeric_feature_names() -> Vec<&'static str> {
|
|
FEATURE_GROUPS
|
|
.iter()
|
|
.flat_map(|group| group.features.iter())
|
|
.filter_map(|feature| match feature {
|
|
Feature::Numeric(c) => Some(c.name),
|
|
Feature::Enum(_) => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Flat ordered list of all enum feature names (follows group order).
|
|
pub fn all_enum_feature_names() -> Vec<&'static str> {
|
|
FEATURE_GROUPS
|
|
.iter()
|
|
.flat_map(|group| group.features.iter())
|
|
.filter_map(|feature| match feature {
|
|
Feature::Enum(c) => Some(c.name),
|
|
Feature::Numeric(_) => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Look up the configured value order for an enum feature by name.
|
|
pub fn order_for(name: &str) -> Option<&'static [&'static str]> {
|
|
FEATURE_GROUPS
|
|
.iter()
|
|
.flat_map(|group| group.features.iter())
|
|
.find_map(|feature| match feature {
|
|
Feature::Enum(c) if c.name == name => Some(c.order),
|
|
_ => None,
|
|
})
|
|
.flatten()
|
|
}
|
|
|
|
/// Whether this feature should use integer-width histogram bins.
|
|
pub fn has_integer_bins(name: &str) -> bool {
|
|
INTEGER_BIN_FEATURES.contains(&name) || dynamic_poi_count_radius(name).is_some()
|
|
}
|
|
|
|
/// Look up the Bounds config for a numeric feature by name.
|
|
pub fn bounds_for(name: &str) -> Option<Bounds> {
|
|
if dynamic_poi_distance_category(name).is_some() {
|
|
return Some(Bounds::Percentile {
|
|
low: 2.0,
|
|
high: 98.0,
|
|
});
|
|
}
|
|
if dynamic_poi_count_radius(name).is_some() {
|
|
return Some(Bounds::Percentile {
|
|
low: 5.0,
|
|
high: 95.0,
|
|
});
|
|
}
|
|
|
|
FEATURE_GROUPS
|
|
.iter()
|
|
.flat_map(|group| group.features.iter())
|
|
.find_map(|feature| match feature {
|
|
Feature::Numeric(c) if c.name == name => Some(c.bounds),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
pub fn dynamic_poi_distance_category(name: &str) -> Option<&str> {
|
|
name.strip_prefix("Distance to nearest amenity (")
|
|
.and_then(|rest| rest.strip_suffix(") (km)"))
|
|
.filter(|category| !category.is_empty())
|
|
}
|
|
|
|
pub fn dynamic_poi_count_radius(name: &str) -> Option<u8> {
|
|
let rest = name.strip_prefix("Number of amenities (")?;
|
|
let (_category, suffix) = rest.rsplit_once(") within ")?;
|
|
match suffix {
|
|
"2km" => Some(2),
|
|
"5km" => Some(5),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn dynamic_poi_count_category(name: &str) -> Option<&str> {
|
|
let rest = name.strip_prefix("Number of amenities (")?;
|
|
let (category, suffix) = rest.rsplit_once(") within ")?;
|
|
matches!(suffix, "2km" | "5km")
|
|
.then_some(category)
|
|
.filter(|category| !category.is_empty())
|
|
}
|
|
|
|
pub fn is_dynamic_poi_feature(name: &str) -> bool {
|
|
dynamic_poi_distance_category(name).is_some() || dynamic_poi_count_category(name).is_some()
|
|
}
|
|
|
|
pub fn dynamic_poi_feature_sort_key(name: &str) -> (u8, String) {
|
|
if let Some(category) = dynamic_poi_distance_category(name) {
|
|
return (0, category.to_ascii_lowercase());
|
|
}
|
|
if let Some(category) = dynamic_poi_count_category(name) {
|
|
let metric_order = match dynamic_poi_count_radius(name) {
|
|
Some(2) => 1,
|
|
Some(5) => 2,
|
|
_ => 3,
|
|
};
|
|
return (metric_order, category.to_ascii_lowercase());
|
|
}
|
|
(9, name.to_ascii_lowercase())
|
|
}
|
|
|
|
/// Canonical display order for POI category groups.
|
|
/// The server will panic at startup if the data contains groups not in this list or vice versa.
|
|
pub const POI_GROUP_ORDER: &[&str] = &[
|
|
"Public Transport",
|
|
"Groceries",
|
|
"Leisure",
|
|
"Education",
|
|
"Health",
|
|
"Emergency Services",
|
|
"Other",
|
|
"Local Businesses",
|
|
"Culture",
|
|
"Services",
|
|
"Shops",
|
|
];
|