alright
This commit is contained in:
parent
c645b0f1d4
commit
39ef5c6646
79 changed files with 5660 additions and 2199 deletions
|
|
@ -13,12 +13,11 @@ use crate::consts::NAN_U16;
|
|||
use crate::data::ActualListing;
|
||||
use crate::licensing::{check_license_bounds, resolve_share_code};
|
||||
use crate::parsing::{
|
||||
parse_filters_with_poi, require_bounds, row_passes_filters, row_passes_poi_filters,
|
||||
ParsedEnumFilter, ParsedFilter,
|
||||
parse_filters_with_poi, require_bounds, ParsedEnumFilter, ParsedFilter, ParsedPoiFilter,
|
||||
};
|
||||
use crate::state::{AppState, SharedState};
|
||||
|
||||
use super::travel_time::{parse_optional_travel, row_passes_travel_filters, TravelEntry};
|
||||
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ActualListingsParams {
|
||||
|
|
@ -41,17 +40,6 @@ pub struct ActualListingsResponse {
|
|||
pub truncated: bool,
|
||||
}
|
||||
|
||||
const LISTING_LEVEL_FILTER_FEATURES: &[&str] = &[
|
||||
"Property type",
|
||||
"Leasehold/Freehold",
|
||||
"Total floor area (sqm)",
|
||||
"Number of bedrooms & living rooms",
|
||||
"Estimated current price",
|
||||
"Last known price",
|
||||
"Est. price per sqm",
|
||||
"Price per sqm",
|
||||
];
|
||||
|
||||
const KEEP_UNKNOWN_LISTING_FILTER_FEATURES: &[&str] = &["Total floor area (sqm)"];
|
||||
|
||||
pub async fn get_actual_listings(
|
||||
|
|
@ -90,38 +78,23 @@ pub async fn get_actual_listings(
|
|||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||
.map_err(|err| ApiError::BadRequest(err).into_response())?;
|
||||
|
||||
let listing_level_feature_idxs = listing_level_filter_feature_idxs(&state);
|
||||
let keep_unknown_listing_filter_idxs = keep_unknown_listing_filter_feature_idxs(&state);
|
||||
let (listing_filters, postcode_filters) =
|
||||
split_numeric_filters(parsed_filters, &listing_level_feature_idxs);
|
||||
let (listing_enum_filters, postcode_enum_filters) =
|
||||
split_enum_filters(parsed_enum_filters, &listing_level_feature_idxs);
|
||||
let listing_filters = parsed_filters;
|
||||
let listing_enum_filters = parsed_enum_filters;
|
||||
|
||||
let has_postcode_filters = !postcode_filters.is_empty()
|
||||
|| !postcode_enum_filters.is_empty()
|
||||
|| !parsed_poi_filters.is_empty()
|
||||
|| !travel_entries.is_empty();
|
||||
let has_listing_filters = !listing_filters.is_empty() || !listing_enum_filters.is_empty();
|
||||
|
||||
let state_clone = state.clone();
|
||||
let response =
|
||||
tokio::task::spawn_blocking(move || -> Result<ActualListingsResponse, String> {
|
||||
let t0 = std::time::Instant::now();
|
||||
|
||||
let passing_postcodes = if has_postcode_filters {
|
||||
Some(compute_passing_postcodes(
|
||||
&state_clone,
|
||||
south,
|
||||
west,
|
||||
north,
|
||||
east,
|
||||
&postcode_filters,
|
||||
&postcode_enum_filters,
|
||||
&parsed_poi_filters,
|
||||
&travel_entries,
|
||||
)?)
|
||||
let has_poi_filters = !parsed_poi_filters.is_empty();
|
||||
let has_travel_filters = !travel_entries.is_empty();
|
||||
let poi_num_features = state_clone.data.poi_metrics.num_features();
|
||||
let travel_data = if has_travel_filters {
|
||||
load_travel_data(&state_clone.travel_time_store, &travel_entries)?
|
||||
} else {
|
||||
None
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let row_indices = actual_listings.grid.query(south, west, north, east);
|
||||
|
|
@ -133,11 +106,6 @@ pub async fn get_actual_listings(
|
|||
.iter()
|
||||
.filter_map(|&row_idx| {
|
||||
let row = row_idx as usize;
|
||||
if let Some(allowed) = passing_postcodes.as_ref() {
|
||||
if !allowed.contains(actual_listings.postcode[row].as_str()) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if has_listing_filters
|
||||
&& !row_passes_listing_filters(
|
||||
row,
|
||||
|
|
@ -150,6 +118,25 @@ pub async fn get_actual_listings(
|
|||
{
|
||||
return None;
|
||||
}
|
||||
if has_poi_filters
|
||||
&& !row_passes_listing_poi_filters(
|
||||
row,
|
||||
&parsed_poi_filters,
|
||||
&actual_listings.poi_filter_feature_data,
|
||||
poi_num_features,
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if has_travel_filters
|
||||
&& !row_passes_travel_filters(
|
||||
actual_listings.postcode[row].as_str(),
|
||||
&travel_entries,
|
||||
&travel_data,
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(row)
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -176,8 +163,9 @@ pub async fn get_actual_listings(
|
|||
total = total_matching,
|
||||
total_in_bounds,
|
||||
offset,
|
||||
postcode_filtered = passing_postcodes.is_some(),
|
||||
listing_filtered = has_listing_filters,
|
||||
poi_filtered = has_poi_filters,
|
||||
travel_filtered = has_travel_filters,
|
||||
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
||||
"GET /api/actual-listings"
|
||||
);
|
||||
|
|
@ -196,10 +184,6 @@ pub async fn get_actual_listings(
|
|||
Ok(Json(response))
|
||||
}
|
||||
|
||||
fn listing_level_filter_feature_idxs(state: &AppState) -> FxHashSet<usize> {
|
||||
feature_idxs(state, LISTING_LEVEL_FILTER_FEATURES)
|
||||
}
|
||||
|
||||
fn keep_unknown_listing_filter_feature_idxs(state: &AppState) -> FxHashSet<usize> {
|
||||
feature_idxs(state, KEEP_UNKNOWN_LISTING_FILTER_FEATURES)
|
||||
}
|
||||
|
|
@ -211,38 +195,6 @@ fn feature_idxs(state: &AppState, names: &[&str]) -> FxHashSet<usize> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn split_numeric_filters(
|
||||
filters: Vec<ParsedFilter>,
|
||||
listing_level_feature_idxs: &FxHashSet<usize>,
|
||||
) -> (Vec<ParsedFilter>, Vec<ParsedFilter>) {
|
||||
let mut listing_filters = Vec::new();
|
||||
let mut postcode_filters = Vec::new();
|
||||
for filter in filters {
|
||||
if listing_level_feature_idxs.contains(&filter.feat_idx) {
|
||||
listing_filters.push(filter);
|
||||
} else {
|
||||
postcode_filters.push(filter);
|
||||
}
|
||||
}
|
||||
(listing_filters, postcode_filters)
|
||||
}
|
||||
|
||||
fn split_enum_filters(
|
||||
filters: Vec<ParsedEnumFilter>,
|
||||
listing_level_feature_idxs: &FxHashSet<usize>,
|
||||
) -> (Vec<ParsedEnumFilter>, Vec<ParsedEnumFilter>) {
|
||||
let mut listing_filters = Vec::new();
|
||||
let mut postcode_filters = Vec::new();
|
||||
for filter in filters {
|
||||
if listing_level_feature_idxs.contains(&filter.feat_idx) {
|
||||
listing_filters.push(filter);
|
||||
} else {
|
||||
postcode_filters.push(filter);
|
||||
}
|
||||
}
|
||||
(listing_filters, postcode_filters)
|
||||
}
|
||||
|
||||
fn row_passes_listing_filters(
|
||||
row: usize,
|
||||
filters: &[ParsedFilter],
|
||||
|
|
@ -266,132 +218,33 @@ fn row_passes_listing_filters(
|
|||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn compute_passing_postcodes(
|
||||
state: &AppState,
|
||||
south: f64,
|
||||
west: f64,
|
||||
north: f64,
|
||||
east: f64,
|
||||
parsed_filters: &[crate::parsing::ParsedFilter],
|
||||
parsed_enum_filters: &[crate::parsing::ParsedEnumFilter],
|
||||
parsed_poi_filters: &[crate::parsing::ParsedPoiFilter],
|
||||
travel_entries: &[TravelEntry],
|
||||
) -> Result<FxHashSet<String>, String> {
|
||||
let num_features = state.data.num_features;
|
||||
let feature_data = &state.data.feature_data;
|
||||
let poi_metrics = &state.data.poi_metrics;
|
||||
let has_poi_filters = !parsed_poi_filters.is_empty();
|
||||
fn row_passes_listing_poi_filters(
|
||||
row: usize,
|
||||
filters: &[ParsedPoiFilter],
|
||||
feature_data: &[u16],
|
||||
num_features: usize,
|
||||
) -> bool {
|
||||
if filters.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if num_features == 0 || feature_data.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let travel_data = if travel_entries.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let store = &state.travel_time_store;
|
||||
travel_entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
store
|
||||
.get(&entry.mode, &entry.slug)
|
||||
.map_err(|err| format!("Failed to load travel data: {}", err))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
};
|
||||
let has_travel = !travel_entries.is_empty();
|
||||
|
||||
let mut passing: FxHashSet<String> = FxHashSet::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;
|
||||
}
|
||||
if has_poi_filters && !row_passes_poi_filters(row, parsed_poi_filters, poi_metrics) {
|
||||
return;
|
||||
}
|
||||
let postcode = state.data.postcode(row);
|
||||
if has_travel && !row_passes_travel_filters(postcode, travel_entries, &travel_data) {
|
||||
return;
|
||||
}
|
||||
// Property postcodes share the same canonical "OUT IN" format used by
|
||||
// ActualListingData::load (normalize_postcode), so we can match by string.
|
||||
if !passing.contains(postcode) {
|
||||
passing.insert(postcode.to_string());
|
||||
}
|
||||
});
|
||||
|
||||
Ok(passing)
|
||||
let base = row * num_features;
|
||||
filters.iter().all(|filter| {
|
||||
let raw = feature_data
|
||||
.get(base + filter.metric_idx)
|
||||
.copied()
|
||||
.unwrap_or(NAN_U16);
|
||||
raw != NAN_U16 && raw >= filter.min_u16 && raw <= filter.max_u16
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn numeric_filter(feat_idx: usize) -> ParsedFilter {
|
||||
ParsedFilter {
|
||||
feat_idx,
|
||||
min_u16: 0,
|
||||
max_u16: 100,
|
||||
}
|
||||
}
|
||||
|
||||
fn enum_filter(feat_idx: usize) -> ParsedEnumFilter {
|
||||
ParsedEnumFilter {
|
||||
feat_idx,
|
||||
allowed: [0u16].into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_actual_listing_filters_by_listing_native_features() {
|
||||
let listing_level_feature_idxs: FxHashSet<usize> = [1usize, 3].into_iter().collect();
|
||||
|
||||
let (listing_filters, postcode_filters) = split_numeric_filters(
|
||||
vec![numeric_filter(0), numeric_filter(1), numeric_filter(3)],
|
||||
&listing_level_feature_idxs,
|
||||
);
|
||||
assert_eq!(
|
||||
listing_filters
|
||||
.iter()
|
||||
.map(|filter| filter.feat_idx)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 3]
|
||||
);
|
||||
assert_eq!(
|
||||
postcode_filters
|
||||
.iter()
|
||||
.map(|filter| filter.feat_idx)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![0]
|
||||
);
|
||||
|
||||
let (listing_enum_filters, postcode_enum_filters) = split_enum_filters(
|
||||
vec![enum_filter(2), enum_filter(3)],
|
||||
&listing_level_feature_idxs,
|
||||
);
|
||||
assert_eq!(
|
||||
listing_enum_filters
|
||||
.iter()
|
||||
.map(|filter| filter.feat_idx)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![3]
|
||||
);
|
||||
assert_eq!(
|
||||
postcode_enum_filters
|
||||
.iter()
|
||||
.map(|filter| filter.feat_idx)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_floor_area_filter_keeps_unknown_values() {
|
||||
let floor_area_filter = ParsedFilter {
|
||||
|
|
@ -436,4 +289,30 @@ mod tests {
|
|||
&keep_unknown_filter_idxs
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_poi_filter_uses_listing_metric_matrix() {
|
||||
let filter = ParsedPoiFilter {
|
||||
metric_idx: 1,
|
||||
min_u16: 10,
|
||||
max_u16: 20,
|
||||
};
|
||||
|
||||
assert!(row_passes_listing_poi_filters(
|
||||
0,
|
||||
&[filter],
|
||||
&[NAN_U16, 15],
|
||||
2
|
||||
));
|
||||
assert!(!row_passes_listing_poi_filters(
|
||||
0,
|
||||
&[ParsedPoiFilter {
|
||||
metric_idx: 1,
|
||||
min_u16: 10,
|
||||
max_u16: 20,
|
||||
}],
|
||||
&[NAN_U16, NAN_U16],
|
||||
2
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue