From 24b802bfe2e54e84ba1d267a90d09b9b7de01cbc Mon Sep 17 00:00:00 2001 From: Turkov Leonid <100690100+LeonDeTur@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:44:57 +0300 Subject: [PATCH 1/3] fix: explain missing service normatives Return territory and service identifiers with an actionable missing-normative error instead of indexing an empty DataFrame. Preserve successful results for other service types. --- app/common/modules/effects_api_gateway.py | 45 +++++++++--- app/provision/provision_mcp.py | 4 +- tests/test_service_normatives.py | 87 +++++++++++++++++++++++ 3 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 tests/test_service_normatives.py diff --git a/app/common/modules/effects_api_gateway.py b/app/common/modules/effects_api_gateway.py index 52b7f34..12b307f 100644 --- a/app/common/modules/effects_api_gateway.py +++ b/app/common/modules/effects_api_gateway.py @@ -64,6 +64,10 @@ async def get_service_normative( headers={USER_ID_HEADER: token} if token else None, ) request_ter_id = territory_id + if not response: + raise self._missing_service_normative( + territory_id, context_ids, request_ter_id, service_type_id, [] + ) response_df = pd.DataFrame.from_records(response) response_df["service_type_id"] = response_df["service_type"].apply( lambda x: x["id"] if x else None @@ -72,17 +76,12 @@ async def get_service_normative( response_df["service_type_id"] == service_type_id ].copy() if len(service_type) < 1: - raise http_exception( - 400, - msg="Service type id not found in urban_db for provided territory/context ids. ", - _input={ - "territory_id": territory_id, - "context_ids": context_ids, - "service_type_id": service_type_id, - }, - _detail={ - "Available service ids": response_df["service_type_id"].to_list() - }, + raise self._missing_service_normative( + territory_id, + context_ids, + request_ter_id, + service_type_id, + response_df["service_type_id"].dropna().to_list(), ) service_type = ( @@ -140,6 +139,30 @@ async def get_service_normative( _detail={"Available service ids": response_df["service_type_id"].to_list()}, ) + @staticmethod + def _missing_service_normative( + territory_id, context_ids, request_ter_id, service_type_id, available_ids + ): + return http_exception( + 400, + msg="Норматив обеспеченности для выбранного вида услуг не задан в Urban API.", + _input={ + "territory_id": territory_id, + "context_ids": context_ids, + "request_ter_id": request_ter_id, + "service_type_id": service_type_id, + }, + _detail={ + "code": "missing_service_normative", + "Available service ids": available_ids, + "required_action": ( + f"Укажите или добавьте в Urban API норматив для территории {request_ter_id} " + f"и вида услуг {service_type_id}: радиус или время доступности и норму " + "обеспеченности. Либо выберите территорию с заданным нормативом." + ), + }, + ) + async def get_project_data( self, project_id: int, token: str ) -> dict[str, int | dict]: diff --git a/app/provision/provision_mcp.py b/app/provision/provision_mcp.py index 43ff8c4..d63e9d7 100644 --- a/app/provision/provision_mcp.py +++ b/app/provision/provision_mcp.py @@ -52,9 +52,7 @@ async def calc_service_provision( service_type_id=service_type_id, target_population=target_population, ) - result = await provision_mcp_service.calculate_provision( - provision_dto, user_id - ) + result = await provision_mcp_service.calculate_provision(provision_dto, user_id) return result.model_dump() except Exception as e: tb = traceback.format_exc() diff --git a/tests/test_service_normatives.py b/tests/test_service_normatives.py new file mode 100644 index 0000000..fbed7bb --- /dev/null +++ b/tests/test_service_normatives.py @@ -0,0 +1,87 @@ +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from app.common.modules.effects_api_gateway import EffectsAPIGateway + + +@pytest.mark.asyncio +@pytest.mark.parametrize("context_ids,requested", [([7], 7), ([], 9), ([7, 8], 9)]) +async def test_empty_normatives_explain_missing_input(context_ids, requested): + handler = AsyncMock() + handler.get.return_value = [] + with pytest.raises(HTTPException) as error: + await EffectsAPIGateway(handler).get_service_normative(9, context_ids, 22, "u1") + assert error.value.status_code == 400 + assert error.value.detail["detail"]["code"] == "missing_service_normative" + assert error.value.detail["input"]["request_ter_id"] == requested + assert error.value.detail["detail"]["Available service ids"] == [] + assert error.value.detail["detail"]["required_action"] + handler.get.assert_awaited_once_with( + f"/api/v1/territory/{requested}/normatives", headers={"X-User-Id": "u1"} + ) + + +@pytest.mark.asyncio +async def test_latest_available_normative_is_preserved(): + handler = AsyncMock() + handler.get.return_value = [ + { + "service_type": {"id": 22}, + "year": year, + "radius_availability_meters": radius, + "time_availability_minutes": None, + "services_per_1000_normative": None, + } + for year, radius in [(2020, 500), (2025, 750)] + ] + result = await EffectsAPIGateway(handler).get_service_normative(9, [], 22, "u1") + assert ( + result["normative_value"], + result["normative_type"], + result["capacity_type"], + ) == (750, "dist", "capacity") + + +@pytest.mark.asyncio +async def test_missing_service_in_nonempty_catalog_is_actionable(): + handler = AsyncMock() + handler.get.return_value = [{"service_type": {"id": 21}, "year": 2025}] + with pytest.raises(HTTPException) as error: + await EffectsAPIGateway(handler).get_service_normative(9, [], 22, "u1") + assert error.value.detail["detail"]["code"] == "missing_service_normative" + assert error.value.detail["detail"]["Available service ids"] == [21] + + +@pytest.mark.asyncio +async def test_missing_normative_keeps_other_service_layers(): + import geopandas as gpd + from shapely.geometry import Point + + from app.provision.provision_service import ProvisionService + from app.schemas.provision_base_schema import MultiProvisionRequestSchema + + gateway = AsyncMock() + gateway.get_project_id_by_scenario.return_value = 1 + service = ProvisionService(gateway) + service._fetch_shared_data = AsyncMock(return_value={}) + layer = gpd.GeoDataFrame({"geometry": [Point(30, 60)]}, crs=4326) + service._calculate_for_service = AsyncMock( + side_effect=[ + EffectsAPIGateway._missing_service_normative(9, [], 9, 22, [21]), + {"buildings": layer, "services": layer, "links": layer}, + ] + ) + service._build_summary = lambda **kwargs: None + result = await service.calculate_multi_provision( + MultiProvisionRequestSchema( + scenario_id=123, + services={22: {"name": "Школа"}, 21: {"name": "Детский сад"}}, + ), + "u1", + ) + assert "missing_service_normative" in result.services[22].error + assert result.services[22].layers is None + assert result.services[21].error is None + assert len(result.services[21].layers.buildings.features) == 1 From 70755b5b6d9025ea27af56c3c8bfa2738296074a Mon Sep 17 00:00:00 2001 From: LeonDeTur Date: Mon, 14 Sep 2026 15:05:44 +0300 Subject: [PATCH 2/3] feat: evaluate unsaved planning variants for service provision --- app/provision/provision_mcp.py | 43 +++++++++++++ app/provision/provision_service.py | 29 +++++++++ app/provision/variant.py | 91 ++++++++++++++++++++++++++++ app/schemas/provision_base_schema.py | 15 +++++ docs/variant-provision.md | 38 ++++++++++++ tests/test_variant_provision.py | 75 +++++++++++++++++++++++ 6 files changed, 291 insertions(+) create mode 100644 app/provision/variant.py create mode 100644 docs/variant-provision.md create mode 100644 tests/test_variant_provision.py diff --git a/app/provision/provision_mcp.py b/app/provision/provision_mcp.py index d63e9d7..a4be802 100644 --- a/app/provision/provision_mcp.py +++ b/app/provision/provision_mcp.py @@ -9,11 +9,54 @@ from app.schemas.provision_base_schema import ( MultiProvisionRequestSchema, ServiceInfoSchema, + VariantProvisionRequestSchema, ) provision_mcp = FastMCP("Object Provision MCP server", auth=service_token_verifier) +@provision_mcp.tool( + name="CalculateVariantServicesProvision", + title="Evaluate an unsaved planning variant", + description="Calculate provision using new GenBuilder residential buildings and/or proposed services, " + "preserving existing scenario buildings and services. No Urban API writes. " + "generated_buildings is a WGS84 FeatureCollection with properties.zone=residential and floors_count; " + "is_excluded features are ignored because existing buildings and attributes are retained from Urban. " + "additional_services maps service type IDs to new WGS84 layers with explicit positive capacity and service_type_id. " + "target_population is the TOTAL population of the whole scenario including existing residents. " + "Demand is distributed using the existing floor-area restoration and gravity accessibility model. " + "Call CalculateServicesProvision separately for the baseline; compare summaries and full layers.", +) +async def calc_variant_services_provision( + scenario_id: int, + services: dict[int, ServiceInfoSchema], + target_population: int, + generated_buildings: dict | None = None, + additional_services: dict[int, dict] | None = None, +): + if generated_buildings is None and not additional_services: + raise ValueError( + "A variant requires generated buildings or additional services" + ) + params = VariantProvisionRequestSchema( + scenario_id=scenario_id, + services=services, + target_population=target_population, + generated_buildings=generated_buildings, + additional_services=additional_services or {}, + ) + result = await provision_mcp_service.calculate_multi_provision( + params, get_mcp_user_id() + ) + return { + **result.model_dump(), + "scenario_id": scenario_id, + "target_population": target_population, + "methodology": "Existing buildings and services preserved; population distributed by restored floor area; gravity accessibility model", + "variant": True, + } + + @provision_mcp.tool( name="CalculateServiceProvision", title="Get service provision for scenario", diff --git a/app/provision/provision_service.py b/app/provision/provision_service.py index 8e6d1fc..a1f8cc8 100644 --- a/app/provision/provision_service.py +++ b/app/provision/provision_service.py @@ -14,6 +14,7 @@ objectnat_calculator, ) from app.dto.provision_dto import ProvisionDTO +from app.provision.variant import add_generated_buildings, additional_services from app.schemas.provision_base_schema import ( MultiProvisionRequestSchema, MultiProvisionSchema, @@ -146,6 +147,23 @@ async def _calculate_for_service( services=target_scenario_services, service_default_capacity=service_default_capacity, ) + extra = shared_data.get("additional_services", {}).get(service_type_id) + if extra is not None: + first_id = ( + min( + [ + 0, + *context_services.get("service_id", []), + *target_scenario_services.get("service_id", []), + ] + ) + - 1 + ) + proposed = additional_services(extra, service_type_id, first_id) + target_scenario_services = gpd.GeoDataFrame( + pd.concat([target_scenario_services, proposed], ignore_index=True), + crs=4326, + ) before_buildings = await asyncio.to_thread( pd.concat, objs=[context_buildings, target_scenario_buildings], @@ -288,6 +306,17 @@ async def calculate_multi_provision( ) if multi_params.target_population: shared_data["target_scenario_population"] = multi_params.target_population + generated = getattr(multi_params, "generated_buildings", None) + additions = getattr(multi_params, "additional_services", {}) + if generated is not None: + shared_data["target_scenario_buildings"] = add_generated_buildings( + shared_data["target_scenario_buildings"], generated + ) + if not set(additions) <= set(multi_params.services): + raise ValueError( + "Additional services must belong to requested service types" + ) + shared_data["additional_services"] = additions results = {} for service_type_id, service_info in multi_params.services.items(): try: diff --git a/app/provision/variant.py b/app/provision/variant.py new file mode 100644 index 0000000..95fe1bd --- /dev/null +++ b/app/provision/variant.py @@ -0,0 +1,91 @@ +"""Transient additions to a scenario; never write a design back to Urban API.""" + +from math import isfinite + +import geopandas as gpd +import pandas as pd +from shapely.geometry import shape + + +def validated_features(layer): + if not isinstance(layer, dict) or layer.get("type") != "FeatureCollection": + raise ValueError("A WGS84 FeatureCollection is required") + features = layer.get("features") + if not isinstance(features, list): + raise ValueError("FeatureCollection.features must be a list") + for feature in features: + geometry = shape(feature["geometry"]) + if not geometry.is_valid or geometry.is_empty: + raise ValueError("Variant geometry must be valid and nonempty") + xmin, ymin, xmax, ymax = geometry.bounds + if not (-180 <= xmin <= xmax <= 180 and -90 <= ymin <= ymax <= 90): + raise ValueError("Variant geometry must use WGS84 longitude/latitude") + yield geometry, feature.get("properties") or {} + + +def add_generated_buildings(baseline, layer): + """Keep baseline attributes; GenBuilder excluded features carry zeroed values.""" + rows = [] + first_id = min([0, *baseline.get("building_id", [])]) - 1 + for geometry, props in validated_features(layer): + if props.get("is_excluded"): + continue # Baseline buildings are retained, not replaced by these stubs. + if props.get("zone") != "residential": + continue + floors = props.get("floors_count") + if ( + isinstance(floors, bool) + or not isinstance(floors, (int, float)) + or not isfinite(floors) + or floors <= 0 + ): + raise ValueError( + "Generated residential buildings require positive floors_count" + ) + if geometry.geom_type not in {"Polygon", "MultiPolygon"}: + raise ValueError("Generated buildings must be polygons") + rows.append( + { + "geometry": geometry, + "building_id": first_id - len(rows), + "storeys_count": floors, + } + ) + if not rows: + raise ValueError("No generated residential buildings in supplied variant") + added = gpd.GeoDataFrame(rows, geometry="geometry", crs=4326) + if baseline.empty: + return added + return gpd.GeoDataFrame( + pd.concat([baseline.to_crs(4326), added], ignore_index=True), crs=4326 + ) + + +def additional_services(layer, service_type_id, first_id=-1): + rows = [] + for geometry, props in validated_features(layer): + capacity = props.get("capacity") + if ( + isinstance(capacity, bool) + or not isinstance(capacity, (int, float)) + or not isfinite(capacity) + or capacity <= 0 + ): + raise ValueError("Proposed services require an explicit positive capacity") + if props.get("service_type_id") != service_type_id: + raise ValueError( + "Proposed service_type_id must match its requested service group" + ) + rows.append( + { + "geometry": geometry, + "service_id": first_id - len(rows), + "capacity": capacity, + } + ) + return gpd.GeoDataFrame( + rows, + columns=["geometry", "service_id", "capacity"], + geometry="geometry", + crs=4326, + ) diff --git a/app/schemas/provision_base_schema.py b/app/schemas/provision_base_schema.py index b75ba0a..198d5cf 100644 --- a/app/schemas/provision_base_schema.py +++ b/app/schemas/provision_base_schema.py @@ -85,6 +85,21 @@ class ProvisionSummarySchema(BaseModel): median_provision_value: float | None +class VariantProvisionRequestSchema(MultiProvisionRequestSchema): + target_population: int = Field( + gt=0, + description="Total population of the whole target scenario, including preserved buildings; not only new residents", + ) + generated_buildings: dict | None = Field( + default=None, + description="GenBuilder WGS84 FeatureCollection; new residential buildings are added to existing scenario buildings", + ) + additional_services: dict[int, dict] = Field( + default_factory=dict, + description="New service layers keyed by service type ID; properties.capacity and properties.service_type_id required", + ) + + class ServiceProvisionResultSchema(BaseModel): name: str diff --git a/docs/variant-provision.md b/docs/variant-provision.md new file mode 100644 index 0000000..71b3092 --- /dev/null +++ b/docs/variant-provision.md @@ -0,0 +1,38 @@ +# Provision for unsaved planning variants + +`CalculateVariantServicesProvision` is available on both `/effects/mcp` and +`/provision/mcp`. It supplements `CalculateServicesProvision`; existing tool +names and arguments are unchanged. + +Required arguments are `scenario_id`, `services` (the usual service ID/name/layer +map) and a positive `target_population`. At least one of the following is needed: + +- `generated_buildings`: a WGS84 GeoJSON FeatureCollection from GenBuilder. + Residential features require `properties.zone = "residential"` and positive + `floors_count`. Existing scenario buildings are retained. `is_excluded` features + are ignored because GenBuilder returns them with zeroed attributes; original + Urban attributes must not be overwritten with those values. +- `additional_services`: a map from requested service type ID to a WGS84 + FeatureCollection of new services. Each feature requires the matching + `properties.service_type_id` and an explicit positive `capacity`. Existing + scenario and context services are retained. + +The target is the **total population** of the scenario, including existing +residents, not only residents of newly generated buildings. The calculation uses +the existing floor-area restoration, normative demand and gravity accessibility +model. It does not use GenBuilder's resident count as an implicit population +assignment. Negative temporary IDs identify additions without colliding with +existing IDs. No layer is written to Urban API. + +Run the baseline separately through `CalculateServicesProvision` with the intended +baseline population. The variant response returns the usual `services` summaries +and optional layers, plus `variant`, `scenario_id`, `target_population` and a +methodology note. Missing Urban service normatives remain explicit errors; this +endpoint does not supply numerical defaults or treat missing norms as compliance. +The summary still covers the existing calculator's combined scenario/context +scope; do not relabel it as a scenario-only statistic. + +Consumer coordination: gMART's provision specialist uses artifact references to +pass the full generated data. The new tool is mounted on the existing effects MCP +URL so a separate downstream URL is not needed. Stored tool-chain consumers that +restrict tool names must add this name before replaying variant calls. diff --git a/tests/test_variant_provision.py b/tests/test_variant_provision.py new file mode 100644 index 0000000..cd4783b --- /dev/null +++ b/tests/test_variant_provision.py @@ -0,0 +1,75 @@ +import geopandas as gpd +import pytest +from shapely.geometry import Point, Polygon, mapping + +from app.provision.variant import add_generated_buildings, additional_services +from app.schemas.provision_base_schema import VariantProvisionRequestSchema + + +def feature(properties, geometry=None): + return { + "type": "Feature", + "geometry": mapping( + geometry or Polygon([(28, 61), (28.01, 61), (28.01, 61.01), (28, 61)]) + ), + "properties": properties, + } + + +def layer(*features): + return {"type": "FeatureCollection", "features": list(features)} + + +def test_generated_layer_retains_existing_attributes_and_no_duplicate_stubs(): + baseline = gpd.GeoDataFrame( + [{"geometry": Point(28, 61), "building_id": 10, "storeys_count": 9}], crs=4326 + ) + result = add_generated_buildings( + baseline, + layer( + feature({"is_excluded": True, "physical_object_id": 10, "floors_count": 0}), + feature({"zone": "residential", "floors_count": 5}), + feature({"zone": "business", "floors_count": 3}), + ), + ) + assert len(result) == 2 + assert result.iloc[0].building_id == 10 and result.iloc[0].storeys_count == 9 + assert result.iloc[1].building_id < 0 and result.iloc[1].storeys_count == 5 + assert len(baseline) == 1 + assert result.crs.to_epsg() == 4326 + + +@pytest.mark.parametrize("floors", [0, -1, None, True, float("nan")]) +def test_missing_or_invalid_floors_are_not_invented(floors): + baseline = gpd.GeoDataFrame( + columns=["geometry", "building_id", "storeys_count"], crs=4326 + ) + with pytest.raises(ValueError): + add_generated_buildings( + baseline, layer(feature({"zone": "residential", "floors_count": floors})) + ) + + +def test_service_capacity_type_and_synthetic_identity(): + result = additional_services( + layer(feature({"service_type_id": 22, "capacity": 550}, Point(28, 61))), 22, -8 + ) + assert result.iloc[0].capacity == 550 + assert result.iloc[0].service_id == -8 + with pytest.raises(ValueError): + additional_services( + layer(feature({"service_type_id": 21, "capacity": 550})), 22 + ) + with pytest.raises(ValueError): + additional_services(layer(feature({"service_type_id": 22})), 22) + + +def test_variant_requires_total_population(): + with pytest.raises(ValueError): + VariantProvisionRequestSchema( + scenario_id=772, services={22: {"name": "school"}} + ) + with pytest.raises(ValueError): + VariantProvisionRequestSchema( + scenario_id=772, services={22: {"name": "school"}}, target_population=0 + ) From ef3876fea0cca2710e7db357b8db59cf5840af6a Mon Sep 17 00:00:00 2001 From: LeonDeTur Date: Mon, 14 Sep 2026 17:04:04 +0300 Subject: [PATCH 3/3] fix: expose explicit test normative provenance in provision results --- app/common/modules/effects_api_gateway.py | 33 +++++++++++++++ app/effects/effects_service.py | 2 +- app/effects/shemas/effects_base_schema.py | 1 + app/provision/provision_service.py | 9 +++- app/schemas/provision_base_schema.py | 4 ++ tests/test_service_normatives.py | 51 +++++++++++++++++++++++ 6 files changed, 98 insertions(+), 2 deletions(-) diff --git a/app/common/modules/effects_api_gateway.py b/app/common/modules/effects_api_gateway.py index 12b307f..1fdabdb 100644 --- a/app/common/modules/effects_api_gateway.py +++ b/app/common/modules/effects_api_gateway.py @@ -1,4 +1,8 @@ import asyncio +import hashlib +import json +import os +from pathlib import Path import geopandas as gpd import pandas as pd @@ -64,6 +68,35 @@ async def get_service_normative( headers={USER_ID_HEADER: token} if token else None, ) request_ter_id = territory_id + # Explicit local assessment fixture: real Urban values always take precedence. + fixture_path = os.getenv("TEST_SERVICE_NORMATIVES_FILE") + if fixture_path and not any( + (row.get("service_type") or {}).get("id") == service_type_id + for row in response or [] + ): + raw = Path(fixture_path).read_bytes() + fixture = json.loads(raw) + if fixture.get("source_kind") != "test_mock": + raise ValueError("Normative fixture must declare source_kind=test_mock") + if request_ter_id in fixture["territory_ids"]: + rows = [ + row + for row in fixture["normatives"] + if row["service_type"]["id"] == service_type_id + ] + response = list(response or []) + [ + dict( + row, + source={ + "kind": "test_mock", + "fixture_id": fixture["id"], + "sha256": hashlib.sha256(raw).hexdigest(), + "description": fixture["description"], + "legal_compliance_claim": False, + }, + ) + for row in rows + ] if not response: raise self._missing_service_normative( territory_id, context_ids, request_ter_id, service_type_id, [] diff --git a/app/effects/effects_service.py b/app/effects/effects_service.py index 7214bc2..e85818e 100644 --- a/app/effects/effects_service.py +++ b/app/effects/effects_service.py @@ -375,7 +375,7 @@ async def calculate_effects( before_prove_data["services"], after_prove_data["services"], ) - return EffectsSchema(**result) + return EffectsSchema(**result, normative=normative_data) @staticmethod async def form_llm_context( diff --git a/app/effects/shemas/effects_base_schema.py b/app/effects/shemas/effects_base_schema.py index 2029720..bf0f8de 100644 --- a/app/effects/shemas/effects_base_schema.py +++ b/app/effects/shemas/effects_base_schema.py @@ -23,6 +23,7 @@ class PivotSchema(BaseModel): class EffectsSchema(BaseModel): + normative: dict | None = None before_prove_data: ProvisionSchema after_prove_data: ProvisionSchema diff --git a/app/provision/provision_service.py b/app/provision/provision_service.py index a1f8cc8..fb1c8ae 100644 --- a/app/provision/provision_service.py +++ b/app/provision/provision_service.py @@ -106,6 +106,7 @@ async def _calculate_for_service( service_type_id=service_type_id, token=token, ) + shared_data.setdefault("normatives", {})[service_type_id] = normative_data context_buildings = await asyncio.to_thread( data_restorator.restore_demands, buildings=shared_data["context_buildings"].copy(), @@ -278,7 +279,12 @@ async def calculate_provision( logger.info( f"Calculated PROVISION for {provision_params.scenario_id} and {provision_params.service_type_id}" ) - return ProvisionSchema(**result) + return ProvisionSchema( + **result, + normative=shared_data.get("normatives", {}).get( + provision_params.service_type_id + ), + ) async def calculate_multi_provision( self, multi_params: MultiProvisionRequestSchema, token: str @@ -350,6 +356,7 @@ async def calculate_multi_provision( services=before_prove_data["services"], ), layers=layers, + normative=shared_data.get("normatives", {}).get(service_type_id), ) logger.info(f"Calculated MULTI PROVISION for {multi_params.scenario_id}") return MultiProvisionSchema(services=results) diff --git a/app/schemas/provision_base_schema.py b/app/schemas/provision_base_schema.py index 198d5cf..864e60a 100644 --- a/app/schemas/provision_base_schema.py +++ b/app/schemas/provision_base_schema.py @@ -32,6 +32,8 @@ class FeatureCollectionSchema(BaseModel): class ProvisionSchema(BaseModel): + normative: dict | None = None + buildings: FeatureCollectionSchema services: FeatureCollectionSchema links: FeatureCollectionSchema @@ -102,6 +104,8 @@ class VariantProvisionRequestSchema(MultiProvisionRequestSchema): class ServiceProvisionResultSchema(BaseModel): + normative: dict | None = None + name: str summary: ProvisionSummarySchema | None = None layers: ProvisionSchema | None = None diff --git a/tests/test_service_normatives.py b/tests/test_service_normatives.py index fbed7bb..c7ab518 100644 --- a/tests/test_service_normatives.py +++ b/tests/test_service_normatives.py @@ -85,3 +85,54 @@ async def test_missing_normative_keeps_other_service_layers(): assert result.services[22].layers is None assert result.services[21].error is None assert len(result.services[21].layers.buildings.features) == 1 + + +@pytest.mark.asyncio +async def test_explicit_mock_normative_is_scoped_and_has_provenance( + tmp_path, monkeypatch +): + import json + from hashlib import sha256 + + fixture = tmp_path / "norms.json" + fixture.write_text( + json.dumps( + { + "id": "test-education-v1", + "source_kind": "test_mock", + "description": "Synthetic assessment values", + "territory_ids": [9], + "normatives": [ + { + "service_type": {"id": 22}, + "year": 2026, + "radius_availability_meters": None, + "time_availability_minutes": 15, + "services_per_1000_normative": None, + "services_capacity_per_1000_normative": 100, + } + ], + } + ) + ) + monkeypatch.setenv("TEST_SERVICE_NORMATIVES_FILE", str(fixture)) + handler = AsyncMock() + handler.get.return_value = [] + gateway = EffectsAPIGateway(handler) + result = await gateway.get_service_normative(9, [], 22, "u1") + assert result["normative_type"] == "time" + assert result["normative_value"] == 15 + assert result["source"]["kind"] == "test_mock" + assert result["source"]["sha256"] == sha256(fixture.read_bytes()).hexdigest() + assert result["source"]["legal_compliance_claim"] is False + with pytest.raises(HTTPException): + await gateway.get_service_normative(10, [], 22, "u1") + with pytest.raises(HTTPException): + await gateway.get_service_normative(9, [], 21, "u1") + # A configured fixture must never replace available authoritative input. + handler.get.return_value = [ + dict(result, source={"kind": "urban_api"}, time_availability_minutes=20) + ] + actual = await gateway.get_service_normative(9, [], 22, "u1") + assert actual["normative_value"] == 20 + assert actual["source"]["kind"] == "urban_api"