1086 lines
50 KiB
Rust
1086 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: "Tree canopy 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: "Loudest of road, rail, or airport noise in decibels (Lden, a 24-hour day-evening-night weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Covers England only; rail noise dominates the value at ~120k postcodes and airport noise at ~4k. Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Blank means no mapped data in the source (Wales, Scotland and areas away from major roads/railways/airports all return blank) — not necessarily quiet. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.",
|
|
source: "noise",
|
|
prefix: "",
|
|
suffix: " dB",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Enum(EnumFeatureConfig {
|
|
name: "Within conservation area",
|
|
order: Some(&["Yes", "No"]),
|
|
description: "Whether the postcode point falls inside a designated conservation area",
|
|
detail: "Planning Data conservation area boundaries, matched to the postcode representative point. The national dataset is a work in progress and may include duplicates or incomplete local coverage, so boundary-sensitive decisions should be checked with the local planning authority.",
|
|
source: "conservation-areas",
|
|
}),
|
|
Feature::Enum(EnumFeatureConfig {
|
|
name: "Listed building",
|
|
order: Some(&["Yes", "No"]),
|
|
description: "Whether this property appears to match a Historic England listed building entry",
|
|
detail: "Historic England National Heritage List for England listed-building points, matched conservatively to property addresses using the listed-entry name and nearby postcode candidates. Treat this as a screening signal, not a legal determination: verify any specific property on the NHLE and with the local planning authority.",
|
|
source: "listed-buildings",
|
|
}),
|
|
],
|
|
},
|
|
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 school catchments",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 15.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Primary schools rated Good or Outstanding whose modelled catchment area covers this postcode",
|
|
detail: "How many state-funded primary schools with a current Ofsted rating of Good or Outstanding draw their pupils from an area covering this postcode. Catchment radii are modelled by simulating England's distance-based admissions (each school's places against the local child population, Census 2021) and calibrated against published 'last distance offered' figures; they are estimates, not official admission areas. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Good+ secondary school catchments",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 11.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Secondary schools rated Good or Outstanding whose modelled catchment area covers this postcode",
|
|
detail: "How many state-funded secondary schools with a current Ofsted rating of Good or Outstanding draw their pupils from an area covering this postcode. Catchment radii are modelled by simulating England's distance-based admissions (each school's places against the local child population, Census 2021) and calibrated against published 'last distance offered' figures; they are estimates, not official admission areas. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Outstanding primary school catchments",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 8.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Primary schools rated Outstanding whose modelled catchment area covers this postcode",
|
|
detail: "How many state-funded primary schools with a current Ofsted rating of Outstanding draw their pupils from an area covering this postcode. Catchment radii are modelled by simulating England's distance-based admissions (each school's places against the local child population, Census 2021) and calibrated against published 'last distance offered' figures; they are estimates, not official admission areas. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
Feature::Numeric(FeatureConfig {
|
|
name: "Outstanding secondary school catchments",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 4.0,
|
|
},
|
|
step: 1.0,
|
|
description: "Secondary schools rated Outstanding whose modelled catchment area covers this postcode",
|
|
detail: "How many state-funded secondary schools with a current Ofsted rating of Outstanding draw their pupils from an area covering this postcode. Catchment radii are modelled by simulating England's distance-based admissions (each school's places against the local child population, Census 2021) and calibrated against published 'last distance offered' figures; they are estimates, not official admission areas. Schools not yet inspected are excluded.",
|
|
source: "ofsted",
|
|
prefix: "",
|
|
suffix: "",
|
|
raw: false,
|
|
absolute: false,
|
|
}),
|
|
],
|
|
},
|
|
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: "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,
|
|
}),
|
|
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 (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 within 50m of the postcode, counted from police.uk street-level crime points (anonymised, snapped to nearby map points). 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 within 50m of the postcode, counted from police.uk street-level crime points (anonymised, snapped to nearby map points). 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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 within 50m of the postcode, 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: "% SE Asian",
|
|
bounds: Bounds::Fixed {
|
|
min: 0.0,
|
|
max: 100.0,
|
|
},
|
|
step: 0.1,
|
|
description: "Percentage of population identifying as Southeast Asian",
|
|
detail: "From the 2021 Census. Percentage of the local authority population identifying as another (non-Chinese) East or Southeast Asian background, e.g. Vietnamese, Filipino, or Thai.",
|
|
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::Numeric(FeatureConfig {
|
|
name: "Max available download speed (Mbps)",
|
|
bounds: Bounds::Fixed {
|
|
min: 10.0,
|
|
max: 1000.0,
|
|
},
|
|
step: 1.0,
|
|
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. Null where no availability data is published.",
|
|
source: "broadband",
|
|
prefix: "",
|
|
suffix: " Mbps",
|
|
raw: true,
|
|
absolute: true,
|
|
}),
|
|
],
|
|
},
|
|
];
|
|
|
|
/// 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",
|
|
];
|