diff --git a/finder/test_rightmove_concurrency.py b/finder/test_rightmove_concurrency.py index cadc3b1..3ef3671 100644 --- a/finder/test_rightmove_concurrency.py +++ b/finder/test_rightmove_concurrency.py @@ -14,7 +14,7 @@ def _prop(pid, pin=None): # --------------------------------------------------------------------------- -# _needs_detail_fetch — accurate-pin skip +# _needs_detail_fetch: accurate-pin skip # --------------------------------------------------------------------------- @@ -32,7 +32,7 @@ def test_needs_detail_fetch_disabled_always_fetches(monkeypatch): # --------------------------------------------------------------------------- -# _prime_detail_postcodes — worklist selection + concurrent fetch +# _prime_detail_postcodes: worklist selection + concurrent fetch # --------------------------------------------------------------------------- @@ -97,7 +97,7 @@ def test_prime_is_a_noop_when_disabled_or_cap_zero(monkeypatch): # --------------------------------------------------------------------------- -# _paginate — end-to-end (network stubbed): accurate pins fall back to +# _paginate end-to-end (network stubbed): accurate pins fall back to # coordinates, approximate pins use the detail postcode. # --------------------------------------------------------------------------- diff --git a/finder/test_scraper_concurrency.py b/finder/test_scraper_concurrency.py index 6f9bbe3..24df360 100644 --- a/finder/test_scraper_concurrency.py +++ b/finder/test_scraper_concurrency.py @@ -9,7 +9,7 @@ import zoopla # --------------------------------------------------------------------------- -# _run_sources — Zoopla inline, others in threads, failures isolated +# _run_sources: Zoopla inline, others in threads, failures isolated # --------------------------------------------------------------------------- @@ -85,13 +85,13 @@ def test_seed_and_save_detail_caches_round_trip(tmp_path): def test_seed_detail_caches_tolerates_missing_files(tmp_path): rightmove._detail_postcode_cache.clear() - # No file written yet — seeding must not raise and must leave cache empty. + # No file written yet: seeding must not raise and must leave cache empty. scraper._seed_detail_caches(["rightmove"], tmp_path) assert rightmove._detail_postcode_cache == {} # --------------------------------------------------------------------------- -# run_scrape — full orchestration wiring (sources stubbed, no network) +# run_scrape: full orchestration wiring (sources stubbed, no network) # --------------------------------------------------------------------------- diff --git a/finder/test_transform.py b/finder/test_transform.py index 1da0a7e..1750fca 100644 --- a/finder/test_transform.py +++ b/finder/test_transform.py @@ -1,4 +1,5 @@ from transform import ( + build_listing_url, build_register_address, clean_listing_address, extract_full_postcode, @@ -173,3 +174,33 @@ def test_rightmove_transform_without_detail_keeps_coordinate_logic() -> None: assert result is not None assert result["Postcode"] == "SW9 7AA" assert result["Postcode source"] == "coordinates" + + +def test_build_listing_url_stamps_channel_from_new_build_flag() -> None: + # Resale gets RES_BUY; new builds get RES_NEW. + assert build_listing_url("/properties/200", False) == ( + "https://www.rightmove.co.uk/properties/200#/?channel=RES_BUY" + ) + assert build_listing_url("/properties/200", True) == ( + "https://www.rightmove.co.uk/properties/200#/?channel=RES_NEW" + ) + # An existing channel/fragment on the source URL is stripped and re-stamped. + assert build_listing_url("/properties/200#/?channel=RES_BUY", True) == ( + "https://www.rightmove.co.uk/properties/200#/?channel=RES_NEW" + ) + # Missing URL stays empty. + assert build_listing_url("", True) == "" + + +def test_rightmove_transform_tags_new_builds_res_new() -> None: + # The Rightmove search response marks new-build developments with + # development=True; transform_property must stamp the listing URL RES_NEW. + new_build = {**_rightmove_prop(), "development": True} + result = transform_property(new_build, "SW9", StubPostcodeIndex("SW9 7AA")) + assert result is not None + assert result["Listing URL"].endswith("#/?channel=RES_NEW") + + # Ordinary resale (development absent/false) stays RES_BUY. + resale = transform_property(_rightmove_prop(), "SW9", StubPostcodeIndex("SW9 7AA")) + assert resale is not None + assert resale["Listing URL"].endswith("#/?channel=RES_BUY") diff --git a/finder/test_zoopla.py b/finder/test_zoopla.py index 228e21b..f7a993f 100644 --- a/finder/test_zoopla.py +++ b/finder/test_zoopla.py @@ -103,7 +103,7 @@ def test_parse_detail_geo_merges_location_uprn_with_address_full_address() -> No def test_parse_detail_geo_does_not_borrow_comparable_full_address() -> None: # The only `address` twin on the page belongs to a different uprn (a # comparable listing). With a uprn to match on, an unrelated twin is never - # borrowed — full_address stays None rather than grabbing the wrong street. + # borrowed: full_address stays None rather than grabbing the wrong street. html = ( '"location":{"outcode":"NR29",' '"coordinates":{"latitude":52.716014,"longitude":1.614495},' @@ -185,7 +185,7 @@ def test_parse_detail_geo_returns_none_for_garbage() -> None: assert parse_detail_geo("no data here") is None assert parse_detail_geo("") is None # Coordinates that are not inside a property location/address wrapper (e.g. - # only an unwrapped POI) yield nothing — safe degradation to the outcode. + # only an unwrapped POI) yield nothing, safe degradation to the outcode. assert parse_detail_geo('"name":"X","coordinates":{"latitude":51.5,"longitude":-0.1}') is None diff --git a/finder/transform.py b/finder/transform.py index 49986ba..2a05dd4 100644 --- a/finder/transform.py +++ b/finder/transform.py @@ -149,7 +149,7 @@ def map_property_type(sub_type: str | None) -> str: return "Terraced" if "house" in lower or "cottage" in lower: return "Detached" - log.warning("Unknown propertySubType: %r — mapping to Other", sub_type) + log.warning("Unknown propertySubType: %r, mapping to Other", sub_type) return "Other" @@ -267,7 +267,7 @@ def build_register_address( property's own number or name (e.g. Zoopla detail pages expose ``propertyNumberOrName`` = "12" or "Martham Mill"), prepend it so the address carries the house identifier that the EPC/Price-Paid register addresses also - use — turning a fuzzy street match into a near-exact one. Falls back to the + use, turning a fuzzy street match into a near-exact one. Falls back to the plain cleaned address when no number/name is available. """ cleaned = clean_listing_address(raw_address) @@ -282,6 +282,23 @@ def build_register_address( return f"{number_or_name}, {cleaned}" if cleaned else number_or_name +def build_listing_url(property_url: str | None, is_new_build: bool) -> str: + """Build the canonical Rightmove listing URL with an explicit channel marker. + + The search API always echoes ``?channel=RES_BUY`` in ``propertyUrl`` even for + new-build developments (the request channel stays BUY), so the channel is + re-stamped here from the per-listing ``development`` flag: ``RES_NEW`` for new + builds, ``RES_BUY`` for ordinary resale. The map UI reads this marker to split + new vs non-new listings. Any channel/fragment already on ``propertyUrl`` is + stripped first so the result is deterministic. + """ + if not property_url: + return "" + base = property_url.split("#", 1)[0].split("?", 1)[0] + channel = "RES_NEW" if is_new_build else "RES_BUY" + return f"{RIGHTMOVE_BASE}{base}#/?channel={channel}" + + def transform_property( prop: dict, outcode: str, @@ -337,7 +354,7 @@ def transform_property( inferred_postcode = pc_index.nearest(lat, lng) if not inferred_postcode: - log.debug("No England postcode for property at %.4f, %.4f — skipping", lat, lng) + log.debug("No England postcode for property at %.4f, %.4f; skipping", lat, lng) return None raw_address = prop.get("displayAddress", "") or "" extracted_postcode = extract_full_postcode(raw_address) @@ -391,7 +408,7 @@ def transform_property( "price_frequency": "", "Price qualifier": price_qualifier, "Total floor area (sqm)": parse_display_size(prop.get("displaySize")), - "Listing URL": RIGHTMOVE_BASE + property_url if property_url else "", + "Listing URL": build_listing_url(property_url, bool(prop.get("development"))), "Listing features": key_features, "first_visible_date": prop.get("firstVisibleDate", ""), } diff --git a/finder/zoopla_flaresolverr.py b/finder/zoopla_flaresolverr.py index f3e6860..c268d83 100644 --- a/finder/zoopla_flaresolverr.py +++ b/finder/zoopla_flaresolverr.py @@ -1,7 +1,7 @@ """Zoopla scraping via FlareSolverr (no browser/VNC needed). FlareSolverr solves Zoopla's Cloudflare and returns the rendered HTML, which -still contains the React Server Components flight stream — so the existing pure +still contains the React Server Components flight stream, so the existing pure parsers work unchanged: - the search page yields the outcode's listing detail URLs, and - each detail page's flight stream carries the property's location object diff --git a/frontend/src/components/home/ProductShowcase.tsx b/frontend/src/components/home/ProductShowcase.tsx index 4273c72..5bfe64a 100644 --- a/frontend/src/components/home/ProductShowcase.tsx +++ b/frontend/src/components/home/ProductShowcase.tsx @@ -12,6 +12,7 @@ import type { TFunction } from 'i18next'; import { useTranslation } from 'react-i18next'; import { cellToLatLng, polygonToCells } from 'h3-js'; import PriceHistoryChart from '../map/PriceHistoryChart'; +import { MapErrorBoundary } from '../map/MapErrorBoundary'; import StackedBarChart from '../map/StackedBarChart'; import JourneyInstructions, { type JourneyInstructionPreset } from '../map/JourneyInstructions'; import { DualHistogram } from '../map/DualHistogram'; @@ -214,10 +215,13 @@ const SHOWCASE_MAP_START_VIEW: ViewState = { bearing: 0, }; +// End the Match fly-in over Greater London so the "match" step lands on the same +// place the Inspect (SW5 9AA) and Scout (SW5/SE22/N4) steps then drill into. The +// single-search narrative used to break by zooming to Birmingham then showing London. const SHOWCASE_MAP_END_VIEW: ViewState = { - longitude: -1.89, - latitude: 52.49, - zoom: 8.72, + longitude: -0.12, + latitude: 51.51, + zoom: 9.05, pitch: 0, bearing: 0, }; @@ -260,7 +264,12 @@ function buildShowcaseMapData(): HexagonData[] { } const SHOWCASE_MAP_DATA = buildShowcaseMapData(); -const SHOWCASE_MAP_TOTAL_COUNT = SHOWCASE_MAP_DATA.reduce((sum, item) => sum + item.count, 0); +// Count only the hexes around Greater London so the Match card's figure matches the +// area it has zoomed to, instead of mislabelling the England-wide total as one city. +const SHOWCASE_LONDON_COUNT = SHOWCASE_MAP_DATA.reduce((sum, item) => { + const nearLondon = Math.abs(item.lat - 51.5072) < 0.55 && Math.abs(item.lon + 0.1276) < 0.85; + return nearLondon ? sum + item.count : sum; +}, 0); const EMPTY_SHOWCASE_POSTCODES: PostcodeFeature[] = []; const EMPTY_SHOWCASE_POIS: POI[] = []; @@ -601,37 +610,42 @@ function EnglandHexMapScreen({ isActive }: { isActive: boolean }) { return (
{shouldRenderMap && ( - + // The full deck.gl/maplibre map sits above the fold; contain any WebGL + // context-loss crash to this pane (and auto-recover) instead of letting it + // bubble to the top-level boundary and blank the hero. + + + )}
-
Birmingham
+
{t('home.showcaseStep2Region')}
{t('home.showcaseMatchingHomes', { - value: SHOWCASE_MAP_TOTAL_COUNT.toLocaleString(), + value: SHOWCASE_LONDON_COUNT.toLocaleString(), })}
@@ -789,19 +803,22 @@ function ScoutScreen({ isActive }: { isActive: boolean }) { postcode: 'SW5 9AA', score: '94%', commute: t('home.showcaseMinutes', { count: 23 }), - price: '£492k', + perSqm: '£8,200', + delta: '−16%', }, { postcode: 'SE22 8EF', score: '91%', commute: t('home.showcaseMinutes', { count: 28 }), - price: '£518k', + perSqm: '£5,900', + delta: '−24%', }, { postcode: 'N4 2AB', score: '88%', commute: t('home.showcaseMinutes', { count: 31 }), - price: '£476k', + perSqm: '£6,400', + delta: '−21%', }, ]; @@ -934,7 +951,12 @@ function ScoutScreen({ isActive }: { isActive: boolean }) {
{row.commute}
-
{row.price}
+
+
{row.perSqm}
+
+ {row.delta} +
+
))}
@@ -1031,6 +1053,7 @@ export default function ProductShowcase({ className = '' }: ProductShowcaseProps const [isStagePaused, setIsStagePaused] = useState(false); const [hasStarted, setHasStarted] = useState(false); const [canPauseOnHover, setCanPauseOnHover] = useState(false); + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); const showcaseRef = useRef(null); const inspectUserScrolledRef = useRef(false); @@ -1101,6 +1124,27 @@ export default function ProductShowcase({ className = '' }: ProductShowcaseProps return () => mediaQuery.removeEventListener('change', updateCanPause); }, []); + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + const update = () => setPrefersReducedMotion(mediaQuery.matches); + update(); + mediaQuery.addEventListener('change', update); + return () => mediaQuery.removeEventListener('change', update); + }, []); + + // Under prefers-reduced-motion the progress-bar animation is disabled in CSS, so + // its onAnimationEnd (which normally advances the carousel) never fires and the + // demo would freeze on step 1. Drive the advance from a timer in that case. + useEffect(() => { + if (!prefersReducedMotion || !isProgressRunning) return; + const timer = window.setTimeout( + () => setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT), + activeStepIntervalMs + ); + return () => window.clearTimeout(timer); + }, [prefersReducedMotion, isProgressRunning, activeStep, activeStepIntervalMs]); + const pauseForHover = () => { if (canPauseOnHover) setIsStagePaused(true); }; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0db2755..b476a72 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -235,6 +235,7 @@ export interface HistoricalPrice { year: number; month: number; price: number; + is_new: boolean; } export interface Property { @@ -386,7 +387,7 @@ export interface HexagonStatsResponse { * sector, shown alongside the national average for each crime metric. */ crime_area_averages?: CrimeAreaAverage[]; /** Total individual crime records (last 7 years) across the selection's - * postcodes — the count behind the "individual crimes" list. */ + * postcodes, the count behind the "individual crimes" list. */ crime_total_records?: number; central_postcode?: string; /** Total usual residents (ONS Census 2021) across the postcodes in this diff --git a/pipeline/download/median_age.py b/pipeline/download/median_age.py index 922caff..23f866c 100644 --- a/pipeline/download/median_age.py +++ b/pipeline/download/median_age.py @@ -3,7 +3,7 @@ Downloads five-year age band counts (TS007A) from the NOMIS API, then computes the median age per LSOA using linear interpolation within the median class. -Source: NOMIS (ONS Census 2021 — TS007A dataset, NM_2020_1) +Source: NOMIS (ONS Census 2021, TS007A dataset, NM_2020_1) License: Open Government Licence v3.0 """ @@ -20,7 +20,7 @@ from pipeline.utils import ENGLAND_LSOA_COUNT_2021, download_nomis_csv BASE_URL = "https://www.nomisweb.co.uk/api/v01/dataset/NM_2020_1.data.csv?date=latest&geography=TYPE151&c2021_age_19=1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18&measures=20100&select=GEOGRAPHY_CODE,C2021_AGE_19_NAME,OBS_VALUE" # Five-year age bands in order, with lower bounds for interpolation. -# The last band (85+) is open-ended — we treat it as 85-89 for median purposes. +# The last band (85+) is open-ended. We treat it as 85-89 for median purposes. AGE_BANDS = [ (0, 5), # Aged 0 to 4 years (5, 5), # Aged 5 to 9 years diff --git a/pipeline/download/naptan.py b/pipeline/download/naptan.py index 5a21aab..10054ed 100644 --- a/pipeline/download/naptan.py +++ b/pipeline/download/naptan.py @@ -28,7 +28,7 @@ LONDON_UNDERGROUND_ATCO_PATTERN = r"(?i)^\d{3}[0G]ZZLU" # The Docklands Light Railway uses the analogous "ZZDL" system code (e.g. # "9400ZZDLBEC" for Beckton). Like ZZLU it is unique to one network, so a # TMU/MET stop carrying a ZZDL code is reclassified from the tram/metro family -# to its own "DLR station" category — restoring DLR to the train/tube station +# to its own "DLR station" category, restoring DLR to the train/tube station # list and giving it the DLR roundel rather than a generic tram icon. LONDON_DLR_ATCO_PATTERN = r"(?i)^\d{3}[0G]ZZDL" @@ -337,7 +337,7 @@ class StationAccumulator: # A single node is never both (ZZLU vs ZZDL), but a co-located # interchange (Bank, Stratford, Canning Town, West Ham) merges its LU # and DLR halves into one group carrying both flags; Tube is checked - # first so these resolve to "Tube station" — their primary identity — + # first so these resolve to "Tube station" (their primary identity), # leaving "DLR station" for the DLR-only stops the fix targets. if self.category == TRAM_METRO_CATEGORY and self.is_lu: return TUBE_STATION_CATEGORY diff --git a/pipeline/download/noise.py b/pipeline/download/noise.py index 55e7c27..c065951 100644 --- a/pipeline/download/noise.py +++ b/pipeline/download/noise.py @@ -85,24 +85,24 @@ RESOLUTION = NATIVE_RESOLUTION # Defra encodes TRUE "no data" with this sentinel (NOT 0.0). A 0.0 cell that is # otherwise inside the raster means "modelled below the lowest reporting band", -# i.e. genuinely quiet — see noise_overlay_tiles.py:167. +# i.e. genuinely quiet (see noise_overlay_tiles.py:167). NOISE_NODATA_SENTINEL = np.float32(-96.0) # Lowest modelled Defra Lden reporting band (dB). Verified against the actual # rasters: the minimum positive in-coverage value is 40.0 dB with NO values in -# (0, 40) — below the band, cells are encoded as 0.0 (genuinely quiet). We floor +# (0, 40). Below the band, cells are encoded as 0.0 (genuinely quiet). We floor # in-coverage cells to 40.0 so a below-band 0.0 surfaces as "we know it's quiet" # (~40 dB) instead of collapsing to null ("we don't know"), WITHOUT inflating the # ~35% of genuine 40-44.99 dB readings that a 45.0 floor would wrongly bump to 45. # NB: 45.0 is the overlay's lowest *paint* stop (noise_overlay_tiles. -# NOISE_COLOR_STOPS[0]) — a rendering threshold, not the data's reporting floor. +# NOISE_COLOR_STOPS[0]), a rendering threshold, not the data's reporting floor. NOISE_QUIET_FLOOR_DB = np.float32(40.0) # Sample noise at the postcode representative point itself (no neighbourhood # window). A 50m MAX-of-window grabbed the single loudest 10m cell within ~1.2 ha # of every postcode; because Defra road contours hug every modelled road and # representative points sit on/near streets, that inflated postcode noise by -# roughly +9 dB (log scale) — making ~94% of England read >=55 dB Lden and +# roughly +9 dB (log scale), making ~94% of England read >=55 dB Lden and # collapsing the metric's discrimination at the quiet end. Radius 0 -> # filter_size 1 -> the maximum_filter is skipped and each postcode reads the # 10m cell it actually sits in. @@ -110,8 +110,8 @@ POSTCODE_NOISE_RADIUS_M = 0 # Adjacent download tiles overlap by the sampling radius so every postcode's # sampling footprint is fully contained in at least one tile. With point -# sampling (radius 0) this is 0 — a representative point falls inside exactly -# one tile — but the relationship is kept so any future non-zero radius keeps +# sampling (radius 0) this is 0 (a representative point falls inside exactly +# one tile), but the relationship is kept so any future non-zero radius keeps # its window seam-safe. TILE_OVERLAP_M = POSTCODE_NOISE_RADIUS_M @@ -273,7 +273,7 @@ def _download_tile( NoGeoTiffError, httpx.HTTPStatusError, # TransportError is the superset of TimeoutException, ConnectError, - # ReadError and ProtocolError — including RemoteProtocolError, raised + # ReadError and ProtocolError, including RemoteProtocolError, raised # when the WCS server closes the connection mid-stream ("incomplete # chunked read"). All are transient; retry/split rather than letting # one flaky tile crash the whole raster download. @@ -446,7 +446,7 @@ def sample_noise_at_postcodes( # Defra rasters encode TRUE nodata as the -96.0 sentinel (and # occasionally non-finite / dataset.nodata); genuinely quiet ground # below the model's lowest reporting band is encoded as 0.0. Only - # the former is "we don't know" — the latter is a real "we know it's + # the former is "we don't know". The latter is a real "we know it's # quiet" reading and must not collapse to null. So treat ONLY true # nodata as -inf (it never wins a max and never counts as coverage), # and clamp every in-coverage cell up to NOISE_QUIET_FLOOR_DB so a @@ -555,7 +555,7 @@ def main() -> None: if not tile_paths: print( - f"[{label}] WARNING: No tiles downloaded — column will be all null" + f"[{label}] WARNING: No tiles downloaded; column will be all null" ) series = pl.Series(col_name, [None] * len(lat), dtype=pl.Float32) else: diff --git a/pipeline/download/os_greenspace.py b/pipeline/download/os_greenspace.py index 59e2768..d64ed65 100644 --- a/pipeline/download/os_greenspace.py +++ b/pipeline/download/os_greenspace.py @@ -7,7 +7,7 @@ site id and the site's polygon centroid. Sites without access points fall back to polygon centroids. Using access points rather than polygon centroids gives much more accurate -distance calculations — a property next to Hyde Park won't show 400m just +distance calculations: a property next to Hyde Park won't show 400m just because the centroid is in the middle of the park. The site id / centroid columns let downstream consumers (poi_proximity) collapse the frame back to one row per SITE for counting, so a park with 30 gates counts as one park. @@ -190,7 +190,7 @@ def download_greenspace(output: Path) -> None: print(f"Reading {site_shps[0].name} for function types...") site_funcs = _read_site_functions(site_shps[0]) - # Step 2: Read access points (primary — park entrances) + # Step 2: Read access points (primary: park entrances) print(f"Reading {access_shps[0].name}...") ap_lats, ap_lngs, ap_cats, ap_site_ids = _read_access_points( access_shps[0], site_funcs diff --git a/pipeline/download/places.py b/pipeline/download/places.py index 2c547a5..03a0e53 100644 --- a/pipeline/download/places.py +++ b/pipeline/download/places.py @@ -930,7 +930,7 @@ def main() -> None: df.write_parquet(args.output) print(f"Saved to {args.output}") else: - print("No places found — skipping output") + print("No places found, skipping output") if __name__ == "__main__": diff --git a/pipeline/download/rental_prices.py b/pipeline/download/rental_prices.py index 739bded..e200141 100644 --- a/pipeline/download/rental_prices.py +++ b/pipeline/download/rental_prices.py @@ -35,7 +35,7 @@ def _data_rows(df: pl.DataFrame) -> pl.DataFrame: The preamble length varies (title, optional "This worksheet contains..." note, then the header row starting with "Time period"), so locate the - header by content instead of counting rows — a fixed slice leaves the + header by content instead of counting rows: a fixed slice leaves the header in the data whenever ONS adds or removes a note line. """ header_marker = ( @@ -78,7 +78,7 @@ def _latest_rents_long(df: pl.DataFrame) -> pl.DataFrame: print(f"LAs in latest month: {df.height}") # Melt to long format: one row per area x bedroom count. - # PIPR has no Studio category — one-bed rent used as proxy for bedrooms=0. + # PIPR has no Studio category: one-bed rent used as proxy for bedrooms=0. frames = [] for col, bedrooms in [ ("rent_1bed", 0), # Studio (proxy) diff --git a/pipeline/download/tenure.py b/pipeline/download/tenure.py index 73da82f..49b999f 100644 --- a/pipeline/download/tenure.py +++ b/pipeline/download/tenure.py @@ -2,8 +2,8 @@ Downloads the household-tenure breakdown (TS054, classification C2021_TENURE_9) from the NOMIS API at LSOA 2021 granularity and folds the 8 detailed leaf -categories into our 3 output buckets — Owner occupied / Social rent / -Private rent — emitting one row per LSOA with the percentage of households in +categories into our 3 output buckets (Owner occupied / Social rent / +Private rent), emitting one row per LSOA with the percentage of households in each. The three buckets sum to 100%, so downstream they render as a composition/ratio (like the ethnicity and qualifications stacked bars) AND each percentage is independently filterable. @@ -19,7 +19,7 @@ NOTE this table counts HOUSEHOLDS (not usual residents). The join key downstream (merge.py) is `lsoa21`, the same key used for ethnicity, qualifications, median age, and IoD. -Source: NOMIS (ONS Census 2021 — TS054 dataset, NM_2072_1) +Source: NOMIS (ONS Census 2021, TS054 dataset, NM_2072_1) License: Open Government Licence v3.0 """ diff --git a/pipeline/download/test_ethnicity.py b/pipeline/download/test_ethnicity.py index 63f913f..5dcc0e2 100644 --- a/pipeline/download/test_ethnicity.py +++ b/pipeline/download/test_ethnicity.py @@ -72,7 +72,7 @@ def test_ethnicity_routes_chinese_to_east_and_other_asian_to_se(): def test_ethnicity_percentages_independent_per_lsoa(): - """Two LSOAs get independent profiles — the LSOA granularity is the point.""" + """Two LSOAs get independent profiles: the LSOA granularity is the point.""" df = pl.concat( [ pl.DataFrame( diff --git a/pipeline/download/test_noise.py b/pipeline/download/test_noise.py index d075590..f9e11b7 100644 --- a/pipeline/download/test_noise.py +++ b/pipeline/download/test_noise.py @@ -281,7 +281,7 @@ def test_sample_noise_preserves_genuine_reading_above_quiet_floor( # The lowest Defra reporting band is 40.0 dB; genuine readings populate # [40, ~80]. A genuine in-coverage reading at or just above the floor must be - # PRESERVED, not clamped UP to the floor — only true-quiet 0.0 is floored. A + # PRESERVED, not clamped UP to the floor. Only true-quiet 0.0 is floored. A # quiet floor set too high (e.g. 45) would inflate the ~35% of real 40-44.99 # dB readings; this pins that they survive unchanged. floor = float(noise.NOISE_QUIET_FLOOR_DB) diff --git a/pipeline/download/transit_network.py b/pipeline/download/transit_network.py index fa858b7..f373beb 100644 --- a/pipeline/download/transit_network.py +++ b/pipeline/download/transit_network.py @@ -50,7 +50,7 @@ ENGLAND_PBF_URL = ( "https://download.geofabrik.de/europe/united-kingdom/england-latest.osm.pbf" ) -# Bus Open Data Service — pre-converted GTFS covering all England bus/tram/ferry +# Bus Open Data Service: pre-converted GTFS covering all England bus/tram/ferry BODS_GTFS_URL = "https://data.bus-data.dft.gov.uk/timetable/download/gtfs-file/all/" # National Rail Open Data API @@ -597,7 +597,7 @@ def validate_gtfs_feed( fail("has neither calendar.txt nor calendar_dates.txt") if not _calendar_active_in_window(z, names, window_start, window_end): fail( - f"no service active between {window_start} and {window_end} — " + f"no service active between {window_start} and {window_end}: " "the feed's calendars are stale/expired and it would contribute " "zero service to routing" ) @@ -697,7 +697,7 @@ def _iter_stop_time_trips(lines, trip_id_idx: int): dtd2mysql currently writes rows grouped by trip and ordered by stop_sequence, but neither is guaranteed by GTFS. Grouping is verified (a trip_id reappearing later raises instead of silently scrambling trips); - within-trip order is NOT assumed — callers sort each group by its original + within-trip order is NOT assumed: callers sort each group by its original stop_sequence. """ current_trip: str | None = None @@ -1175,7 +1175,7 @@ def main() -> None: cif = download_national_rail_cif(raw_dir) if cif is None: raise RuntimeError( - "National Rail timetable was not downloaded — set " + "National Rail timetable was not downloaded: set " "NATIONAL_RAIL_EMAIL / NATIONAL_RAIL_PASSWORD (register free at " "https://opendata.nationalrail.co.uk/). National Rail heavy rail is " "required; without it the transit network models every train journey " diff --git a/pipeline/transform/postcode_boundaries/geometry.py b/pipeline/transform/postcode_boundaries/geometry.py index 3fd85a4..4172f48 100644 --- a/pipeline/transform/postcode_boundaries/geometry.py +++ b/pipeline/transform/postcode_boundaries/geometry.py @@ -1,8 +1,8 @@ """Robust GEOS overlay helpers. Overlay operations (union, difference, intersection) can raise a -``GEOSException`` — most often ``TopologyException: side location conflict``, -``Ring edge missing``, or ``found non-noded intersection`` — on geometries that +``GEOSException``, most often ``TopologyException: side location conflict``, +``Ring edge missing``, or ``found non-noded intersection``, on geometries that contain near-coincident or near-degenerate edges, or that are individually invalid. The robust remedy is a *fixed-precision* overlay: GEOS's OverlayNG engine, handed a grid size, nodes every edge onto that grid and finishes where @@ -14,8 +14,8 @@ learned the hard way from a crash: 1. **Never precision-reduce with the default mode.** ``set_precision``'s default ``valid_output`` (and ``keep_collapsed``) mode runs its *own* noding pass that re-raises the very ``side location conflict`` we are trying to escape. We push - the grid into the overlay via the ``grid_size`` argument instead — where - OverlayNG nodes robustly — and only ever call ``set_precision`` in + the grid into the overlay via the ``grid_size`` argument instead (where + OverlayNG nodes robustly) and only ever call ``set_precision`` in ``pointwise`` mode (pure coordinate rounding, which cannot raise). 2. **Validate first.** ``make_valid`` repairs the self-intersections (bow-ties, pinches) that make GEOS choke, so the overlay starts from an OGC-valid shape. @@ -40,7 +40,7 @@ from shapely import GEOSException, make_valid, set_precision from shapely.geometry import Polygon from shapely.ops import unary_union -# 0.1 mm in metres — well below MIN_GEOM_AREA (0.01 m^2) and survey resolution. +# 0.1 mm in metres: well below MIN_GEOM_AREA (0.01 m^2) and survey resolution. _SNAP_GRID = 1e-4 _EMPTY = Polygon() diff --git a/pipeline/transform/postcode_boundaries/output.py b/pipeline/transform/postcode_boundaries/output.py index 4d5aa58..0356459 100644 --- a/pipeline/transform/postcode_boundaries/output.py +++ b/pipeline/transform/postcode_boundaries/output.py @@ -120,7 +120,7 @@ def _is_pointlike(geom_bng) -> bool: def _rescue_footprint(geom_bng) -> dict | None: """Fatten a degenerate BNG geometry into a representable footprint and snap. - A POINTLIKE input (a point, or a near-zero-area/short-perimeter polygon — the + A POINTLIKE input (a point, or a near-zero-area/short-perimeter polygon, the signature of a tower-block postcode whose UPRNs all share one coordinate) gets a building-scale buffer so it is not reduced to an invisible sub-metre dot; thin slivers that still carry length keep the minimal buffer. @@ -263,7 +263,7 @@ def merge_fragments( # Close tiny gaps between adjacent OA boundary edges (float mismatches). # The closing can erode a tiny MultiPolygon (e.g. a postcode with only a # sliver fragment) to nothing, which would leave the postcode with no - # geometry at all — keep the un-closed shape if that happens. + # geometry at all. Keep the un-closed shape if that happens. if combined.geom_type == "MultiPolygon": closed = combined.buffer(5.0).buffer(-5.0) if not closed.is_valid: @@ -308,7 +308,7 @@ def _polygonal(geom): return None # Both callers run on WGS84-degree output geometry, so the robustness # fallback snaps on the 1e-6° grid (~0.11 m), not geometry.py's metre - # default — a coarse metre grid would obliterate a degree-scale shape. + # default. A coarse metre grid would obliterate a degree-scale shape. merged = safe_union(polys, grid=_OUTPUT_PRECISION_DEG) return merged if not merged.is_empty else None return None @@ -324,7 +324,7 @@ def _resolve_overlaps( containment (a postcode fully enclosed by another). Each postcode is trimmed by the union of its higher-priority overlapping neighbours, where **priority = ascending area**: a smaller postcode wins contested ground. That single rule - handles both cases correctly — an enclosed postcode is always smaller than its + handles both cases correctly: an enclosed postcode is always smaller than its container, so it keeps its area while the container gets a hole (a `overlaps` query alone would miss containment entirely). Run last, on the final output geometries, so nothing re-introduces overlap afterwards. A postcode that would @@ -348,7 +348,7 @@ def _resolve_overlaps( arr = np.array(geoms, dtype=object) pairs: set[tuple[int, int]] = set() # "overlaps" gives partial overlaps; "contains" gives containment (which - # "overlaps" excludes) — together they cover every 2-D overlap without the + # "overlaps" excludes). Together they cover every 2-D overlap without the # edge-touch explosion a plain "intersects" query would add. for predicate in ("overlaps", "contains"): qsrc, qtgt = tree.query(arr, predicate=predicate) @@ -577,7 +577,7 @@ def _grid_footprint(geom): pass can shave a small (e.g. co-located, non-geographic) postcode down to a sub-grid sliver that disappears when snapped to output precision. Rather than drop it, place a minimal valid footprint at its location. The tiny overlap - this re-creates with the neighbour that trimmed it is harmless — the output + this re-creates with the neighbour that trimmed it is harmless: the output partition is best-effort, a missing boundary is a hard validation failure. """ try: diff --git a/pipeline/transform/postcode_boundaries/process_oa.py b/pipeline/transform/postcode_boundaries/process_oa.py index 4404560..50555d6 100644 --- a/pipeline/transform/postcode_boundaries/process_oa.py +++ b/pipeline/transform/postcode_boundaries/process_oa.py @@ -11,7 +11,7 @@ from .voronoi import compute_voronoi_regions MIN_GEOM_AREA = 0.01 # Minimal footprint (BNG metres) for a postcode whose UPRN seed wins no area in a -# crowded multi-postcode OA — its Voronoi cell ∩ remaining collapses below +# crowded multi-postcode OA: its Voronoi cell ∩ remaining collapses below # MIN_GEOM_AREA, or its seed sits inside an INSPIRE parcel wholly claimed by a # co-located postcode. Every *active* postcode must keep a boundary # (validate_outputs is zero-tolerance), so it gets a small disc at its true seed @@ -77,7 +77,7 @@ def process_oa( fragments.append((pc, merged)) # Every postcode with a UPRN seed in this OA must keep at least a minimal - # footprint — in a dense OA (a block of flats with hundreds of distinct + # footprint: in a dense OA (a block of flats with hundreds of distinct # postcodes) a single-seed postcode's cell can collapse below MIN_GEOM_AREA or # be fully absorbed by a co-located postcode's INSPIRE parcel, producing no # fragment, and an active postcode must never be dropped. @@ -142,7 +142,7 @@ def _claim_inspire_parcels( # UPRNs from a single postcode goes wholly to that postcode. A parcel shared # by several postcodes (a block of flats spanning postcodes, or overlapping # parcel data) is split between them via a sub-Voronoi over their own UPRNs - # clipped to the parcel — so EVERY contained postcode keeps part of the + # clipped to the parcel, so EVERY contained postcode keeps part of the # parcel. A bare majority vote would hand the whole parcel to one winner and # leave the losers' UPRNs trapped inside claimed land, dropping them from # both this claim and the `remaining` polygon handed to Voronoi downstream. @@ -312,7 +312,7 @@ def _extract_polygonal(geom) -> Polygon | MultiPolygon | None: return polys[0] # Union (not bare MultiPolygon construction): make_valid can emit # overlapping polygonal parts, and a MultiPolygon of overlapping parts is - # invalid — it double-counts area and makes the next `.difference()` raise + # invalid: it double-counts area and makes the next `.difference()` raise # a TopologyException that aborts the OA (and, in parallel mode, the # worker). safe_union merges them into a valid geometry. merged = safe_union(polys) diff --git a/pipeline/transform/postcode_boundaries/uprn.py b/pipeline/transform/postcode_boundaries/uprn.py index d7d19b8..6c99e94 100644 --- a/pipeline/transform/postcode_boundaries/uprn.py +++ b/pipeline/transform/postcode_boundaries/uprn.py @@ -82,7 +82,7 @@ def load_uprns( # Remap terminated postcodes to their nearest active successor. The # successor generally lives in a DIFFERENT OA (and at different grid # coordinates), so the remapped point must adopt the successor's - # authoritative OA/coords — keeping the terminated postcode's original + # authoritative OA/coords. Keeping the terminated postcode's original # OA would seed the successor into an OA it doesn't belong to, splitting # its boundary across OAs. Genuine (non-remapped) UPRN rows keep their # own OA, since a live postcode can legitimately span several OAs. @@ -127,7 +127,7 @@ def load_uprns( uprns.sort("OA21CD").sink_parquet(tmp_path) release_memory() - # Read the sorted data — only one copy in memory (~2GB) + # Read the sorted data: only one copy in memory (~2GB) df = pl.read_parquet(tmp_path) tmp_path.unlink() n = len(df) diff --git a/pipeline/transform/price_estimation/index.py b/pipeline/transform/price_estimation/index.py index 0994939..65230e9 100644 --- a/pipeline/transform/price_estimation/index.py +++ b/pipeline/transform/price_estimation/index.py @@ -4,7 +4,7 @@ Stratified by property type and postcode sector, with IRLS Huber regression, hierarchical shrinkage (sector → district → area → national → hedonic), and KD-tree spatial smoothing for sparse sectors. -Output: price_index.parquet — sector x type_group x year -> log_index +Output: price_index.parquet (sector x type_group x year -> log_index) """ import argparse diff --git a/pipeline/transform/price_estimation/utils.py b/pipeline/transform/price_estimation/utils.py index e551e17..e0e73ba 100644 --- a/pipeline/transform/price_estimation/utils.py +++ b/pipeline/transform/price_estimation/utils.py @@ -23,7 +23,7 @@ ESTIMATE_COLUMNS = ["Estimated current price", "Est. price per sqm"] # Natural join key from estimates back onto properties: postcode plus the # coalesced register/EPC address. This is unique and non-null on the deduped # dwelling universe (see property_base._dedupe_collapsed_properties), so it maps -# estimates 1:1 onto properties regardless of row order — estimates are computed +# estimates 1:1 onto properties regardless of row order. Estimates are computed # from a separate price_inputs.parquet, so a positional key would not line up. JOIN_ADDRESS = "_join_address" JOIN_KEYS = ["Postcode", JOIN_ADDRESS] diff --git a/pipeline/transform/test_join_epc_pp.py b/pipeline/transform/test_join_epc_pp.py index 6438caf..8940495 100644 --- a/pipeline/transform/test_join_epc_pp.py +++ b/pipeline/transform/test_join_epc_pp.py @@ -315,7 +315,7 @@ def test_run_tenure_history_tracks_rent_owner_transitions(tmp_path: Path): def test_run_tenure_history_empty_when_always_owner_occupied(tmp_path: Path): # A property only ever observed as owner-occupied has no tenure change worth - # surfacing — the timeline column is null (no events), not a noisy baseline. + # surfacing: the timeline column is null (no events), not a noisy baseline. zip_path = tmp_path / "domestic-csv.zip" _write_epc_zip( zip_path, @@ -339,6 +339,37 @@ def test_run_tenure_history_empty_when_always_owner_occupied(tmp_path: Path): assert df.get_column("tenure_history").to_list() == [None] +def test_run_latest_tenure_status_reflects_most_recent_certificate(tmp_path: Path): + # Two certificates for one dwelling: an older social-rented cert and a newer + # owner-occupied one. The published latest_tenure_status must carry the + # LATEST certificate's normalized tenure ("Owner-occupied"), while + # was_council_house stays "Yes" because the dwelling was social at some + # point. This also confirms latest_tenure_status reaches the epc_pp parquet. + zip_path = tmp_path / "domestic-csv.zip" + _write_epc_zip( + zip_path, + [ + _row(inspection_date="2016-04-01", tenure="Rented (social)"), + _row(inspection_date="2024-04-01", tenure="owner-occupied"), + ], + ) + + price_paid_path = tmp_path / "price-paid.parquet" + _price_paid_frame(prices=[250_000], dates=[date(2024, 2, 3)]).write_parquet( + price_paid_path + ) + + output_path = tmp_path / "epc-pp.parquet" + _run(zip_path, price_paid_path, output_path, tmp_path) + + df = pl.read_parquet(output_path) + + assert df.height == 1 + assert df.select("latest_tenure_status", "was_council_house").to_dicts() == [ + {"latest_tenure_status": "Owner-occupied", "was_council_house": "Yes"} + ] + + def test_run_dedup_prefers_valid_dated_cert_over_garbled_date(tmp_path: Path): # Two certificates for the same property. The cert with the garbled, # unparseable inspection_date must NOT be chosen as "latest": a string sort @@ -565,7 +596,7 @@ def test_run_new_build_keeps_early_first_transfer_when_sub_min_price(tmp_path: P price_paid_path = tmp_path / "price-paid.parquet" pl.DataFrame( { - # 5_000 is below MIN_PRICE (10_000) — a nominal/junk transfer that + # 5_000 is below MIN_PRICE (10_000), a nominal/junk transfer that # must still anchor the construction year but stay out of the price # aggregations. "price": [5_000, 300_000], @@ -603,7 +634,7 @@ def test_run_caps_band_year_at_first_transfer_year(tmp_path: Path): # lands AFTER its first Land Registry sale (1998). A dwelling cannot have # been built after it was first sold, so the published build year must be # capped at the first transfer year (1998), not the later band estimate. - # It stays flagged as an estimate (approximate=1) — it is still EPC-derived. + # It stays flagged as an estimate (approximate=1). It is still EPC-derived. zip_path = tmp_path / "domestic-csv.zip" _write_epc_zip( zip_path, [_row(construction_age_band="England and Wales: 2003 onwards")] @@ -627,7 +658,7 @@ def test_run_caps_band_year_at_first_transfer_year(tmp_path: Path): def test_run_keeps_band_year_when_earlier_than_first_transfer(tmp_path: Path): # The common case: the EPC band (1950-1966 -> 1958) predates the first - # recorded sale (2020). The cap must NOT fire — the band estimate stands. + # recorded sale (2020). The cap must NOT fire. The band estimate stands. zip_path = tmp_path / "domestic-csv.zip" _write_epc_zip(zip_path) @@ -649,7 +680,7 @@ def test_run_keeps_band_year_when_earlier_than_first_transfer(tmp_path: Path): def test_run_keeps_sale_above_lowered_min_price(tmp_path: Path): # A genuine cheap sale of 30_000 sits between the OLD floor (50k) and the # NEW floor (10k): it must now be RETAINED in the price aggregations. This - # pins the 50k->10k change — it fails on the pre-fix 50k floor (where 30k was + # pins the 50k->10k change: it fails on the pre-fix 50k floor (where 30k was # excluded, giving historical_prices length 1 / latest_price 250_000). zip_path = tmp_path / "domestic-csv.zip" with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: @@ -748,8 +779,8 @@ def test_run_collapses_duplicate_transactions(tmp_path: Path): # The duplicated 250_000 sale collapses to one entry; two distinct sales. assert df.get_column("historical_prices").to_list() == [ [ - {"year": 2020, "month": 2, "price": 200_000}, - {"year": 2024, "month": 2, "price": 250_000}, + {"year": 2020, "month": 2, "price": 200_000, "is_new": False}, + {"year": 2024, "month": 2, "price": 250_000, "is_new": False}, ] ] assert df.get_column("latest_price").to_list() == [250_000] @@ -777,7 +808,7 @@ def test_run_excludes_implausible_price_jump_but_keeps_property(tmp_path: Path): assert df.height == 1 assert df.get_column("latest_price").to_list() == [140_000] assert df.get_column("historical_prices").to_list() == [ - [{"year": 2016, "month": 6, "price": 140_000}] + [{"year": 2016, "month": 6, "price": 140_000, "is_new": False}] ] diff --git a/pipeline/transform/test_transform_poi.py b/pipeline/transform/test_transform_poi.py index 11bb3a4..7b5399f 100644 --- a/pipeline/transform/test_transform_poi.py +++ b/pipeline/transform/test_transform_poi.py @@ -593,7 +593,7 @@ def test_transform_grocery_dedup_drops_only_grocery_aspect(tmp_path): # The _write_transform_inputs fixture seeds 5 GEOLYTIX "Tesco" points at # (51.52, -0.14). An OSM object colocated there carrying "Tesco" in its name # is the same physical store, so its Convenience Store (Groceries) row is a - # duplicate and must be dropped — but its NON-grocery aspect (a Post Office + # duplicate and must be dropped, but its NON-grocery aspect (a Post Office # sharing the same OSM id) must survive. An independent shop away from the # GEOLYTIX point keeps its grocery row. raw = pl.DataFrame( diff --git a/pipeline/transform/transform_poi.py b/pipeline/transform/transform_poi.py index 4fae0a9..dac448f 100644 --- a/pipeline/transform/transform_poi.py +++ b/pipeline/transform/transform_poi.py @@ -34,7 +34,7 @@ DROP_CATEGORIES = { "emergency/water_tank", "leisure/bleachers", "leisure/schoolyard", - # Park "furniture" / incidental features — not parks; they massively + # Park "furniture" / incidental features, not parks; they massively # inflated the Park count (picnic_table ~15k, outdoor_seating ~5.8k). "leisure/bandstand", "leisure/bird_hide", @@ -222,7 +222,7 @@ DROP_CATEGORIES = { "public_transport/entrance", "public_transport/station", "public_transport/stop_position", - # Education amenities — schools come from GIAS instead. OSM coverage for + # Education amenities. Schools come from GIAS instead. OSM coverage for # tertiary education, tutoring, and childcare is too noisy/incomplete to be # useful on a property-search map. "amenity/school", @@ -398,7 +398,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [ "tourism/theme_park", # bicycle_rental/boat_rental/marina/slipway used to live here and # made up ~46% of the bucket (cycle-hire docks, boat ramps); they - # are infrastructure, not entertainment venues — see DROP_CATEGORIES. + # are infrastructure, not entertainment venues. See DROP_CATEGORIES. "leisure/hackerspace", "leisure/yes", ], @@ -722,7 +722,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [ [ "leisure/fitness_centre", # leisure/fitness_station (outdoor pull-up bars / trim-trail - # apparatus, ~2.5k) is not a gym — see DROP_CATEGORIES. + # apparatus, ~2.5k) is not a gym. See DROP_CATEGORIES. "amenity/dojo", "amenity/dancing_school", ], @@ -849,8 +849,8 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [ "healthcare/pharmacy", "shop/chemist", # healthcare/alternative, shop/herbalist and shop/health (homeopaths, - # herbalists, generic "health" shops) are not dispensing pharmacies - # — see DROP_CATEGORIES. + # herbalists, generic "health" shops) are not dispensing pharmacies. + # See DROP_CATEGORIES. ], ), # "Hospital & Clinic" used to be one bucket; an actual hospital and a small @@ -878,7 +878,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [ "healthcare/laboratory", "healthcare/rehabilitation", "healthcare/vaccination_centre", - # healthcare/yes (untyped junk rows) is dropped — see DROP_CATEGORIES. + # healthcare/yes (untyped junk rows) is dropped. See DROP_CATEGORIES. ], ), ( @@ -950,7 +950,7 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [ [ "tourism/gallery", # tourism/artwork (statues, murals, village signs) was 93% of this - # bucket and is not a visitable gallery — see DROP_CATEGORIES. + # bucket and is not a visitable gallery. See DROP_CATEGORIES. ], ), ( @@ -1420,7 +1420,7 @@ def _school_icon_category_expr() -> pl.Expr: # GIAS phase mixes casing ("Middle deemed Primary" vs "Middle deemed # primary") so we normalise before matching. phase = pl.col("phase").str.to_lowercase() - # gias._format_age_range emits three shapes: "" (em-dash), + # gias._format_age_range emits three shapes: "" (en-dash), # "up to " (high-only) and "+" (low-only). Extract the leading # integer as low and the trailing integer as high, then suppress the wrong # end for the one-sided shapes so they don't collapse to a single bound. @@ -1477,7 +1477,7 @@ def _load_ofsted_ratings(ofsted_path: Path) -> pl.LazyFrame: the conventional Ofsted labels; when there is no usable graded result (null/"Not judged", e.g. schools last seen under the post-2024 ungraded report-card framework) we fall back to "Ungraded inspection overall outcome" - so genuinely good/outstanding schools aren't dropped — mirroring + so genuinely good/outstanding schools aren't dropped, mirroring school_catchments.classify_good_plus_schools. Remaining nulls drop out.""" grade_col = pl.col("Latest OEIF overall effectiveness") # See school_catchments: the ungraded outcome carries "School remains Good"/ @@ -1682,7 +1682,7 @@ def osm_groceries_colocated_with_geolytix( An OSM Groceries row is a duplicate when a GEOLYTIX point lies within ``radius_m`` metres AND that point's brand tokens (its ``category``, e.g. - "Tesco", "Co-op", "M&S") are all present in the OSM row's name — i.e. the + "Tesco", "Co-op", "M&S") are all present in the OSM row's name, i.e. the same physical branded store. Short brands like "M&S" match via _GROCERY_SHORT_BRAND_TOKENS; brands that still tokenise to set() are kept. @@ -1703,7 +1703,7 @@ def osm_groceries_colocated_with_geolytix( osm_ids = osm_groceries["id"].to_list() osm_name_tokens = [_significant_tokens(n) for n in osm_groceries["name"].to_list()] - # Equirectangular projection to metres around the shared mean latitude — at + # Equirectangular projection to metres around the shared mean latitude. At # England's scale this is accurate to well under the dedup radius. mean_lat = float(np.mean(np.concatenate([glx_lat, osm_lat]))) cos_lat = float(np.cos(np.radians(mean_lat))) @@ -1860,7 +1860,7 @@ def transform( ) # Scope the drop to the Groceries group: a single OSM object can also # carry a non-grocery aspect (e.g. a convenience store that is also a - # Post Office), which must survive — only its duplicate grocery row goes. + # Post Office), which must survive. Only its duplicate grocery row goes. lf = lf.filter( ~((pl.col("group") == "Groceries") & pl.col("id").is_in(duplicate_ids)) ) diff --git a/pipeline/transform/tree_density.py b/pipeline/transform/tree_density.py index 75ba03e..8eb7275 100644 --- a/pipeline/transform/tree_density.py +++ b/pipeline/transform/tree_density.py @@ -45,7 +45,7 @@ POSTCODE_DENSITY_PERCENTILE_COL = "Tree canopy density percentile within {radius POSTCODE_AREA_COL = "Tree canopy area within {radius}m (sqm)" POSTCODE_HEIGHT_COL = "Mean TOW height within {radius}m (m)" -# National Forest Inventory (NFI) woodland — the geometric complement of TOW. +# National Forest Inventory (NFI) woodland: the geometric complement of TOW. # NFI ships as a zipped shapefile of woodland parcels (>=0.5 ha) in EPSG:27700. # Field names are from the NFI Woodland England 2022 release; re-check on bumps. NFI_CATEGORY_COL = "CATEGORY" @@ -263,7 +263,7 @@ def _postcode_buffers( return circles, shapely.STRtree(circles) -# 0.1 mm in the BNG working CRS (EPSG:27700) — far below survey resolution; the +# 0.1 mm in the BNG working CRS (EPSG:27700), far below survey resolution; the # same grid the postcode_boundaries overlay uses. _OVERLAY_GRID_M = 1e-4 @@ -274,13 +274,13 @@ def _robust_intersection_area(a: np.ndarray, b: np.ndarray) -> np.ndarray: External Forest Research TOW/NFI polygons are occasionally invalid (self-intersections), and a single bad polygon makes the batched ``shapely.intersection`` raise ``TopologyException: side location conflict``, - aborting the whole run. The fast path is the raw batched overlay — unchanged, - full-speed, when the data is clean — and only a failure triggers repair. + aborting the whole run. The fast path is the raw batched overlay (unchanged, + full-speed, when the data is clean), and only a failure triggers repair. The repair deliberately uses a *plain* overlay rather than the fixed-precision (``grid_size``) one: ``make_valid`` can emit a mixed-dimension ``GeometryCollection`` (a polygon plus a dangling line), which OverlayNG - rejects with ``Overlay input is mixed-dimension`` — whereas a plain overlay + rejects with ``Overlay input is mixed-dimension``, whereas a plain overlay accepts it, and its non-polygonal debris has zero area and is dropped by the ``clipped_area > 0`` filter downstream anyway. A final pointwise coordinate snap (which never raises) collapses the near-coincident edges behind any @@ -496,7 +496,7 @@ def _finalize_metrics( if over_count: print( f" note: {over_count:,} postcode(s) exceeded 100% raw canopy and were " - "capped — indicates overlapping TOW/NFI canopy within the buffer" + "capped, indicating overlapping TOW/NFI canopy within the buffer" ) mean_height = np.divide( diff --git a/pipeline/utils/test_poi_counts.py b/pipeline/utils/test_poi_counts.py index 59d34c2..008c37d 100644 --- a/pipeline/utils/test_poi_counts.py +++ b/pipeline/utils/test_poi_counts.py @@ -92,7 +92,7 @@ def test_custom_radius(pois): } ) - # 0.01 km = 10m — only the POI at the exact same location should match + # 0.01 km = 10m: only the POI at the exact same location should match result = count_pois_per_postcode(postcodes, pois, groups=POI_GROUPS, radius_km=0.01) # The Restaurant at (51.5074, -0.1278) is at distance 0 assert result["restaurants_0km"][0] >= 1 @@ -135,9 +135,9 @@ def test_min_distance_finds_nearest(postcodes, pois): assert len(result) == 2 ec1a = result.filter(pl.col("postcode") == "EC1A 1BB") - # Rail station is at (51.5073, -0.1277), postcode at (51.5074, -0.1278) — very close + # Rail station is at (51.5073, -0.1277), postcode at (51.5074, -0.1278), very close assert ec1a["train_tube_nearest_km"][0] < 0.05 # within 50m - # Restaurant is co-located — distance ~0 + # Restaurant is co-located: distance ~0 assert ec1a["restaurants_nearest_km"][0] < 0.01 # Far-away postcode should still get the global nearest distance.