From 1f26703dc565b4346da8e685dd312ecaef38e20d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 15 Jun 2026 14:58:42 +0000 Subject: [PATCH] feat(epc-prediction): geo-proximity weighting, per-component (#1227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds a haversine distance kernel into the categorical-mode weighting so a nearer neighbour counts for more — applied ONLY to the components that showed a clear distance signal in the corpus pre-check (age band, wall + floor construction, glazing: homes built/retrofitted together cluster). Roof construction showed no decay and is excluded; heating keeps its coherent donor. Predictor stays pure: weights come from target.coordinates vs each Comparable.coordinates (resolved at the boundary); geo is OFF when the target has no coords, neutral for a neighbour with none. Scale chosen on the harness: _GEO_SCALE_KM=0.1 is the gate-safe optimum (0.05 lifts the corpus more but regresses fixture floor_construction). Corpus (150pc/514, geo off->on): age 0.564->0.572, age_pm1 0.841->0.847, wall 0.902->0.912, floor_con 0.786->0.796, glazing 0.667->0.673; roof unchanged. Fixture: glazing 0.5278->0.5833 (floor ratcheted), all else held. Refactored recency into a reusable _recency_weights vector composed via _combine, so similarity/recency/geo factors multiply uniformly. Fixture ships a committed _coordinates.json (OGL OS OpenData; build script carries it from the corpus sidecar on rebuild) so the gate exercises geo without S3. This is the per-component method applied to geography ([[feedback_per_component_best_method]]). Co-Authored-By: Claude Opus 4.8 --- domain/epc_prediction/epc_prediction.py | 148 ++++++++++++++---- scripts/build_epc_prediction_fixture.py | 23 +++ .../test_component_accuracy_gate.py | 2 +- .../epc_prediction/test_epc_prediction.py | 49 ++++++ .../fixtures/epc_prediction/_coordinates.json | 1 + 5 files changed, 189 insertions(+), 34 deletions(-) create mode 100644 tests/fixtures/epc_prediction/_coordinates.json diff --git a/domain/epc_prediction/epc_prediction.py b/domain/epc_prediction/epc_prediction.py index a04a2975..532e491c 100644 --- a/domain/epc_prediction/epc_prediction.py +++ b/domain/epc_prediction/epc_prediction.py @@ -27,6 +27,7 @@ from domain.epc_prediction.comparable_properties import ( ComparableProperties, PredictionTarget, ) +from domain.geospatial.coordinates import Coordinates @dataclass(frozen=True) @@ -64,8 +65,8 @@ class EpcPrediction: template: Comparable = self._template(comparables) predicted: EpcPropertyData = copy.deepcopy(template.epc) predicted.total_floor_area_m2 = _median_floor_area(comparables.members) - self._apply_categorical_modes(predicted, comparables) - self._apply_glazing_mode(predicted, comparables) + self._apply_categorical_modes(predicted, comparables, target.coordinates) + self._apply_glazing_mode(predicted, comparables, target.coordinates) self._apply_heating_donor(predicted, comparables) self._apply_overrides(predicted, target) return predicted @@ -93,16 +94,23 @@ class EpcPrediction: @staticmethod def _apply_glazing_mode( - predicted: EpcPropertyData, comparables: ComparableProperties + predicted: EpcPropertyData, + comparables: ComparableProperties, + target_coordinates: Optional[Coordinates], ) -> None: - """Set every window's glazing type to the recency-weighted cohort mode. - Glazing is retrofitted over a dwelling's life (single → double), so a - recent neighbour reflects the current state — its correct method is the - recency-weighted mode (like roof insulation), NOT the plain mode (which - regressed) or the template copy. The window geometry (size, count) is - left on the template; only the glazing categorical moves.""" - glazing = _recency_weighted_choice( - comparables.members, _comparable_modal_glazing + """Set every window's glazing type to the recency- and geo-weighted cohort + mode. Glazing is retrofitted over a dwelling's life (single → double), so + a recent neighbour reflects the current state (recency, like roof + insulation); it also varies geographically (retrofit waves by street), so + a nearer neighbour counts for more. NOT the plain mode (which regressed) + or the template copy. The window geometry (size, count) is left on the + template; only the glazing categorical moves.""" + members = comparables.members + weights = _combine( + _recency_weights(members), _geo_weights(target_coordinates, members) + ) + glazing = _weighted_mode( + (_comparable_modal_glazing(c) for c in members), weights ) if glazing is None: return @@ -152,27 +160,37 @@ class EpcPrediction: @staticmethod def _apply_categorical_modes( - predicted: EpcPropertyData, comparables: ComparableProperties + predicted: EpcPropertyData, + comparables: ComparableProperties, + target_coordinates: Optional[Coordinates], ) -> None: """Override the predicted picture's homogeneous categoricals — wall / roof / floor construction + insulation, age band — with the cohort mode (robust to an atypical template, per ADR-0029 decision 4). The mode is physically-similarity-weighted (decision 5): each neighbour's vote decays with its distance from the cohort's physical centre, so the mode leans on - the most representative neighbours rather than treating every survivor - equally. The template still supplies the geometry; only the categorical - codes move to the mode. (Glazing type is deliberately left on the - template — moding it is marginal and noisy; revisit with a larger - corpus.)""" + the most representative neighbours. The components that vary + *geographically* — age band, wall construction, floor construction (homes + built together cluster) — additionally take a geo-proximity weight, so a + nearer neighbour counts for more; the rest (e.g. roof construction, which + showed no geo signal) do not. The template still supplies the geometry; + only the categorical codes move to the mode.""" if not predicted.sap_building_parts: return main: SapBuildingPart = predicted.sap_building_parts[0] members = comparables.members - weights: list[float] = _similarity_weights(members) + similarity: list[float] = _similarity_weights(members) + geo: list[float] = _geo_weights(target_coordinates, members) + similarity_geo: list[float] = _combine(similarity, geo) for attr in _MAIN_PART_CATEGORICALS: if attr in _RECENCY_WEIGHTED_CATEGORICALS: mode = _recency_weighted_mode(members, attr) else: + weights = ( + similarity_geo + if attr in _GEO_WEIGHTED_CATEGORICALS + else similarity + ) mode = _weighted_mode( (_main_part_attr(c, attr) for c in members), weights ) @@ -181,8 +199,13 @@ class EpcPrediction: floor_dims = main.sap_floor_dimensions if floor_dims: for attr in _FLOOR_DIM_CATEGORICALS: + floor_weights = ( + similarity_geo + if attr in _GEO_WEIGHTED_CATEGORICALS + else similarity + ) floor_mode = _weighted_int_mode( - (_main_floor_attr(c, attr) for c in members), weights + (_main_floor_attr(c, attr) for c in members), floor_weights ) if floor_mode is not None: setattr(floor_dims[0], attr, floor_mode) @@ -241,6 +264,19 @@ _SIMILARITY_SIZE_SCALE_M2: float = 20.0 _SIMILARITY_AGE_WEIGHT: float = 0.5 _AGE_BAND_ORDER: str = "ABCDEFGHIJKL" +# Geo-proximity weighting (#1227): a neighbour's vote decays with its haversine +# distance to the target, so a closer neighbour counts for more. Applied only to +# the components that showed a clear distance signal in the corpus — age band, +# wall + floor construction, glazing (homes built / retrofitted together cluster); +# roof construction showed no decay, so it is excluded. `_GEO_SCALE_KM` is the +# kernel length-scale (chosen on the corpus). Off when the target has no +# coordinates; neutral for a neighbour with none (never penalised for missing +# data). floor_construction lives on the floor dimension but shares this set. +_GEO_SCALE_KM: float = 0.1 +_GEO_WEIGHTED_CATEGORICALS: frozenset[str] = frozenset( + {"construction_age_band", "wall_construction", "floor_construction"} +) + def _main_part_attr( comparable: Comparable, attr: str @@ -347,6 +383,62 @@ def _modal_share( return modal_count / len(present) +def _combine(left: list[float], right: list[float]) -> list[float]: + """Element-wise product of two aligned weight vectors (compose weighting + factors, e.g. similarity × geo-proximity).""" + return [a * b for a, b in zip(left, right)] + + +def _haversine_km(origin: Coordinates, point: Coordinates) -> float: + """Great-circle distance in km between two WGS84 points.""" + radius_km = 6371.0 + lat1, lat2 = math.radians(origin.latitude), math.radians(point.latitude) + delta_lat = lat2 - lat1 + delta_lon = math.radians(point.longitude - origin.longitude) + h = ( + math.sin(delta_lat / 2) ** 2 + + math.cos(lat1) * math.cos(lat2) * math.sin(delta_lon / 2) ** 2 + ) + return 2 * radius_km * math.asin(min(1.0, math.sqrt(h))) + + +def _geo_weights( + target: Optional[Coordinates], members: tuple[Comparable, ...] +) -> list[float]: + """A geo-proximity weight per comparable — an exponential decay in haversine + distance to the target. All-neutral (1.0) when the target has no coordinates + (geo weighting off) or a neighbour has none (never penalised for absent + data); aligned with `members` index-for-index.""" + if target is None: + return [1.0] * len(members) + weights: list[float] = [] + for comparable in members: + coordinates = comparable.coordinates + if coordinates is None: + weights.append(1.0) + else: + weights.append( + math.exp(-_haversine_km(target, coordinates) / _GEO_SCALE_KM) + ) + return weights + + +def _recency_weights(members: tuple[Comparable, ...]) -> list[float]: + """A recency weight per comparable — exponential decay in the cert's age + relative to the newest in the cohort, so newer neighbours dominate. All-equal + when no registration dates are lodged. Aligned with `members`.""" + newest: date = max( + (c.registration_date or date.min for c in members), default=date.min + ) + return [ + math.exp( + -((newest - (c.registration_date or date.min)).days / _DAYS_PER_YEAR) + / _RECENCY_TAU_YEARS + ) + for c in members + ] + + def _recency_weighted_choice( members: tuple[Comparable, ...], value_of: Callable[[Comparable], Optional[Union[int, str]]], @@ -357,21 +449,11 @@ def _recency_weighted_choice( outvote the current state. Falls back to a plain mode when no registration dates are lodged (all ages 0 ⇒ equal weight). Returns None when no comparable supplies a value. Used for the time-varying components — those upgraded over a - dwelling's life (loft top-ups, glazing retrofits).""" - newest: date = max( - (c.registration_date or date.min for c in members), default=date.min + dwelling's life (loft top-ups).""" + return _weighted_mode( + (value_of(comparable) for comparable in members), + _recency_weights(members), ) - weights: dict[Union[int, str], float] = defaultdict(float) - for comparable in members: - value = value_of(comparable) - if value is None: - continue - lodged: date = comparable.registration_date or date.min - age_years: float = (newest - lodged).days / _DAYS_PER_YEAR - weights[value] += math.exp(-age_years / _RECENCY_TAU_YEARS) - if not weights: - return None - return max(weights, key=lambda value: weights[value]) def _recency_weighted_mode( diff --git a/scripts/build_epc_prediction_fixture.py b/scripts/build_epc_prediction_fixture.py index f1b83c7a..5ab82554 100644 --- a/scripts/build_epc_prediction_fixture.py +++ b/scripts/build_epc_prediction_fixture.py @@ -65,6 +65,7 @@ def main() -> None: (SOURCE / "_index.json").read_text() ) fixture_index: dict[str, list[str]] = {} + kept_uprns: set[str] = set() total_certs = 0 for postcode, certs in index.items(): if len(fixture_index) >= _MAX_POSTCODES: @@ -80,15 +81,37 @@ def main() -> None: out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(anon)) kept.append(cert_token) + uprn = raw.get("uprn") + if uprn is not None: + kept_uprns.add(str(int(uprn))) fixture_index[postcode] = kept total_certs += len(kept) (FIXTURE / "_index.json").parent.mkdir(parents=True, exist_ok=True) (FIXTURE / "_index.json").write_text(json.dumps(fixture_index, indent=2)) + _write_coordinates(kept_uprns) print( f"wrote {len(fixture_index)} postcodes / {total_certs} anonymised certs " f"to {FIXTURE}" ) +def _write_coordinates(kept_uprns: set[str]) -> None: + """Carry the geo-proximity coordinates for the kept UPRNs into the committed + fixture (subset of the corpus `_coordinates.json`), so the gate exercises + geo-weighting without S3. Skipped when the corpus has no coordinates sidecar. + Coordinates are OS OpenData (OGL) and add no identifiability beyond the UPRN + already kept in the fixture.""" + source = SOURCE / "_coordinates.json" + if not source.exists(): + return + corpus_coords: dict[str, list[float]] = json.loads(source.read_text()) + fixture_coords = { + uprn: corpus_coords[uprn] + for uprn in kept_uprns + if uprn in corpus_coords + } + (FIXTURE / "_coordinates.json").write_text(json.dumps(fixture_coords)) + + if __name__ == "__main__": main() diff --git a/tests/domain/epc_prediction/test_component_accuracy_gate.py b/tests/domain/epc_prediction/test_component_accuracy_gate.py index 71e19fd1..16e663f8 100644 --- a/tests/domain/epc_prediction/test_component_accuracy_gate.py +++ b/tests/domain/epc_prediction/test_component_accuracy_gate.py @@ -48,7 +48,7 @@ _RATE_FLOORS: dict[str, float] = { "roof_insulation_thickness_pm1": 0.4118, "floor_insulation": 0.9375, "has_room_in_roof": 0.8333, - "modal_glazing_type": 0.5278, + "modal_glazing_type": 0.5833, "has_pv": 1.0000, "solar_water_heating": 1.0000, } diff --git a/tests/domain/epc_prediction/test_epc_prediction.py b/tests/domain/epc_prediction/test_epc_prediction.py index 8aaec7f3..1df0d56d 100644 --- a/tests/domain/epc_prediction/test_epc_prediction.py +++ b/tests/domain/epc_prediction/test_epc_prediction.py @@ -16,6 +16,7 @@ from datatypes.epc.domain.epc_property_data import ( SapHeating, SapWindow, ) +from domain.geospatial.coordinates import Coordinates from domain.epc_prediction.comparable_properties import ( Comparable, ComparableProperties, @@ -429,6 +430,54 @@ def test_glazing_follows_the_recency_weighted_cohort_mode() -> None: assert all(window.glazing_type == 3 for window in predicted.sap_windows) +def test_geo_proximity_weights_the_nearest_neighbour() -> None: + # Arrange — same size + age (so similarity weighting is uniform). Three FAR + # neighbours are cavity (1); one neighbour AT the target is solid brick (2). + # wall construction is a geo-weighted component, so the near neighbour + # outweighs the far majority. + here = Coordinates(longitude=0.0, latitude=0.0) + far = Coordinates(longitude=1.0, latitude=1.0) # ~150 km away + cohort = ComparableProperties( + members=( + Comparable(_epc(wall_construction=1), "1", coordinates=far), + Comparable(_epc(wall_construction=1), "2", coordinates=far), + Comparable(_epc(wall_construction=1), "3", coordinates=far), + Comparable(_epc(wall_construction=2), "4", coordinates=here), + ) + ) + target = PredictionTarget( + postcode="LS6 1AA", property_type="2", coordinates=here + ) + + # Act + predicted: EpcPropertyData = EpcPrediction().predict(target, cohort) + + # Assert — the near neighbour's wall wins over the far majority. + assert predicted.sap_building_parts[0].wall_construction == 2 + + +def test_geo_proximity_is_off_without_target_coordinates() -> None: + # Arrange — identical cohort, but the target has no coordinates, so geo + # weighting is disabled and the plain cohort majority (cavity, 1) wins. + here = Coordinates(longitude=0.0, latitude=0.0) + far = Coordinates(longitude=1.0, latitude=1.0) + cohort = ComparableProperties( + members=( + Comparable(_epc(wall_construction=1), "1", coordinates=far), + Comparable(_epc(wall_construction=1), "2", coordinates=far), + Comparable(_epc(wall_construction=1), "3", coordinates=far), + Comparable(_epc(wall_construction=2), "4", coordinates=here), + ) + ) + target = PredictionTarget(postcode="LS6 1AA", property_type="2") + + # Act + predicted: EpcPropertyData = EpcPrediction().predict(target, cohort) + + # Assert — without target coordinates, the majority wins (geo off). + assert predicted.sap_building_parts[0].wall_construction == 1 + + def test_applies_a_known_wall_override_over_the_mode() -> None: # Arrange — the cohort mode is cavity (1), but we KNOW the target is solid # brick (2), a Landlord Override. The known value must win over the estimate. diff --git a/tests/fixtures/epc_prediction/_coordinates.json b/tests/fixtures/epc_prediction/_coordinates.json new file mode 100644 index 00000000..9453d363 --- /dev/null +++ b/tests/fixtures/epc_prediction/_coordinates.json @@ -0,0 +1 @@ +{"10000796832": [-4.1133041, 50.3834015], "10000796833": [-4.1132369, 50.383409], "10000796835": [-4.1131012, 50.3834187], "10000796836": [-4.1130173, 50.3834342], "10000796837": [-4.1129365, 50.3834387], "10000796838": [-4.1128724, 50.3834497], "10000796839": [-4.1127981, 50.383452], "100021408528": [0.2533413, 51.5445726], "100021408529": [0.2530653, 51.5443274], "100021408530": [0.2534272, 51.5445038], "100021408531": [0.2531532, 51.5442804], "100021408533": [0.2532239, 51.5442099], "100021408534": [0.2536266, 51.5444153], "100021408537": [0.2533684, 51.5440718], "100021408541": [0.2534373, 51.5438067], "100021408542": [0.2538788, 51.544143], "100021408545": [0.2534824, 51.5436614], "100021408547": [0.2535236, 51.5435907], "100021408549": [0.2534377, 51.5435018], "100021408550": [0.2539776, 51.5438442], "100021408551": [0.2534967, 51.5434437], "100021408552": [0.2539885, 51.5437796], "100021408553": [0.2534557, 51.543349], "100021408554": [0.2540375, 51.5436961], "100021408557": [0.2540901, 51.5434687], "100021408559": [0.2541231, 51.5433195], "100022784848": [-0.2115926, 51.5310282], "100022784850": [-0.2116643, 51.5310383], "100022784858": [-0.2119243, 51.5310243], "100022784860": [-0.2120112, 51.5310166], "100022784864": [-0.2121264, 51.5310183], "100022784866": [-0.2121841, 51.5310192], "100022784868": [-0.2122417, 51.5310201], "100022784872": [-0.2123717, 51.5310131], "100022784874": [-0.2124441, 51.5310052], "100022784876": [-0.2125309, 51.5309975], "100022784886": [-0.211587, 51.5308033], "100022784888": [-0.2117023, 51.5308051], "100022784890": [-0.2118326, 51.5307891], "100022784892": [-0.2119908, 51.5308005], "100022784897": [-0.2122797, 51.5307869], "100022784899": [-0.2124245, 51.5307711], "100022784900": [-0.2124825, 51.530763], "100022784902": [-0.2126125, 51.530756], "100030465784": [-1.2323397, 52.7577937], "100030465789": [-1.2318599, 52.7575799], "100030465791": [-1.2316645, 52.7574789], "100030465795": [-1.2315068, 52.7573582], "100030465799": [-1.2313538, 52.7572406], "100030465801": [-1.2313077, 52.7571946], "100030465803": [-1.2312105, 52.7571228], "100030465811": [-1.2307981, 52.7568182], "100030465815": [-1.2305564, 52.7566543], "100030465833": [-1.2268354, 52.756818], "100030465837": [-1.2266034, 52.7566601], "100030465839": [-1.2265244, 52.7565966], "100030465844": [-1.2261953, 52.7563553], "100030465850": [-1.2258443, 52.7560454], "100040471327": [-4.1163877, 50.3832253], "100040471329": [-4.1162327, 50.3832191], "100040471336": [-4.1160226, 50.3832409], "100040471338": [-4.1159528, 50.3832512], "100040471350": [-4.1155741, 50.3832761], "100040471356": [-4.115364, 50.3832979], "100040471368": [-4.1149146, 50.383315], "100040471383": [-4.1144094, 50.3833422], "100040471385": [-4.1143391, 50.3833435], "100040471389": [-4.1142688, 50.3833448], "100040471395": [-4.114115, 50.3833656], "100040471400": [-4.1139741, 50.3833591], "100040471402": [-4.1138897, 50.3833607], "100040471409": [-4.11375, 50.3833812], "100040471411": [-4.1136801, 50.3833914], "100040471417": [-4.1134692, 50.3833953], "100051188769": [-1.7336723, 53.8133624], "100051188771": [-1.7338082, 53.8134796], "100051188774": [-1.7343552, 53.8134448], "100051188775": [-1.7339738, 53.8137136], "100051188776": [-1.7344154, 53.8135259], "100051188777": [-1.7340793, 53.8138397], "100051188778": [-1.7344607, 53.8135709], "100051188779": [-1.7341544, 53.8139837], "100051188780": [-1.7345209, 53.8136609], "100051188786": [-1.7346564, 53.813841], "100051188787": [-1.7349262, 53.8144168], "100051188788": [-1.7347319, 53.813922], "100051188789": [-1.7351542, 53.8143903], "100051188793": [-1.7356266, 53.8141397], "100051188796": [-1.7351566, 53.8140039], "100051188797": [-1.7357798, 53.8139243], "100051188798": [-1.7351874, 53.813941], "100051188801": [-1.7359936, 53.8137361], "100051188804": [-1.7353859, 53.8137617], "100051188806": [-1.7354318, 53.8137168], "100051188809": [-1.7355541, 53.8135913], "100061404210": [-0.5496017, 51.2442576], "100061404211": [-0.5495251, 51.2442811], "100061404212": [-0.5493686, 51.2443295], "100061404214": [-0.5493368, 51.2446181], "100061404223": [-0.5502158, 51.2447493], "100061404225": [-0.5502774, 51.2452345], "100061404226": [-0.5505002, 51.2451975], "100061404228": [-0.5507137, 51.2450263], "100061404229": [-0.550977, 51.2450406], "100061404231": [-0.5512038, 51.2448903], "100061404233": [-0.5509499, 51.2446685], "100061404235": [-0.5506393, 51.2445763], "100061404237": [-0.5504821, 51.2443993], "100061404239": [-0.550379, 51.2442493], "100071318864": [-1.5030685, 52.4104769], "100090075242": [-0.112468, 52.565015], "100090075244": [-0.1125134, 52.5649887], "100090075246": [-0.1125875, 52.5649809], "100090075253": [-0.1129399, 52.5650225], "100090075255": [-0.1128972, 52.5649859], "100090075257": [-0.112799, 52.5648674], "100090075259": [-0.1128587, 52.5648504], "100090075261": [-0.1129041, 52.5648241], "100090075267": [-0.113315, 52.5648757], "100090075269": [-0.1132832, 52.5649291], "100090075271": [-0.1133116, 52.5649565], "100090075273": [-0.1133547, 52.5649842], "100090075275": [-0.1133974, 52.5650208], "100090075277": [-0.1134257, 52.5650482], "100090075281": [-0.1131378, 52.5652235], "100090075283": [-0.1130781, 52.5652405], "100090075285": [-0.1130327, 52.5652668], "100090075287": [-0.1129124, 52.5653188], "100090075289": [-0.1128379, 52.5653356], "100090075291": [-0.1127333, 52.565402], "100090075293": [-0.1126859, 52.5654411], "100090075295": [-0.1125664, 52.5654751], "100090075297": [-0.1124604, 52.5654938], "100100583977": [-3.4059402, 51.75485], "100100583978": [-3.4060402, 51.7548038], "100100583979": [-3.406356, 51.7547101], "100100583980": [-3.4064426, 51.7547001], "100100583981": [-3.4065579, 51.7546807], "100100583982": [-3.4066298, 51.7546619], "100100583983": [-3.4067161, 51.7546429], "100100583984": [-3.4068024, 51.7546239], "10023041043": [-1.5026578, 52.4103943], "10090853539": [0.0018835, 51.530142], "10091715279": [-1.5030685, 52.4104769], "10091715280": [-1.5030685, 52.4104769], "10091715281": [-1.5030685, 52.4104769], "10093129133": [0.0018835, 51.530142], "10093129134": [0.0018835, 51.530142], "10093129137": [0.0018835, 51.530142], "10093129143": [0.0018835, 51.530142], "10093129146": [0.0018835, 51.530142], "10093129152": [0.0018835, 51.530142], "10093129155": [0.0018835, 51.530142], "10093129157": [0.0018835, 51.530142], "10093129168": [0.0018835, 51.530142], "10093129169": [0.0018835, 51.530142], "10093129170": [0.0018835, 51.530142], "10093129171": [0.0018835, 51.530142], "10093129172": [0.0018835, 51.530142], "10093129176": [0.0018835, 51.530142], "10093129181": [0.0018835, 51.530142], "10093129186": [0.0018835, 51.530142], "10093129188": [0.0018835, 51.530142], "10093129190": [0.0018835, 51.530142], "10093129191": [0.0018835, 51.530142], "10093129196": [0.0018835, 51.530142], "10093945833": [-1.5028045, 52.4104219], "11010967": [-0.0753479, 53.569072], "11010968": [-0.0751069, 53.5690591], "11028505": [-0.0757651, 53.5691956], "11044320": [-0.0754833, 53.5690832], "11048258": [-0.0750019, 53.5690465], "207112000": [-0.0719068, 51.6423193], "207112001": [-0.0716339, 51.6422789], "207112295": [-0.0715328, 51.6422772], "207112979": [-0.0710874, 51.6422159], "207113820": [-0.0714032, 51.6422661], "207113821": [-0.0713029, 51.6422464], "207113822": [-0.0711869, 51.6422535]} \ No newline at end of file