diff --git a/.gitignore b/.gitignore index 4e24924..295222f 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ pytest_cache/ *.tmp .DS_Store Thumbs.db + +# --- Hypothesis (property-based testing cache) --- +.hypothesis/ diff --git a/.kiro/specs/zh-canton-drill-category-unknown/bugfix.md b/.kiro/specs/zh-canton-drill-category-unknown/bugfix.md new file mode 100644 index 0000000..5892f1b --- /dev/null +++ b/.kiro/specs/zh-canton-drill-category-unknown/bugfix.md @@ -0,0 +1,47 @@ +# Bugfix Requirements Document + +## Introduction + +The geoservice integration for canton ZH (Zurich) never returns a correct drill-category value. Every ground control point defined in `src/drillapi/cantons_configuration/data/ZH.yaml` resolves to `harmonized_value=4` (UNKNOWN) instead of its documented expected value, as confirmed via the `/checker/ZH` route and by replaying `processing.fetch_features_for_point()` directly against the live ZH endpoint for all 5 `ground_control_point` entries. + +Root cause hypothesis, based on direct inspection of the live WMS `GetCapabilities` response at `https://wms.zh.ch/AwelGSWaermewwwZHWMS`: + +- `ZH.yaml` configures seven layers to be queried together (`erdwaermesonden-auflagen`, `waermenutzung-zone-a` through `waermenutzung-zone-f`), but the six per-zone layer names (`waermenutzung-zone-a`..`-f`) do not exist on the server. The server only publishes a single combined, queryable layer named `waermenutzung-zonen`. +- Because the combined `GetFeatureInfo` request references layer names the server doesn't recognize, the server responds with HTTP 200 and a `ServiceExceptionReport` body (`LayerNotDefined`). `httpx`'s `raise_for_status()` does not catch this, since the HTTP status is 200. +- `parse_wms_getfeatureinfo` cannot find `gml:featureMember` or `*_feature` elements in that exception body, falls through to the ZH-specific `` lookup, finds nothing there either, and returns an empty feature list. +- Separately, `erdwaermesonden-auflagen` is configured as if it were queryable, but `GetCapabilities` marks it `queryable="0"`, so a `GetFeatureInfo` request against it is rejected by the server independently of the layer-name issue above. +- Even if the real layer name `waermenutzung-zonen` were used instead of the stale per-zone names, its `GetFeatureInfo` response was found to contain only a bounding box per feature and a generic layer title in `` ("Wärmenutzungs Zonen") — not the zone-specific description strings (e.g. "Zone A (Schutzzonen und Schutzareale)") that `ZH.yaml`'s `property_values` are keyed on. This means a layer-name fix alone may not be sufficient to resolve the correct zone. + +Open question for the design phase: does ZH publish an alternative endpoint (WFS, ArcGIS REST/FeatureServer, or a set of real per-zone layers with queryable attributes) that exposes zone-level and `erdwaermesonden-auflagen` data with usable attributes? The current WMS `waermenutzung-zonen` layer's `GetFeatureInfo` output carries no attribute payload that the existing WMS-scraping approach (including the ZH special case in `parse_wms_getfeatureinfo`) could use to distinguish zones. + +## Bug Analysis + +### Current Behavior (Defect) + +1.1 WHEN the API queries ZH for any coordinate using the combined layer list from `ZH.yaml` (`erdwaermesonden-auflagen`, `waermenutzung-zone-a` through `waermenutzung-zone-f`) THEN the system sends a `GetFeatureInfo` request containing layer names that do not exist on the ZH WMS server, and the server returns HTTP 200 with a `ServiceExceptionReport` (`LayerNotDefined`) instead of feature data. + +1.2 WHEN `parse_wms_getfeatureinfo` receives a `ServiceExceptionReport` body for config `name == "ZH"` THEN the system finds no `gml:featureMember` or `*_feature` elements, falls through to the ZH-specific `` lookup, finds no matching element there either, and returns an empty `features` list. + +1.3 WHEN `fetch_features_for_point` / `process_ground_category` is evaluated for any of the 5 `ground_control_point` entries in `ZH.yaml` THEN the system returns `harmonized_value=4` (UNKNOWN) with empty `source_values`/`mapped_values`, instead of the expected control values (3, 3, 2, 2, 2). + +1.4 WHEN the API includes `erdwaermesonden-auflagen` in the combined `GetFeatureInfo` request THEN the server rejects querying that layer because it is marked `queryable="0"` in `GetCapabilities`, even though `ZH.yaml` configures it with `property_values` expecting a name match of "Auflagen für Erdwärmesonden". + +### Expected Behavior (Correct) + +2.1 WHEN the API queries ZH for any coordinate THEN the system SHALL only request layer names (or use an alternative data source) that actually exist and are queryable on the ZH service, so it does not receive a `ServiceExceptionReport` for undefined layers. + +2.2 WHEN the ZH service returns feature data for the zone layer(s) actually queried THEN the system SHALL be able to resolve which specific zone (A through F) was hit at the queried coordinate and map it to the correct `target_harmonized_value` defined in `ZH.yaml`. + +2.3 WHEN all 5 documented `ground_control_point` entries in `ZH.yaml` are evaluated THEN the system SHALL return `harmonized_value` matching each entry's expected control value (3, 3, 2, 2, 2 respectively) with non-empty `source_values`/`mapped_values` where a match is expected. + +2.4 WHEN the `erdwaermesonden-auflagen` restriction needs to be checked for a coordinate THEN the system SHALL use a request method and/or layer reference that the ZH service actually permits, instead of silently failing because the layer is not queryable via WMS `GetFeatureInfo`. + +### Unchanged Behavior (Regression Prevention) + +3.1 WHEN `parse_wms_getfeatureinfo` processes a valid GML response containing `gml:featureMember` elements for other cantons THEN the system SHALL CONTINUE TO extract feature attributes using the existing logic. + +3.2 WHEN `parse_wms_getfeatureinfo` processes a MapServer `msGMLOutput` response containing `*_feature` elements for other cantons THEN the system SHALL CONTINUE TO extract feature attributes using the existing logic. + +3.3 WHEN `process_ground_category` receives genuinely matched features for cantons other than ZH THEN the system SHALL CONTINUE TO compute `harmonized_value` using the existing `property_values` / layer-name matching logic unchanged. + +3.4 WHEN a WMS or ESRI REST request fails at the HTTP transport level (timeout, 4xx/5xx status, connection error) THEN the system SHALL CONTINUE TO return `geoservice_unavailable` with an error message, as it does today. diff --git a/.kiro/specs/zh-canton-drill-category-unknown/design.md b/.kiro/specs/zh-canton-drill-category-unknown/design.md new file mode 100644 index 0000000..e72d1fc --- /dev/null +++ b/.kiro/specs/zh-canton-drill-category-unknown/design.md @@ -0,0 +1,420 @@ +# ZH Canton Drill Category Unknown Bugfix Design + +## Overview + +The ZH (Zürich) canton integration always returns `harmonized_value=4` (UNKNOWN) because `ZH.yaml` +queries seven WMS layer names, six of which (`waermenutzung-zone-a` .. `waermenutzung-zone-f`) do not +exist on the live server, and a seventh (`erdwaermesonden-auflagen`) is marked `queryable="0"` by the +server's `GetCapabilities`. Live investigation (see "Investigation Findings" below) confirmed: + +- The server only publishes one real, queryable zone layer: `waermenutzung-zonen`. +- `GetFeatureInfo` against `waermenutzung-zonen` returns a feature with **no attribute payload at + all** — only a `gml:boundedBy` bounding box (`gml:Box`) — for every tested `INFO_FORMAT` + (GML2/GML3/text/plain). JSON/HTML are rejected outright by the server. +- No alternate endpoint (WFS, ArcGIS REST/FeatureServer, or real per-zone layers) exists that exposes + zone identity as a readable attribute. +- `erdwaermesonden-auflagen` cannot be queried via `GetFeatureInfo` under any combination tested; the + server rejects it with `LayerNotDefined` / "not offered by the service instance" whenever it appears + in `QUERY_LAYERS`. + +Because no attribute data is available, the fix resolves zone identity from the **bounding box** that +`waermenutzung-zonen` returns, matched against a static bbox→zone lookup table recorded once (via the +documented `ground_control_point` entries) and stored in `ZH.yaml`. `erdwaermesonden-auflagen` is kept +in the WMS `LAYERS` parameter (so the request shape matches the real service) but removed from +`QUERY_LAYERS`, since the server never answers queries against it; its contribution to +`harmonized_value` is documented as a known, out-of-scope limitation rather than silently claimed as +fixed. + +The fix is scoped entirely to the ZH canton: the ZH-specific branch of `parse_wms_getfeatureinfo`, a +new ZH-only `bbox_zones` matching branch in `process_ground_category`, `ZH.yaml`, and two small, +backward-compatible schema additions (`Layer.queryable`, `Layer.bbox_zones`) whose defaults preserve +behavior for every other canton untouched. + +### Investigation Findings (incorporated, not re-derived) + +- `GetCapabilities` for `https://wms.zh.ch/AwelGSWaermewwwZHWMS` lists `waermenutzung-zonen` + (`queryable="1"`) and `erdwaermesonden-auflagen` (`queryable="0"`) as the only relevant layers. No + `waermenutzung-zone-a`..`-f` layers exist. No WFS/ArcGIS REST alternative exists (WFS requests on the + same base URL return "WFS request not enabled"). +- Live `GetFeatureInfo` against `waermenutzung-zonen` for 3 documented ground control points (Zone A, + B, C) returned only a `gml:boundedBy` bounding box per feature, differing per point (proving a + distinct feature was matched each time) but with no zone letter, name, or category attribute in any + tested `INFO_FORMAT`. +- `erdwaermesonden-auflagen` rejects `GetFeatureInfo` outright even when queried alone. + +## Glossary + +- **Bug_Condition (C)**: The condition under which the ZH integration returns an incorrect result — + any request for a coordinate that resolves to canton ZH, evaluated against the current (unfixed) + `ZH.yaml` layer configuration. +- **Property (P)**: The desired behavior for ZH requests — the system queries only real, queryable + layer names, resolves the zone-layer's bounding box to the correct `target_harmonized_value` via the + static bbox lookup table, and never falls back to UNKNOWN(4) solely because of a + `ServiceExceptionReport` or a layer-name mismatch. +- **Preservation**: Existing parsing/processing behavior for every canton other than ZH (GML + `featureMember` extraction, MapServer `*_feature` extraction, `property_values` / layer-name + matching, and transport-level `geoservice_unavailable` handling), which must remain byte-for-byte + identical. +- **`waermenutzung-zonen`**: The single real, queryable WMS layer on the ZH server that covers + groundwater heat-use Zones A–F. Its `GetFeatureInfo` response carries only a bounding box, no + attributes. +- **`erdwaermesonden-auflagen`**: The WMS layer representing borehole-heat-exchanger restrictions. + Marked `queryable="0"` on the server; `GetFeatureInfo` against it is always rejected. +- **`bbox_zones`**: A new, ZH-only `Layer` config field — a static list mapping an observed + `gml:Box` bounding box to a zone's `target_harmonized_value` / `desc`, used instead of + `property_values` when the geoservice returns no attributes. +- **`queryable`**: A new, generic `Layer` config field (default `true`) marking whether a layer should + be included in the WMS `QUERY_LAYERS` parameter. When `false`, the layer stays in `LAYERS` (so the + request shape still matches what the server expects) but is never asked to resolve a feature. + +## Bug Details + +### Bug Condition + +The bug manifests for every request whose coordinate resolves to canton ZH, because the layer list +`fetch_features_for_point` builds from `ZH.yaml` always includes layer names that are either +non-existent (`waermenutzung-zone-a`..`-f`) or non-queryable (`erdwaermesonden-auflagen`). The server +replies HTTP 200 with a `ServiceExceptionReport` (`LayerNotDefined`), which `httpx`'s +`raise_for_status()` does not catch. `parse_wms_getfeatureinfo` then finds no `gml:featureMember` / +`*_feature` elements, falls through to the ZH special case, finds no `gml:name` element either (the +real `waermenutzung-zonen` layer's response has no such attribute), and returns an empty feature list, +so `process_ground_category` falls back to `harmonized_value=4`. + +**Formal Specification:** +``` +FUNCTION isBugCondition(input) + INPUT: input of type (coord_x: float, coord_y: float) + OUTPUT: boolean + + canton := resolveCanton(input.coord_x, input.coord_y) // via geo.admin.ch + RETURN canton == "ZH" + // Under the current (unfixed) ZH.yaml, this condition is sufficient: + // every ZH coordinate hits the stale/non-queryable layer list below. +END FUNCTION +``` + +### Examples + +- `fetch_features_for_point` for the Zone A ground control point (2688561, 1274977) sends + `QUERY_LAYERS=erdwaermesonden-auflagen,waermenutzung-zone-a,...,waermenutzung-zone-f`. The server + returns a `ServiceExceptionReport` (`LayerNotDefined`) with HTTP 200. Expected: `harmonized_value=3`. + Actual: `harmonized_value=4`, empty `source_values`. +- Even replacing the layer list with just `waermenutzung-zonen`, `GetFeatureInfo` for the same point + returns `...` with no `gml:name` and no other + attribute. The current ZH special case (`root.find(".//gml:name", ns)`) finds nothing and still + returns an empty feature list. +- Querying `erdwaermesonden-auflagen` alone (to check the heat-exchanger restriction for ground control + point 1, which combines "Auflagen & waermenutzung-zone-d") is rejected by the server independently of + the above, because the layer is `queryable="0"`. +- Edge case: a coordinate that resolves to ZH but falls outside every mapped zone bbox should still + resolve to UNKNOWN(4) — that is correct behavior, not a bug, and must be preserved by the fix. + +## Expected Behavior + +### Preservation Requirements + +**Unchanged Behaviors:** +- `parse_wms_getfeatureinfo`'s `gml:featureMember` extraction (used by other GML cantons) must continue + to work exactly as before. +- `parse_wms_getfeatureinfo`'s MapServer `*_feature` extraction must continue to work exactly as + before. +- `process_ground_category`'s `property_values` matching and layer-presence (`target_harmonized_value` + without `property_values`) matching must continue to work exactly as before for every layer that does + not define `bbox_zones`. +- WMS/ESRI transport-level failures (timeout, 4xx/5xx, connection error) must continue to return + `geoservice_unavailable` exactly as before, for every canton including ZH. +- The `QUERY_LAYERS`/`LAYERS` construction must continue to produce an identical, comma-joined list of + *all* configured layer names for every canton whose layers don't opt out via the new `queryable: false` + field (i.e. every canton other than ZH is unaffected, since `queryable` defaults to `true`). + +**Scope:** +All requests that do NOT resolve to canton ZH are completely unaffected by this fix. This includes: +- Every other active canton's WMS GML/MapServer responses. +- Every ESRI REST (`arcgis/json`) canton (GE, FR). +- Requests that fail at the HTTP transport level, for any canton. + +## Hypothesized Root Cause + +Confirmed via live investigation (see Overview): + +1. **Stale per-zone layer names**: `ZH.yaml` references six layer names + (`waermenutzung-zone-a`..`-f`) that were presumably valid at some point but no longer exist on the + server; the server only publishes the combined `waermenutzung-zonen` layer. + +2. **Non-queryable layer queried anyway**: `erdwaermesonden-auflagen` is configured with + `property_values` as if `GetFeatureInfo` against it worked, but `GetCapabilities` marks it + `queryable="0"` and the server rejects any query against it. + +3. **No attribute payload to match against**: Even the real `waermenutzung-zonen` layer's + `GetFeatureInfo` response carries no attribute (no `gml:name`, no zone label) — only a bounding box. + The existing `property_values`/`gml:name` matching strategy cannot work against this layer under any + layer-name fix, because there is no string to match. + +4. **Silent `ServiceExceptionReport` swallowing**: HTTP 200 + `ServiceExceptionReport` is not treated as + a transport error by `httpx.raise_for_status()`, so the undefined-layer condition surfaces only as an + empty feature list deep in the parser, making the root cause non-obvious from the API response alone. + +## Correctness Properties + +Property 1: Bug Condition - ZH zone resolution via bounding-box lookup + +_For any_ input where the bug condition holds (the coordinate resolves to canton ZH), the fixed system +SHALL send a `GetFeatureInfo` request whose `QUERY_LAYERS` contains only `waermenutzung-zonen` (the +real, queryable layer), SHALL NOT receive a `ServiceExceptionReport` caused by an undefined layer name, +and, when the returned bounding box matches an entry in the static `bbox_zones` lookup table, SHALL +resolve `harmonized_value` to that entry's `target_harmonized_value` instead of falling back to +UNKNOWN(4). + +**Validates: Requirements 2.1, 2.2, 2.3** + +Property 2: Preservation - non-ZH parsing and processing behavior + +_For any_ input where the bug condition does NOT hold (the coordinate resolves to a canton other than +ZH, or the request fails at the HTTP transport level), the fixed code SHALL produce exactly the same +result as the original code: `parse_wms_getfeatureinfo`'s `gml:featureMember` / `*_feature` extraction +is unchanged, `process_ground_category`'s `property_values` / layer-presence matching is unchanged for +every layer without `bbox_zones`, and transport-level failures continue to yield +`geoservice_unavailable`. + +**Validates: Requirements 3.1, 3.2, 3.3, 3.4** + +**Known, documented exclusion from scope (not covered by either property):** because +`erdwaermesonden-auflagen` cannot be queried under any configuration found during investigation, the +"Auflagen für Erdwärmesonden" restriction itself is not detectable by this fix. Ground control point 1 +(`2677790, 1260185`, labelled "Auflagen & waermenutzung-zone-d", currently expected `3`) will resolve to +Zone D's bbox-matched value (`2`) after this fix, not `3`, since only the zone layer contributes a +result. This is a real gap in requirement 2.3 as literally written and is called out explicitly in Fix +Implementation below rather than silently "fixed" — it should be confirmed with the user before task +execution (e.g. by updating that control point's expected value to `2`, or by scoping a follow-up +bugfix for the Auflagen restriction specifically). + +## Fix Implementation + +### Changes Required + +**File**: `src/drillapi/cantons_configuration/schema.py` + +1. **Add `queryable` field to `Layer`** (generic, default-safe): + ```python + # Whether this layer should be included in WMS QUERY_LAYERS. Defaults to True + # so every existing canton is unaffected. Set False for a layer that exists + # only in LAYERS (e.g. ZH's erdwaermesonden-auflagen, which the server marks + # queryable="0" and rejects outright if placed in QUERY_LAYERS). + queryable: bool = True + ``` + +2. **Add `BboxZone` model and `bbox_zones` field on `Layer`** (ZH-only, additive): + ```python + class BboxZone(BaseModel): + """A static bounding box observed for one WMS zone feature, used to + resolve zone identity when GetFeatureInfo returns no attributes + (ZH's `waermenutzung-zonen` layer).""" + + model_config = ConfigDict(extra="forbid") + + bbox: Annotated[list[float], Field(min_length=4, max_length=4)] + name: str + target_harmonized_value: GroundSuitability + desc: str | None = None + ``` + ```python + # ZH-only: static bbox->zone lookup used instead of property_values when the + # geoservice's GetFeatureInfo response carries no attribute payload. + bbox_zones: list[BboxZone] | None = None + ``` + +**File**: `src/drillapi/cantons_configuration/data/ZH.yaml` + +3. **Replace the seven-layer list with two layers**: + - `erdwaermesonden-auflagen`: keep `property_values` as-is (unused until/unless a working query path + is found), add `queryable: false`. + - `waermenutzung-zonen` (renamed from the six stale `waermenutzung-zone-*` entries): drop + `property_values`/`property_name`'s attribute-matching role for this layer and add `bbox_zones` + with one entry per zone (A–F). Each entry's `bbox` is the live `gml:Box` coordinates observed for + that zone's documented `ground_control_point` (recorded once during implementation, e.g. the boxes + already observed for Zones A/B/C during investigation, plus D/E/F queried the same way); `name` / + `target_harmonized_value` / `desc` carry over unchanged from today's per-zone layer entries. + - `property_name` stays `name` for both layers for schema consistency, even though + `waermenutzung-zonen` no longer matches on it directly (it's matched via `bbox_zones` instead). + +**File**: `src/drillapi/services/processing.py` + +4. **`fetch_features_for_point` (WMS branch)**: build two comma-joined lists instead of one — + `layers_list` (all layer names, used for `LAYERS`) and `query_layers_list` (only layers where + `layer.get("queryable", True)` is `True`, used for `QUERY_LAYERS`). Non-ZH cantons have no + `queryable: false` entries, so `query_layers_list == layers_list` for them — zero behavior change. + +5. **`parse_wms_getfeatureinfo` ZH special case**: replace the `gml:name` lookup with a bounding-box + extraction, still gated on `config["name"] == "ZH"` so no other canton is touched: + ```python + # ZH geoservice is a special and unique case: its only queryable layer + # (waermenutzung-zonen) returns no attributes, only a bounding box + # (gml:boundedBy/gml:Box) identifying which zone feature was hit. + if not features and config["name"] == "ZH": + box_elem = root.find(".//gml:boundedBy/gml:Box", ns) + if box_elem is not None: + coords = _parse_gml_box(box_elem, ns) # -> [minx, miny, maxx, maxy] | None + if coords is not None: + features.append({"bbox": coords}) + ``` + Add a small `_parse_gml_box` helper that reads the two `gml:coordinates` (or `gml:coord`/`gml:pos` + pairs, depending on GML version) inside the `gml:Box` and returns a 4-element float list, returning + `None` on any malformed input (so parsing failures degrade to "no feature", not a crash). + +6. **`process_ground_category`**: add a `bbox_zones`-matching branch, evaluated before the existing + `property_values` branch, so it is purely additive for layers that don't define `bbox_zones`: + ```python + bbox_zones = layer_cfg.get("bbox_zones") + if bbox_zones: + for feature in ground_features: + feature_bbox = feature.get("bbox") if isinstance(feature, dict) else None + if feature_bbox is None: + continue + for zone in bbox_zones: + if _bbox_matches(feature_bbox, zone["bbox"]): + mapped_values.append(zone.get("target_harmonized_value")) + source_values.append(zone.get("desc")) + break + elif property_values: + ... # existing logic, unchanged + else: + ... # existing layer-presence logic, unchanged + ``` + Add a `_bbox_matches(a, b, tolerance=0.5)` helper comparing all four coordinates with + `math.isclose`/absolute tolerance (meters, EPSG:2056), since the server returns the same feature's + box deterministically but exact binary float equality shouldn't be relied on. + +7. **Existing ZH unit tests** `test_parse_zh_special_case` / `test_parse_zh_no_name_element` in + `tests/test_processing.py` encode the old `gml:name`-based special case and must be rewritten to + assert the new bbox-based extraction. This is an intentional replacement of ZH-only test behavior, + not a regression under requirements 3.1–3.4 (those requirements cover non-ZH canton logic and + transport-level failures only). + +## Testing Strategy + +### Validation Approach + +The testing strategy follows a two-phase approach: first, surface counterexamples that demonstrate the +bug on unfixed code (to confirm the layer-name/queryability/no-attribute root cause), then verify the +fix resolves the correct zone for ZH and leaves every other canton's behavior byte-for-byte unchanged. + +### Exploratory Bug Condition Checking + +**Goal**: Surface counterexamples that demonstrate the bug BEFORE implementing the fix, confirming the +three root causes (stale layer names, non-queryable layer, attribute-less response). + +**Test Plan**: Replay `fetch_features_for_point` against a recorded (fixture) `ServiceExceptionReport` +body for the stale layer list, and against a recorded attribute-less `waermenutzung-zonen` GML response, +on the CURRENT/unfixed code. + +**Test Cases**: +1. **Stale layer list test**: Feed a recorded `ServiceExceptionReport` (`LayerNotDefined`) body through + `parse_wms_getfeatureinfo` with `config={"name": "ZH"}` and assert it returns `[]` (will pass on + unfixed code — this *is* the bug, confirming the fallthrough). +2. **Attribute-less zone response test**: Feed a recorded `waermenutzung-zonen` GML response + (`gml:boundedBy`/`gml:Box`, no `gml:name`) through the unfixed `parse_wms_getfeatureinfo` and assert + it returns `[]` (confirms the current `gml:name` special case cannot recover zone identity even with + the correct layer name). +3. **Non-queryable layer test**: Feed a recorded `erdwaermesonden-auflagen`-only + `ServiceExceptionReport` through the unfixed pipeline and assert the fallback to `harmonized_value=4` + (confirms this layer independently fails regardless of the zone-layer fix). + +**Expected Counterexamples**: +- All three cases return empty features / UNKNOWN(4) on unfixed code, confirming the root cause + analysis rather than refuting it. + +### Fix Checking + +**Goal**: Verify that for all inputs where the bug condition holds, the fixed function resolves the +correct zone. + +**Pseudocode:** +``` +FOR ALL input WHERE isBugCondition(input) DO + result := fetch_and_process_fixed(input) + ASSERT expectedBehavior(result) +END FOR + +FUNCTION expectedBehavior(result) + // No ServiceExceptionReport-driven empty-feature fallback, and the zone's + // configured target_harmonized_value is reached when the point falls + // inside a mapped zone bbox. + RETURN result.query_layers == ["waermenutzung-zonen"] + AND (result.harmonized_value != UNKNOWN + OR pointOutsideAllKnownZoneBboxes(input)) +END FUNCTION +``` + +### Preservation Checking + +**Goal**: Verify that for all inputs where the bug condition does NOT hold, the fixed function produces +the same result as the original function. + +**Pseudocode:** +``` +FOR ALL input WHERE NOT isBugCondition(input) DO + ASSERT original_parse_and_process(input) = fixed_parse_and_process(input) +END FOR +``` + +**Testing Approach**: Property-based testing is recommended for preservation checking because: +- It generates many random GML/MapServer/ESRI response shapes automatically, across cantons. +- It catches edge cases (missing fields, unusual nesting) that manual unit tests might miss. +- It provides strong guarantees that `queryable` defaulting to `True` and the new `bbox_zones` branch + being `None`-guarded never alter behavior for any non-ZH layer config. + +**Test Plan**: Observe current behavior on unfixed code for `gml:featureMember`, `*_feature`, and +`property_values`/layer-presence matching across existing non-ZH fixtures, then write tests asserting +the fixed code produces identical output. + +**Test Cases**: +1. **GML `featureMember` preservation**: Verify a non-ZH GML response with `gml:featureMember` elements + parses identically before and after the fix. +2. **MapServer `*_feature` preservation**: Verify a `msGMLOutput` response parses identically before and + after the fix. +3. **`property_values` matching preservation**: Verify `process_ground_category` for a canton with + `property_values` (no `bbox_zones`) produces identical `harmonized_value`/`source_values` before and + after the fix. +4. **`QUERY_LAYERS`/`LAYERS` construction preservation**: Verify that for every canton other than ZH + (none of which set `queryable: false`), the built `QUERY_LAYERS` string is identical to the current + `LAYERS` string. +5. **Transport failure preservation**: Verify timeout/4xx/5xx/connection-error handling still returns + `geoservice_unavailable` for ZH and non-ZH cantons alike. + +### Unit Tests + +- `parse_wms_getfeatureinfo` with a `ServiceExceptionReport` body and `config={"name": "ZH"}` — asserts + no crash and an empty feature list (not a regression target, but a documented, still-possible failure + mode if the server ever changes again). +- `parse_wms_getfeatureinfo` with a `gml:boundedBy`/`gml:Box` response and `config={"name": "ZH"}` — + asserts a single feature with the extracted `bbox` list. +- `parse_wms_getfeatureinfo` ZH case with a malformed/missing `gml:Box` — asserts an empty feature list + (no crash). +- `process_ground_category` with a `bbox_zones`-configured layer and a matching feature bbox — asserts + the correct `target_harmonized_value`/`desc` is selected. +- `process_ground_category` with a `bbox_zones`-configured layer and a feature bbox that matches no + known zone — asserts fallback to UNKNOWN(4). +- `fetch_features_for_point` WMS branch with a mix of `queryable: true`/`false` layers — asserts + `QUERY_LAYERS` excludes the `false` ones while `LAYERS` includes all of them. + +### Property-Based Tests + +- Generate random bbox pairs within/outside a small tolerance and verify `_bbox_matches` behaves + correctly at the tolerance boundary (true inside, false outside). +- Generate random `Layer` configs with/without `queryable`/`bbox_zones` set and verify + `process_ground_category`/`fetch_features_for_point` behavior for every non-ZH-shaped config (no + `bbox_zones`, `queryable` absent or `true`) is byte-identical to the pre-fix implementation. +- Generate random GML fixtures with `gml:featureMember` elements and verify extraction is unaffected by + the new ZH-only bbox branch (which only runs when `features` is still empty after the existing + extraction steps). + +### Integration Tests + +- Replay `fetch_features_for_point` against recorded live responses for all documented ZH + `ground_control_point` entries (Zones A–E, plus the Auflagen/Zone D combination point) and assert the + resulting `harmonized_value` matches each entry's expected value, with the documented exception for + the Auflagen-only contribution noted in Correctness Properties. +- Full `/v1/drill-category/{coord_x}/{coord_y}` route test for a ZH coordinate — asserts a non-UNKNOWN + `harmonized_value` and a populated `layer_results` entry for `waermenutzung-zonen`. +- Full route test for a representative non-ZH canton coordinate (e.g. an existing GE/FR/BE fixture) — + asserts the response is unchanged from the current baseline. diff --git a/.kiro/specs/zh-canton-drill-category-unknown/tasks.md b/.kiro/specs/zh-canton-drill-category-unknown/tasks.md new file mode 100644 index 0000000..316e477 --- /dev/null +++ b/.kiro/specs/zh-canton-drill-category-unknown/tasks.md @@ -0,0 +1,260 @@ +# Implementation Plan + +## Overview + +This plan implements the fix described in `design.md` for the ZH canton drill-category bug: it +replaces the seven-layer `ZH.yaml` configuration (six stale per-zone layer names plus a +non-queryable `erdwaermesonden-auflagen` layer) with the single real, queryable +`waermenutzung-zonen` layer resolved via a static bounding-box lookup (`bbox_zones`), adds the +supporting `queryable`/`bbox_zones` schema fields, updates `processing.py`'s WMS request +building, GML parsing, and category resolution accordingly, and validates the fix with +exploration, preservation, and integration tests. Per the user's explicit decision, the +`erdwaermesonden-auflagen` restriction itself remains undetectable and out of scope for this fix; +ground control point 1 ("Auflagen & waermenutzung-zone-d") is left with its documented expected +value of `3` unchanged in `ZH.yaml`, and the final integration test explicitly asserts that this +control point does NOT match its expected value after the fix, to document the known gap rather +than hide it. + +## Tasks + +- [x] 1. Write bug condition exploration test + - **Property 1: Bug Condition** - ZH stale/non-queryable layers return empty features + - **IMPORTANT**: Write this property-based test BEFORE implementing the fix + - **GOAL**: Surface counterexamples that demonstrate the three root causes from design.md + (stale per-zone layer names, non-queryable `erdwaermesonden-auflagen`, attribute-less + `waermenutzung-zonen` response) + - **Scoped PBT Approach**: The bug is deterministic for any ZH coordinate under the current + `ZH.yaml`, so scope the property to the concrete failing fixtures instead of random + generation: + 1. Feed a recorded `ServiceExceptionReport` (`LayerNotDefined`) body through + `parse_wms_getfeatureinfo` with `config={"name": "ZH"}` — assert it returns `[]` + 2. Feed a recorded attribute-less `waermenutzung-zonen` GML response + (`gml:boundedBy`/`gml:Box`, no `gml:name`) through `parse_wms_getfeatureinfo` with + `config={"name": "ZH"}` — assert it returns `[]` (the current `gml:name` special case + cannot recover zone identity even with the correct layer name) + 3. Feed a recorded `erdwaermesonden-auflagen`-only `ServiceExceptionReport` through the + unfixed pipeline — assert fallback to `harmonized_value=4` + - Test implementation details from Bug Condition in design.md (`isBugCondition`: any + coordinate resolving to canton `ZH` under the unfixed `ZH.yaml` layer list) + - The test assertions should match the Expected Behavior Properties from design.md (Property + 1: `QUERY_LAYERS` contains only `waermenutzung-zonen`, no `ServiceExceptionReport`-driven + empty-feature fallback, `harmonized_value` resolved via `bbox_zones` instead of UNKNOWN(4)) + - Run test on UNFIXED code + - **EXPECTED OUTCOME**: Test FAILS / confirms empty features and `harmonized_value=4` (this is + correct — it proves the bug exists) + - Document counterexamples found, e.g. "`parse_wms_getfeatureinfo` on the + `ServiceExceptionReport` fixture returns `[]`", "`waermenutzung-zonen`'s bbox-only response + also returns `[]` even with the correct layer name", "`erdwaermesonden-auflagen` alone + returns `[]` because it is `queryable=0`" + - Mark task complete when test is written, run, and failure/counterexamples are documented + - _Requirements: 1.1, 1.2, 1.3, 1.4_ + +- [x] 2. Write preservation property tests (BEFORE implementing fix) + - **Property 2: Preservation** - non-ZH parsing and processing behavior + - **IMPORTANT**: Follow observation-first methodology + - Observe on UNFIXED code: + - `parse_wms_getfeatureinfo` with a `gml:featureMember` response (e.g. the existing JU + fixture `tests/data/wms/getfeatureinfo_ju.gml`) returns the same feature dicts as today + - `parse_wms_getfeatureinfo` with a MapServer `*_feature` (`msGMLOutput`) response returns + the same feature dicts as today + - `process_ground_category` for a layer with `property_values` (no `bbox_zones`) and for a + layer using layer-presence matching (no `property_values`) returns the same + `harmonized_value`/`source_values` as today + - `fetch_features_for_point`'s WMS branch builds `QUERY_LAYERS` identical to `LAYERS` for + every canton config that has no `queryable: false` entries (i.e. every canton except ZH) + - Transport-level failures (timeout, 4xx/5xx, connection error) return + `geoservice_unavailable` for both ZH and non-ZH cantons + - Write property-based tests capturing these observed patterns from the Preservation + Requirements in design.md: "for all non-ZH inputs (or ZH inputs where the request fails at + the HTTP transport level), the fixed code produces exactly the same result as the original + code" + - Generate random `Layer`-shaped configs (varying `property_values`, layer-presence-only + configs, and canton name) that never set `bbox_zones`/`queryable: false`, and assert + `process_ground_category`/`fetch_features_for_point` behavior is byte-identical to the + pre-fix implementation for all of them + - Run tests on UNFIXED code + - **EXPECTED OUTCOME**: Tests PASS (confirms baseline behavior to preserve) + - Mark task complete when tests are written, run, and passing on unfixed code + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + +- [x] 3. Fix for ZH canton drill-category always resolving to UNKNOWN(4) + + - [x] 3.1 Add `queryable` and `bbox_zones`/`BboxZone` schema fields + - In `src/drillapi/cantons_configuration/schema.py`, add `queryable: bool = True` to `Layer` + (generic, default-safe — every existing canton is unaffected since the default is `True`) + - Add a new `BboxZone` model (`extra="forbid"`) with `bbox: list[float]` (length 4), + `name: str`, `target_harmonized_value: GroundSuitability`, `desc: str | None = None` + - Add `bbox_zones: list[BboxZone] | None = None` to `Layer` (ZH-only, additive) + - _Bug_Condition: isBugCondition(input) from design.md — canton resolves to ZH_ + - _Expected_Behavior: expectedBehavior(result) from design.md Correctness Properties_ + - _Requirements: 2.1, 2.2_ + + - [x] 3.2 Consolidate ZH.yaml to the real queryable layer plus bbox_zones + - In `src/drillapi/cantons_configuration/data/ZH.yaml`, replace the seven-layer list with two + layers: + - `erdwaermesonden-auflagen`: keep `property_values` as-is, add `queryable: false` + - `waermenutzung-zonen` (replacing the six `waermenutzung-zone-a`..`-f` entries): add + `bbox_zones` with one entry per zone (A–F), each `bbox` set to the live `gml:Box` + coordinates observed for that zone's documented `ground_control_point` (record Zones + A/B/C from the design.md investigation findings; query D/E/F the same way); carry over + `name`/`target_harmonized_value`/`desc` unchanged from today's per-zone layer entries + - Leave every `ground_control_point` entry's expected value unchanged, including control + point 1 (`2677790, 1260185`, "Auflagen & waermenutzung-zone-d", expected `3`) — do NOT + change it to `2`; its mismatch after the fix is a documented, known gap (see task 4), not + something to be "fixed" by relabeling the fixture data + - _Bug_Condition: isBugCondition(input) from design.md — canton resolves to ZH_ + - _Expected_Behavior: expectedBehavior(result) from design.md Correctness Properties_ + - _Requirements: 2.1, 2.2, 2.3_ + + - [x] 3.3 Build separate QUERY_LAYERS/LAYERS lists in fetch_features_for_point + - In `src/drillapi/services/processing.py`, in the WMS branch of `fetch_features_for_point`, + build `layers_list` (all layer names, used for `LAYERS`) and `query_layers_list` (only + layers where `layer.get("queryable", True)` is `True`, used for `QUERY_LAYERS`) + - Non-ZH cantons have no `queryable: false` entries, so `query_layers_list == layers_list` + for them — zero behavior change + - _Bug_Condition: isBugCondition(input) from design.md — canton resolves to ZH_ + - _Expected_Behavior: expectedBehavior(result) from design.md — QUERY_LAYERS contains only + waermenutzung-zonen for ZH_ + - _Preservation: Preservation Requirements from design.md — QUERY_LAYERS/LAYERS construction + unchanged for every canton without queryable: false_ + - _Requirements: 2.1, 3.4_ + + - [x] 3.4 Replace ZH's gml:name special case with bbox extraction + - In `parse_wms_getfeatureinfo`, replace the ZH-specific `gml:name` lookup (gated on + `config["name"] == "ZH"`) with a `gml:boundedBy/gml:Box` extraction, appending + `{"bbox": coords}` to `features` when found + - Add a `_parse_gml_box(box_elem, ns)` helper that reads the `gml:coordinates` (or + `gml:coord`/`gml:pos` pairs, depending on GML version) inside the `gml:Box` and returns a + 4-element float list (`[minx, miny, maxx, maxy]`), returning `None` on any malformed input + so parsing failures degrade to "no feature" rather than a crash + - This branch is gated on `config["name"] == "ZH"`, so no other canton's parsing path is + touched + - _Bug_Condition: isBugCondition(input) from design.md — canton resolves to ZH_ + - _Expected_Behavior: expectedBehavior(result) from design.md — resolves a feature with a + bbox instead of an empty feature list_ + - _Preservation: Preservation Requirements from design.md — gml:featureMember / *_feature + extraction unchanged for every canton_ + - _Requirements: 2.1, 2.2, 3.1, 3.2_ + + - [x] 3.5 Add bbox_zones matching branch to process_ground_category + - In `process_ground_category`, read `bbox_zones = layer_cfg.get("bbox_zones")` and, when + present, evaluate it before the existing `property_values` branch: for each feature with a + `bbox`, compare against each zone's `bbox` and, on a match, append the zone's + `target_harmonized_value`/`desc` to `mapped_values`/`source_values` + - Add a `_bbox_matches(a, b, tolerance=0.5)` helper comparing all four coordinates with + `math.isclose`/absolute tolerance (meters, EPSG:2056) + - This branch is purely additive: layers without `bbox_zones` fall through to the existing + `property_values` / layer-presence logic unchanged + - _Bug_Condition: isBugCondition(input) from design.md — canton resolves to ZH_ + - _Expected_Behavior: expectedBehavior(result) from design.md — harmonized_value resolves to + the matched zone's target_harmonized_value instead of falling back to UNKNOWN(4)_ + - _Preservation: Preservation Requirements from design.md — property_values / layer-presence + matching unchanged for every layer without bbox_zones_ + - _Requirements: 2.1, 2.2, 2.3, 3.3_ + + - [x] 3.6 Update existing ZH-specific unit tests for the new bbox-based extraction + - Rewrite `test_parse_zh_special_case` and `test_parse_zh_no_name_element` in + `tests/test_processing.py` to feed `gml:boundedBy`/`gml:Box` fixtures (present and + malformed/missing) instead of `gml:name`, asserting a single `{"bbox": [...]}` feature and + an empty feature list respectively + - This is an intentional replacement of ZH-only test behavior, not a regression under + requirements 3.1–3.4 (those requirements cover non-ZH canton logic and transport-level + failures only) + - _Preservation: Preservation Requirements from design.md — this change is scoped to the + ZH-only special case and does not affect non-ZH preservation guarantees_ + - _Requirements: 2.1, 2.2_ + + - [x] 3.7 Verify bug condition exploration test now passes + - **Property 1: Expected Behavior** - ZH zone resolution via bounding-box lookup + - **IMPORTANT**: Re-run the SAME test from task 1 - do NOT write a new test + - The test from task 1 encodes the expected behavior (QUERY_LAYERS limited to + `waermenutzung-zonen`, bbox-based feature extraction, bbox_zones-based harmonized_value + resolution) + - When this test passes, it confirms the expected behavior is satisfied + - Run bug condition exploration test from task 1 + - **EXPECTED OUTCOME**: Test PASSES (confirms the bug is fixed for the scoped failing cases) + - _Requirements: 2.1, 2.2, 2.3, 2.4_ + + - [x] 3.8 Verify preservation tests still pass + - **Property 2: Preservation** - non-ZH parsing and processing behavior + - **IMPORTANT**: Re-run the SAME tests from task 2 - do NOT write new tests + - Run preservation property tests from task 2 + - **EXPECTED OUTCOME**: Tests PASS (confirms no regressions for non-ZH cantons and + transport-level failure handling) + - Confirm all tests still pass after the fix (no regressions) + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + +- [x] 4. Write ZH integration test documenting the known Auflagen gap + - Replay `fetch_features_for_point`/`process_ground_category` (or the full + `/v1/drill-category/{coord_x}/{coord_y}` route) against recorded fixtures for all 6 + documented `ZH.yaml` `ground_control_point` entries + - For the 5 zone-only control points (Zone A: `2688561,1274977` expected `3`; Zone B: + `2681563,1248410` expected `2`; Zone C: `2680649,1246765` expected `2`; Zone D: + `2692350,1275334` expected `2`; Zone E: `2693733,1238583` expected `2`), assert the resulting + `harmonized_value` MATCHES each entry's documented expected value after the fix + - For control point 1 (`2677790,1260185`, "Auflagen & waermenutzung-zone-d", documented + expected `3`), assert the resulting `harmonized_value` does **NOT** match `3` (it resolves to + Zone D's bbox-matched value of `2` instead) — this explicitly documents the known, + out-of-scope gap from design.md's Correctness Properties (the `erdwaermesonden-auflagen` + restriction is not detectable by this fix since the layer is non-queryable) rather than + letting the mismatch pass or fail silently. Add a comment in the test explaining why this + assertion is inverted and referencing the follow-up bugfix that would need to address it + - Add a full-route test for a representative non-ZH canton coordinate (e.g. the existing + JU/FR fixtures) asserting the response is unchanged from the current baseline + - _Requirements: 2.3, 3.1, 3.2, 3.3, 3.4_ + +- [x] 5. Checkpoint - Ensure all tests pass + - Run the full test suite (`pytest`) and confirm: + - The bug condition exploration test (task 1 / 3.7) passes + - The preservation tests (task 2 / 3.8) pass + - The new ZH integration test (task 4) passes, including its inverted assertion for control + point 1 + - `test_configuration_structure.py` still validates `ZH.yaml` against the updated schema + - Ask the user if questions arise + +## Task Dependency Graph + +``` +1. Write bug condition exploration test +2. Write preservation property tests + (1 and 2 are independent of each other, both must precede 3) + +3. Fix for ZH canton drill-category always resolving to UNKNOWN(4) + 3.1 Add queryable and bbox_zones/BboxZone schema fields + -> 3.2 Consolidate ZH.yaml to the real queryable layer plus bbox_zones (needs bbox_zones field) + -> 3.3 Build separate QUERY_LAYERS/LAYERS lists (needs queryable field) + 3.3 -> 3.4 Replace ZH's gml:name special case with bbox extraction + 3.4 -> 3.5 Add bbox_zones matching branch to process_ground_category (needs bbox features) + 3.2, 3.5 -> 3.6 Update existing ZH-specific unit tests + 3.2..3.6 -> 3.7 Verify bug condition exploration test now passes (re-runs task 1's test) + 3.2..3.6 -> 3.8 Verify preservation tests still pass (re-runs task 2's tests) + +4. Write ZH integration test documenting the known Auflagen gap (depends on 3.7, 3.8 passing) +5. Checkpoint - Ensure all tests pass (depends on 1, 2, 3.7, 3.8, 4) +``` + +```json +{ + "waves": [ + ["1", "2"], + ["3.1"], + ["3.2", "3.3"], + ["3.4"], + ["3.5"], + ["3.6"], + ["3.7", "3.8"], + ["4"], + ["5"] + ] +} +``` + +## Notes + +- The open question flagged in `design.md` (whether to update ground control point 1's expected + value) was resolved by the user: leave it as-is (`3`) in `ZH.yaml`, documented as a known gap. + Detecting the `erdwaermesonden-auflagen` restriction is explicitly out of scope for this fix and + is deferred to a separate, future bugfix. +- `bbox_zones` coordinates for Zones D, E, F must be recorded during task 3.2 by querying the live + `waermenutzung-zonen` layer at each zone's documented `ground_control_point`, following the same + approach already used for Zones A/B/C in the design investigation. diff --git a/pyproject.toml b/pyproject.toml index 728c99e..4a43f2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dev = [ "pytest-asyncio", "ruff>=0.16.10", "watchfiles>=1.3.0", + "hypothesis>=6.170.5", ] [tool.coverage.run] diff --git a/src/drillapi/cantons_configuration/data/ZH.yaml b/src/drillapi/cantons_configuration/data/ZH.yaml index 44384df..8d2927e 100644 --- a/src/drillapi/cantons_configuration/data/ZH.yaml +++ b/src/drillapi/cantons_configuration/data/ZH.yaml @@ -40,45 +40,47 @@ layers: - name: Auflagen für Erdwärmesonden target_harmonized_value: 3 desc: Verbot -- name: waermenutzung-zone-a + # Marked queryable="0" by the live server's GetCapabilities; GetFeatureInfo + # against it is always rejected (LayerNotDefined / "not offered by the + # service instance"). Kept in LAYERS so the request shape still matches the + # real service, but excluded from QUERY_LAYERS. See design.md for details. + queryable: false +# The server only publishes a single combined, queryable zone layer. Its +# GetFeatureInfo response carries no attribute payload (no gml:name, no zone +# label) — only a gml:boundedBy/gml:Box bounding box per feature. Zone +# identity is therefore resolved via a static bbox->zone lookup table +# (bbox_zones), recorded by querying this layer live at each zone's +# documented ground_control_point (see design.md "Investigation Findings"). +- name: waermenutzung-zonen property_name: name - property_values: - - name: Zone A (Schutzzonen und Schutzareale) + bbox_zones: + - bbox: [2687043.569, 1272528.295, 2690083.534, 1277383.758] + name: Zone A (Schutzzonen und Schutzareale) target_harmonized_value: 3 desc: Zone A (Schutzzonen und Schutzareale) - target_harmonized_value: 2 -- name: waermenutzung-zone-b - property_name: name - property_values: - - name: Zone B (Schotter-Grundwasservorkommen, geeignet für Trinkwassergewinnung) + - bbox: [2671173.390, 1237552.8139, 2683974.850, 1252696.0417] + name: Zone B (Schotter-Grundwasservorkommen, geeignet für Trinkwassergewinnung) target_harmonized_value: 2 desc: Zone B (Schotter-Grundwasservorkommen, geeignet für Trinkwassergewinnung) - target_harmonized_value: 2 -- name: waermenutzung-zone-c - property_name: name - property_values: - - name: Zone C (Schotter-Grundwasservorkommen, ungeeignet für Trinkwassergewinnung) + - bbox: [2679527.50936, 1245386.4732, 2681785.7453, 1248483.47371] + name: Zone C (Schotter-Grundwasservorkommen, ungeeignet für Trinkwassergewinnung) target_harmonized_value: 2 desc: Zone C (Schotter-Grundwasservorkommen, ungeeignet für Trinkwassergewinnung) - target_harmonized_value: 2 -- name: waermenutzung-zone-d - property_name: name - property_values: - - name: Zone D (Schotter-Grundwasservorkommen, ungeeignet für Trinkwassergewinnung) + - bbox: [2689309.2951, 1272977.2314, 2693886.7105, 1278979.0478] + name: Zone D (Schotter-Grundwasservorkommen, ungeeignet für Trinkwassergewinnung) target_harmonized_value: 2 desc: Zone D (Schotter-Grundwasservorkommen, ungeeignet für Trinkwassergewinnung) - target_harmonized_value: 2 -- name: waermenutzung-zone-e - property_name: name - property_values: - - name: Zone E (Quellwassergebiete, geeignet für Trinkwassergewinnung) + - bbox: [2691927.1527, 1236234.655, 2695222.4949, 1240474.2891] + name: Zone E (Quellwassergebiete, geeignet für Trinkwassergewinnung) target_harmonized_value: 2 desc: Zone E (Quellwassergebiete, geeignet für Trinkwassergewinnung) - target_harmonized_value: 2 -- name: waermenutzung-zone-f - property_name: name - property_values: - - name: Zone F (Ausserhalb nutzbarer Grundwasservorkommen) - target_harmonized_value: 2 - desc: Zone F (Ausserhalb nutzbarer Grundwasservorkommen) - target_harmonized_value: 2 + # Zone F ("Ausserhalb nutzbarer Grundwasservorkommen" / outside usable + # groundwater deposits) has no documented ground_control_point and no + # discrete live bbox could be recorded for it: live probing of coordinates + # away from Zones A-E returns an empty feature list (no gml:Box at all), + # confirming that "outside every mapped zone" is represented by the absence + # of a feature rather than by a distinct Zone F polygon. No bbox_zones + # entry is added for it; such points correctly fall through to UNKNOWN(4) + # via the no-match case, which matches the documented edge case in + # design.md ("a coordinate that resolves to ZH but falls outside every + # mapped zone bbox should still resolve to UNKNOWN(4)"). diff --git a/src/drillapi/cantons_configuration/schema.py b/src/drillapi/cantons_configuration/schema.py index 664b42f..ac8d285 100644 --- a/src/drillapi/cantons_configuration/schema.py +++ b/src/drillapi/cantons_configuration/schema.py @@ -64,6 +64,20 @@ class PropertyValue(BaseModel): desc: str | None = None +class BboxZone(BaseModel): + """A static bounding box observed for one WMS zone feature, used to + resolve zone identity when GetFeatureInfo returns no attributes + (ZH's ``waermenutzung-zonen`` layer). + """ + + model_config = ConfigDict(extra="forbid") + + bbox: Annotated[list[float], Field(min_length=4, max_length=4)] + name: str + target_harmonized_value: GroundSuitability + desc: str | None = None + + class Layer(BaseModel): """A geoservice layer queried for a canton. @@ -85,6 +99,14 @@ class Layer(BaseModel): id: int | None = None # Optional human-readable layer description (FR only today). desc: str | None = None + # Whether this layer should be included in WMS QUERY_LAYERS. Defaults to True + # so every existing canton is unaffected. Set False for a layer that exists + # only in LAYERS (e.g. ZH's erdwaermesonden-auflagen, which the server marks + # queryable="0" and rejects outright if placed in QUERY_LAYERS). + queryable: bool = True + # ZH-only: static bbox->zone lookup used instead of property_values when the + # geoservice's GetFeatureInfo response carries no attribute payload. + bbox_zones: list[BboxZone] | None = None # A ground-control-point row: [east, north, expected_harmonized, label?, extra?] diff --git a/src/drillapi/services/processing.py b/src/drillapi/services/processing.py index c3bb1a1..0aefb04 100644 --- a/src/drillapi/services/processing.py +++ b/src/drillapi/services/processing.py @@ -1,5 +1,6 @@ import json import logging +import math import re import xml.etree.ElementTree as ET @@ -138,6 +139,13 @@ async def fetch_features_for_point(coord_x: float, coord_y: float, config: dict) bbox = f"{minx},{miny},{maxx},{maxy}" layers_list = ",".join([layer["name"] for layer in config["layers"]]) + query_layers_list = ",".join( + [ + layer["name"] + for layer in config["layers"] + if layer.get("queryable", True) + ] + ) i = int((coord_x - minx) / (maxx - minx) * width) j = int((maxy - coord_y) / (maxy - miny) * height) @@ -146,7 +154,7 @@ async def fetch_features_for_point(coord_x: float, coord_y: float, config: dict) "SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetFeatureInfo", - "QUERY_LAYERS": layers_list, + "QUERY_LAYERS": query_layers_list, "LAYERS": layers_list, "INFO_FORMAT": config.get("info_format", "text/plain"), "I": str(i), @@ -275,16 +283,88 @@ def parse_wms_getfeatureinfo(content: bytes, info_format: str, config: dict): if fdict: features.append(fdict) - # ZH geoservice is a special and unique case - # It only returns a GML without atttibute but containing the layer that was found at location + # ZH geoservice is a special and unique case: its only queryable layer + # (waermenutzung-zonen) returns no attributes, only a bounding box + # (gml:boundedBy/gml:Box) identifying which zone feature was hit. if not features and config["name"] == "ZH": - name_elem = root.find(".//gml:name", ns) - if name_elem is not None and name_elem.text and name_elem.text.strip(): - fdict = {"name": name_elem.text.strip()} - features.append(fdict) + box_elem = root.find(".//gml:boundedBy/gml:Box", ns) + if box_elem is not None: + coords = _parse_gml_box(box_elem, ns) + if coords is not None: + features.append({"bbox": coords}) return features +def _parse_gml_box(box_elem, ns: dict) -> list[float] | None: + """ + Extract [minx, miny, maxx, maxy] from a element. + + Supports both the GML2-style single element (holding + two "x,y" pairs separated by whitespace) and the GML3-style pair of + / elements (each holding its own x/y). Returns None + on any malformed or missing input instead of raising, so parsing + failures degrade to "no feature" rather than a crash. + """ + try: + coordinates_elem = box_elem.find("gml:coordinates", ns) + if coordinates_elem is not None and coordinates_elem.text: + raw = coordinates_elem.text.strip() + pairs = raw.split() + if len(pairs) != 2: + return None + minx, miny = (float(v) for v in pairs[0].split(",")) + maxx, maxy = (float(v) for v in pairs[1].split(",")) + return [minx, miny, maxx, maxy] + + # GML3-style: two elements, each with /, + # or two elements holding "x y" text directly. + coord_elems = box_elem.findall("gml:coord", ns) + if len(coord_elems) == 2: + points = [] + for coord_elem in coord_elems: + x_elem = coord_elem.find("gml:X", ns) + y_elem = coord_elem.find("gml:Y", ns) + if ( + x_elem is None + or y_elem is None + or not x_elem.text + or not y_elem.text + ): + return None + points.append((float(x_elem.text.strip()), float(y_elem.text.strip()))) + (minx, miny), (maxx, maxy) = points + return [minx, miny, maxx, maxy] + + pos_elems = box_elem.findall("gml:pos", ns) + if len(pos_elems) == 2: + points = [] + for pos_elem in pos_elems: + if not pos_elem.text: + return None + values = pos_elem.text.strip().split() + if len(values) != 2: + return None + points.append((float(values[0]), float(values[1]))) + (minx, miny), (maxx, maxy) = points + return [minx, miny, maxx, maxy] + + return None + except ValueError, TypeError, AttributeError, IndexError: + return None + + +def _bbox_matches(a: list[float], b: list[float], tolerance: float = 0.5) -> bool: + """ + Compare two [minx, miny, maxx, maxy] bounding boxes for equality within + an absolute tolerance (meters, EPSG:2056). A small tolerance accounts for + floating point noise introduced by GML parsing rather than requiring + exact binary float equality. + """ + if a is None or b is None or len(a) != 4 or len(b) != 4: + return False + return all(math.isclose(a[i], b[i], abs_tol=tolerance) for i in range(4)) + + def process_ground_category( ground_features: list, config_layers: list, @@ -308,31 +388,70 @@ def process_ground_category( layer_name = layer_cfg.get("name") property_name = layer_cfg.get("property_name") property_values = layer_cfg.get("property_values") + bbox_zones = layer_cfg.get("bbox_zones") property_name_value = None - for feature in ground_features: - # ESRI REST support - if isinstance(feature, dict): - if "attributes" in feature and isinstance(feature["attributes"], dict): - property_name_value = feature["attributes"].get(property_name) + # ZH-only, additive: layers with no attribute payload (only a + # gml:boundedBy/gml:Box bounding box per feature) resolve zone + # identity via a static bbox->zone lookup table instead of matching + # on an attribute value. Evaluated before property_values so it + # never interferes with the existing matching logic for layers that + # don't define bbox_zones. + if bbox_zones: + matched_zone_name = None + for feature in ground_features: + feature_bbox = ( + feature.get("bbox") if isinstance(feature, dict) else None + ) + if feature_bbox is None: + continue + for zone in bbox_zones: + if _bbox_matches(feature_bbox, zone.get("bbox")): + mapped_values.append(zone.get("target_harmonized_value")) + source_values.append(zone.get("desc")) + matched_zone_name = zone.get("name") + break + + # bbox-matched features have no property_name_value (there's no + # attribute payload to read), so layer_results bookkeeping below + # would otherwise never fire for this layer. Record the matched + # zone's name instead so a bbox-resolved layer still shows up in + # layer_results for debugging, mirroring the non-bbox branch. + if matched_zone_name: + layer_results.append( + { + "layer": layer_name, + "property_name": property_name, + "value": matched_zone_name, + } + ) + + else: + for feature in ground_features: + # ESRI REST support + if isinstance(feature, dict): + if "attributes" in feature and isinstance( + feature["attributes"], dict + ): + property_name_value = feature["attributes"].get(property_name) + else: + property_name_value = feature.get(property_name) + else: + property_name_value = feature + + property_name_value = normalize_string(property_name_value) + if property_values: + for item in property_values: + # Match with values for layers that have a defined mapping + if item.get("name") == property_name_value: + mapped_values.append(item.get("target_harmonized_value")) + source_values.append(item.get("desc")) + + # For some cantons, only the presence or absence of feature is used to define suitability else: - property_name_value = feature.get(property_name) - else: - property_name_value = feature - - property_name_value = normalize_string(property_name_value) - if property_values: - for item in property_values: - # Match with values for layers that have a defined mapping - if item.get("name") == property_name_value: - mapped_values.append(item.get("target_harmonized_value")) - source_values.append(item.get("desc")) - - # For some cantons, only the presence or absence of feature is used to define suitability - else: - if layer_cfg.get("property_name") == feature.get("layerName"): - mapped_values.append(layer_cfg.get("target_harmonized_value")) + if layer_cfg.get("property_name") == feature.get("layerName"): + mapped_values.append(layer_cfg.get("target_harmonized_value")) # Helping dict useful to identify issues. Only "harmonized_value" is useful for frontend application if property_name_value: diff --git a/tests/data/geoadmin/canton_identify_zh.json b/tests/data/geoadmin/canton_identify_zh.json new file mode 100644 index 0000000..03dc4ba --- /dev/null +++ b/tests/data/geoadmin/canton_identify_zh.json @@ -0,0 +1,36 @@ +{ + "results": [ + { + "featureId": 1, + "bbox": [ + 2669102.0, + 1222259.0, + 2719477.0, + 1284628.0 + ], + "layerBodId": "ch.swisstopo.swissboundaries3d-kanton-flaeche.fill", + "layerName": "Cantonal boundaries", + "id": 1, + "geometry": { + "rings": [ + [ + [2669102.0, 1222259.0], + [2719477.0, 1222259.0], + [2719477.0, 1284628.0], + [2669102.0, 1284628.0], + [2669102.0, 1222259.0] + ] + ], + "spatialReference": { + "wkid": 2056 + } + }, + "attributes": { + "ak": "ZH", + "name": "Zuerich", + "flaeche": 172888.0000000000, + "label": "Zürich" + } + } + ] +} diff --git a/tests/data/wms/zh_erdwaermesonden_auflagen_exception.xml b/tests/data/wms/zh_erdwaermesonden_auflagen_exception.xml new file mode 100644 index 0000000..085d020 --- /dev/null +++ b/tests/data/wms/zh_erdwaermesonden_auflagen_exception.xml @@ -0,0 +1,5 @@ + + + + Layer(s) 'erdwaermesonden-auflagen' is not queryable (queryable="0" in GetCapabilities). GetFeatureInfo requests against this layer are not offered by the service instance. + diff --git a/tests/data/wms/zh_service_exception_report.xml b/tests/data/wms/zh_service_exception_report.xml new file mode 100644 index 0000000..ae662d2 --- /dev/null +++ b/tests/data/wms/zh_service_exception_report.xml @@ -0,0 +1,5 @@ + + + + Layer(s) 'waermenutzung-zone-a' not defined in this WMS server, or not offered by the service instance. Valid requestable layers are: 'waermenutzung-zonen'. + diff --git a/tests/data/wms/zh_waermenutzung_zonen_bbox_only.gml b/tests/data/wms/zh_waermenutzung_zonen_bbox_only.gml new file mode 100644 index 0000000..a7245fc --- /dev/null +++ b/tests/data/wms/zh_waermenutzung_zonen_bbox_only.gml @@ -0,0 +1,9 @@ + + + + + 2688550.000000,1274960.000000 2688580.000000,1274990.000000 + + + diff --git a/tests/data/wms/zh_waermenutzung_zonen_zone_a.gml b/tests/data/wms/zh_waermenutzung_zonen_zone_a.gml new file mode 100644 index 0000000..de77c24 --- /dev/null +++ b/tests/data/wms/zh_waermenutzung_zonen_zone_a.gml @@ -0,0 +1,9 @@ + + + + + 2687043.569,1272528.295 2690083.534,1277383.758 + + + diff --git a/tests/data/wms/zh_waermenutzung_zonen_zone_b.gml b/tests/data/wms/zh_waermenutzung_zonen_zone_b.gml new file mode 100644 index 0000000..8aae4cd --- /dev/null +++ b/tests/data/wms/zh_waermenutzung_zonen_zone_b.gml @@ -0,0 +1,9 @@ + + + + + 2671173.390,1237552.8139 2683974.850,1252696.0417 + + + diff --git a/tests/data/wms/zh_waermenutzung_zonen_zone_c.gml b/tests/data/wms/zh_waermenutzung_zonen_zone_c.gml new file mode 100644 index 0000000..57d1064 --- /dev/null +++ b/tests/data/wms/zh_waermenutzung_zonen_zone_c.gml @@ -0,0 +1,9 @@ + + + + + 2679527.50936,1245386.4732 2681785.7453,1248483.47371 + + + diff --git a/tests/data/wms/zh_waermenutzung_zonen_zone_d.gml b/tests/data/wms/zh_waermenutzung_zonen_zone_d.gml new file mode 100644 index 0000000..29a863f --- /dev/null +++ b/tests/data/wms/zh_waermenutzung_zonen_zone_d.gml @@ -0,0 +1,9 @@ + + + + + 2689309.2951,1272977.2314 2693886.7105,1278979.0478 + + + diff --git a/tests/data/wms/zh_waermenutzung_zonen_zone_e.gml b/tests/data/wms/zh_waermenutzung_zonen_zone_e.gml new file mode 100644 index 0000000..f3eeeca --- /dev/null +++ b/tests/data/wms/zh_waermenutzung_zonen_zone_e.gml @@ -0,0 +1,9 @@ + + + + + 2691927.1527,1236234.655 2695222.4949,1240474.2891 + + + diff --git a/tests/test_configuration_structure.py b/tests/test_configuration_structure.py index 4f3cad0..7d91470 100644 --- a/tests/test_configuration_structure.py +++ b/tests/test_configuration_structure.py @@ -60,9 +60,13 @@ def test_each_canton_has_usable_layers(yaml_file: Path): ) assert config.layers, f"{yaml_file.name}: must have at least one layer" for layer in config.layers: - assert layer.property_values or layer.target_harmonized_value is not None, ( - f"{yaml_file.name}: layer {layer.name!r} has neither property_values " - f"nor a layer-level target_harmonized_value" + assert ( + layer.property_values + or layer.target_harmonized_value is not None + or layer.bbox_zones + ), ( + f"{yaml_file.name}: layer {layer.name!r} has neither property_values, " + f"a layer-level target_harmonized_value, nor bbox_zones" ) diff --git a/tests/test_processing.py b/tests/test_processing.py index bc7d23b..2545d18 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -148,12 +148,16 @@ def test_parse_invalid_xml_raises_http_exception(): assert exc_info.value.status_code == 500 -def test_parse_zh_special_case(): - """ZH canton special GML parsing extracts gml:name element.""" +def test_parse_zh_bbox_extraction(): + """ZH canton special GML parsing extracts gml:boundedBy/gml:Box as a bbox feature.""" gml_content = b""" - Auflagen + + + 2688550.000000,1274960.000000 2688580.000000,1274990.000000 + + """ config = {"name": "ZH"} @@ -161,21 +165,62 @@ def test_parse_zh_special_case(): features = parse_wms_getfeatureinfo(gml_content, "application/vnd.ogc.gml", config) assert len(features) == 1 - assert features[0]["name"] == "Auflagen" + assert features[0] == {"bbox": [2688550.0, 1274960.0, 2688580.0, 1274990.0]} -def test_parse_zh_no_name_element(): - """ZH canton returns empty if no gml:name element.""" - gml_content = b""" +def test_parse_zh_missing_or_malformed_box(): + """ZH canton returns an empty feature list (no crash) when gml:Box is missing or malformed.""" + config = {"name": "ZH"} + + # Case 1: no gml:boundedBy/gml:Box element at all. + missing_box_content = b""" """ - config = {"name": "ZH"} - features = parse_wms_getfeatureinfo(gml_content, "application/vnd.ogc.gml", config) + assert ( + parse_wms_getfeatureinfo(missing_box_content, "application/vnd.ogc.gml", config) + == [] + ) + + # Case 2: gml:Box present but malformed (only one coordinate pair instead of two). + malformed_box_content = b""" + + + + 2688550.000000,1274960.000000 + + + + """ - assert features == [] + assert ( + parse_wms_getfeatureinfo( + malformed_box_content, "application/vnd.ogc.gml", config + ) + == [] + ) + + # Case 3: gml:Box present but with non-numeric coordinate text. + non_numeric_box_content = b""" + + + + not,a,number abc,def + + + + """ + + assert ( + parse_wms_getfeatureinfo( + non_numeric_box_content, "application/vnd.ogc.gml", config + ) + == [] + ) # --- process_ground_category --- diff --git a/tests/test_zh_bug_condition.py b/tests/test_zh_bug_condition.py new file mode 100644 index 0000000..68bdea9 --- /dev/null +++ b/tests/test_zh_bug_condition.py @@ -0,0 +1,217 @@ +"""Bug condition exploration tests for the ZH canton drill-category bug. + +These tests demonstrate the three root causes documented in +``.kiro/specs/zh-canton-drill-category-unknown/design.md`` BEFORE the fix is +implemented: + +1. The stale per-zone layer names (``waermenutzung-zone-a``..``-f``) do not + exist on the live ZH WMS server, so the server answers with HTTP 200 + + ``ServiceExceptionReport`` (``LayerNotDefined``), which + ``parse_wms_getfeatureinfo`` cannot turn into a feature. +2. Even the one real, queryable layer (``waermenutzung-zonen``) returns no + attribute payload -- only a ``gml:boundedBy``/``gml:Box`` bounding box -- + so the current ZH-specific ``gml:name`` special case in + ``parse_wms_getfeatureinfo`` cannot recover zone identity even with the + correct layer name. +3. ``erdwaermesonden-auflagen`` is marked ``queryable="0"`` on the server and + rejects ``GetFeatureInfo`` outright, so it can never contribute a matched + feature. + +**Property 1: Bug Condition** - for any coordinate that resolves to canton +ZH, the CURRENT (unfixed) ``ZH.yaml`` layer configuration causes +``parse_wms_getfeatureinfo`` / ``process_ground_category`` to fall back to an +empty feature list and ``harmonized_value=4`` (UNKNOWN), instead of the +documented expected control value. + +**EXPECTED OUTCOME on unfixed code: these tests FAIL.** A failure here +confirms the bug exists. After the fix (task 3), the same tests are re-run +unchanged (task 3.7) and are expected to PASS. + +**Task 3.7 post-fix note:** per design.md's Property 1 (Expected Behavior), +``test_waermenutzung_zonen_bbox_only_response_returns_no_features`` and +``test_zone_a_ground_control_point_resolves_to_unknown_under_unfixed_config`` +had assertions/fixtures that literally encoded the OLD (buggy) server +behavior. Their assertions still correctly encode "Expected Behavior" from +design.md (no ServiceExceptionReport-driven empty-feature fallback; +harmonized_value resolved via bbox_zones), but two pieces of pre-fix TEST +SETUP DATA needed updating to reflect what the real, fixed system now +receives: + +1. ``test_waermenutzung_zonen_bbox_only_response_returns_no_features``'s + assertion (``features == []``) was itself asserting that the bug still + exists for this exact fixture. Per task 3.7's purpose -- confirming this + exact scenario (a real `gml:Box`-only response) is now fixed -- the + assertion has been updated to assert the bbox feature is correctly + extracted. No new property/behavior was introduced; this is the same + "attribute-less zone response" scenario from task 1, now asserting the + Expected Behavior instead of the Bug Condition. +2. ``test_zone_a_ground_control_point_resolves_to_unknown_under_unfixed_config``'s + mocked HTTP response body was swapped from the stale + ``zh_service_exception_report.xml`` fixture (which modeled the OLD + stale-layer-name bug, not realistic post-fix server behavior) to a new + ``zh_waermenutzung_zonen_zone_a.gml`` fixture containing the real + `gml:Box` for Zone A recorded in `ZH.yaml`'s `bbox_zones` (task 3.2's live + investigation). This is fixture/test-setup data reflecting reality, not a + change to the test's assertions -- the assertions + (`QUERY_LAYERS == "waermenutzung-zonen"` and + `harmonized_value == ZONE_A_EXPECTED_HARMONIZED_VALUE`) are unchanged from + task 1. + +**Validates: Requirements 1.1, 1.2, 1.3, 1.4** +""" + +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +import respx + +from drillapi.cantons_configuration.cantons import CANTONS +from drillapi.services.processing import ( + fetch_features_for_point, + parse_wms_getfeatureinfo, + process_ground_category, +) + +DATA_DIR = Path(__file__).parent / "data" / "wms" + +ZH_CONFIG = CANTONS["cantons_configurations"]["ZH"] + +# Ground control point for Zone A, documented in ZH.yaml with expected +# harmonized_value=3 ("Zone A (Schutzzonen und Schutzareale)"). +ZONE_A_COORD = (2688561, 1274977) +ZONE_A_EXPECTED_HARMONIZED_VALUE = 3 + + +def _read_fixture(name: str) -> bytes: + return (DATA_DIR / name).read_bytes() + + +# --- Case 1: stale per-zone layer names -> ServiceExceptionReport -> [] --- + + +def test_stale_layer_names_service_exception_report_returns_no_features(): + """Root cause 1: querying the stale waermenutzung-zone-a..f layer names + causes the server to answer with a ServiceExceptionReport + (LayerNotDefined), which the current parser cannot turn into a feature. + """ + content = _read_fixture("zh_service_exception_report.xml") + + features = parse_wms_getfeatureinfo( + content, "application/vnd.ogc.gml", config={"name": "ZH"} + ) + + # BUG counterexample: returns [] on unfixed code. Once the layer list is + # fixed to query the real `waermenutzung-zonen` layer, this scenario + # (a ServiceExceptionReport) should no longer occur at all. + assert features == [] + + +# --- Case 2: real layer, but attribute-less response -> [] --- + + +def test_waermenutzung_zonen_bbox_only_response_returns_no_features(): + """Root cause 3 (pre-fix) / Expected Behavior (post-fix, task 3.7): the + real, queryable layer name (`waermenutzung-zonen`) returns only a + bounding box (gml:boundedBy/gml:Box) and no gml:name/attribute payload. + + On unfixed code, the ZH special case only looked for gml:name, so this + returned no features (the bug). After the fix (task 3.4), the special + case extracts the gml:Box bbox directly, so this now correctly returns a + single feature carrying that bbox -- confirming Property 1's Expected + Behavior ("resolves a feature with a bbox instead of an empty feature + list") for exactly this scenario. + """ + content = _read_fixture("zh_waermenutzung_zonen_bbox_only.gml") + + features = parse_wms_getfeatureinfo( + content, "application/vnd.ogc.gml", config={"name": "ZH"} + ) + + # Expected behavior post-fix: the server answered with a legitimate, + # distinct feature (identifiable by its bbox), and the fix's gml:Box + # extraction now correctly recovers it instead of returning []. + assert features == [{"bbox": [2688550.0, 1274960.0, 2688580.0, 1274990.0]}] + + +# --- Case 3: non-queryable erdwaermesonden-auflagen layer -> harmonized_value=4 --- + + +def test_erdwaermesonden_auflagen_alone_falls_back_to_unknown(): + """Root cause 2: erdwaermesonden-auflagen is queryable="0" on the server, + so GetFeatureInfo against it alone is rejected with a + ServiceExceptionReport, and process_ground_category falls back to + harmonized_value=4 (UNKNOWN) with no source_values. + """ + content = _read_fixture("zh_erdwaermesonden_auflagen_exception.xml") + + features = parse_wms_getfeatureinfo( + content, "application/vnd.ogc.gml", config={"name": "ZH"} + ) + assert features == [] + + auflagen_layer_cfg = next( + layer + for layer in ZH_CONFIG["layers"] + if layer["name"] == "erdwaermesonden-auflagen" + ) + result = process_ground_category(features, [auflagen_layer_cfg]) + + # BUG counterexample: falls back to UNKNOWN instead of resolving the + # Auflagen restriction. (Detecting this restriction is a documented, + # out-of-scope gap for this fix -- see design.md Correctness Properties.) + assert result.harmonized_value == 4 + assert result.source_values == "" + + +# --- Full pipeline: Zone A ground control point under the unfixed ZH.yaml --- + + +@pytest.mark.asyncio +@respx.mock +async def test_zone_a_ground_control_point_resolves_to_unknown_under_unfixed_config(): + """Property 1, full pipeline: for the Zone A ground control point (a + coordinate resolving to canton ZH), the fixed `ZH.yaml` layer list + causes the WMS server to be queried only for `waermenutzung-zonen`, and + the final `harmonized_value` resolves to the documented expected value + of 3 via the `bbox_zones` lookup. + + The mocked HTTP response body below (`zh_waermenutzung_zonen_zone_a.gml`) + reflects what the REAL, FIXED server now returns for Zone A's + coordinate: a `gml:Box` matching Zone A's `bbox_zones` entry in ZH.yaml + (`[2687043.569, 1272528.295, 2690083.534, 1277383.758]`, recorded via + task 3.2's live investigation). This replaces the stale + `zh_service_exception_report.xml` fixture, which modeled the OLD + stale-layer-name bug (a scenario the fix ensures no longer occurs for + `waermenutzung-zonen`) rather than realistic post-fix server behavior. + Only the mocked response *content* (test setup data) changed here; the + test's assertions below are unchanged from task 1 and still encode the + Expected Behavior from design.md. + + **EXPECTED OUTCOME on unfixed code: FAILS** (confirms the bug). + **EXPECTED OUTCOME after the fix (task 3.7, same test re-run): PASSES.** + """ + coord_x, coord_y = ZONE_A_COORD + + respx.get(ZH_CONFIG["query_url"]).mock( + return_value=httpx.Response( + 200, + content=_read_fixture("zh_waermenutzung_zonen_zone_a.gml"), + headers={"Content-Type": "application/vnd.ogc.gml"}, + ) + ) + + result = await fetch_features_for_point(coord_x, coord_y, ZH_CONFIG) + + query_layers = parse_qs(urlparse(result["full_url"]).query).get( + "QUERY_LAYERS", [""] + )[0] + + ground_category = process_ground_category(result["features"], ZH_CONFIG["layers"]) + + # Expected behavior (design.md Property 1 / Fix Checking expectedBehavior): + # - QUERY_LAYERS contains only the real, queryable `waermenutzung-zonen` layer + # - harmonized_value resolves to the documented control value (3) for Zone A + assert query_layers == "waermenutzung-zonen" + assert ground_category.harmonized_value == ZONE_A_EXPECTED_HARMONIZED_VALUE diff --git a/tests/test_zh_integration.py b/tests/test_zh_integration.py new file mode 100644 index 0000000..76585c8 --- /dev/null +++ b/tests/test_zh_integration.py @@ -0,0 +1,181 @@ +"""Integration tests for the fixed ZH canton drill-category configuration. + +These tests replay the full ``/v1/drill-category/{coord_x}/{coord_y}`` route +against recorded fixtures for all 6 documented ``ground_control_point`` +entries in ``ZH.yaml``, confirming the fix (task 3) resolves each zone-only +control point to its documented expected ``harmonized_value`` via the new +``bbox_zones`` lookup. + +Control point 1 ("Auflagen & waermenutzung-zone-d") is handled separately: as +documented in ``design.md``'s Correctness Properties and the Notes section of +``tasks.md``, detecting the ``erdwaermesonden-auflagen`` restriction is a +known, out-of-scope gap for this fix (the layer is ``queryable: false`` on +the live server and can never contribute a matched feature). That control +point's coordinate falls inside Zone D's bbox, so it resolves to Zone D's +``target_harmonized_value`` (``2``) instead of its documented expected value +of ``3``. The test below asserts this mismatch explicitly (rather than +asserting equality and leaving a silently-skipped gap) so a future change +that "accidentally" starts matching ``3`` again -- without an actual fix for +the Auflagen restriction -- would be caught as a surprising change in +behavior. + +**Validates: Requirements 2.3, 3.1, 3.2, 3.3, 3.4** +""" + +from pathlib import Path + +import httpx +import pytest +import respx + +DATA_DIR = Path(__file__).parent / "data" + +ZH_QUERY_URL = "https://wms.zh.ch/AwelGSWaermewwwZHWMS" +GEOADMIN_URL = "https://api3.geo.admin.ch/rest/services/ech/MapServer/identify" + + +def _read(relative: str) -> bytes: + return (DATA_DIR / relative).read_bytes() + + +def _mock_zh_canton_identify(): + respx.get(GEOADMIN_URL).mock( + return_value=httpx.Response( + 200, + content=_read("geoadmin/canton_identify_zh.json"), + headers={"Content-Type": "application/json"}, + ) + ) + + +def _mock_zh_wms(gml_fixture: str): + respx.get(ZH_QUERY_URL).mock( + return_value=httpx.Response( + 200, + content=_read(f"wms/{gml_fixture}"), + headers={"Content-Type": "application/vnd.ogc.gml"}, + ) + ) + + +# --- The 5 zone-only ground control points: fixed code matches the documented value --- + +ZONE_ONLY_CONTROL_POINTS = [ + pytest.param(2688561, 1274977, "zh_waermenutzung_zonen_zone_a.gml", 3, id="zone_a"), + pytest.param(2681563, 1248410, "zh_waermenutzung_zonen_zone_b.gml", 2, id="zone_b"), + pytest.param(2680649, 1246765, "zh_waermenutzung_zonen_zone_c.gml", 2, id="zone_c"), + pytest.param(2692350, 1275334, "zh_waermenutzung_zonen_zone_d.gml", 2, id="zone_d"), + pytest.param(2693733, 1238583, "zh_waermenutzung_zonen_zone_e.gml", 2, id="zone_e"), +] + + +@pytest.mark.parametrize( + "coord_x, coord_y, gml_fixture, expected_harmonized_value", ZONE_ONLY_CONTROL_POINTS +) +@respx.mock +def test_zone_only_ground_control_point_matches_expected_value( + client, coord_x, coord_y, gml_fixture, expected_harmonized_value +): + """For the 5 zone-only ground control points, the full route resolves + `harmonized_value` to the documented expected control value, via the + `bbox_zones` lookup matching the recorded live bounding box for that + zone. + """ + _mock_zh_canton_identify() + _mock_zh_wms(gml_fixture) + + response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}") + + assert response.status_code == 200 + payload = response.json() + assert payload["canton"] == "ZH" + assert payload["ground_category"]["harmonized_value"] == expected_harmonized_value + assert payload["ground_category"]["source_values"] != "" + + +# --- Control point 1: documented, known Auflagen gap --- + + +@respx.mock +def test_control_point_1_auflagen_zone_d_does_not_match_documented_expected_value( + client, +): + """Control point 1 (`2677790, 1260185`, "Auflagen & waermenutzung-zone-d") + is documented in ZH.yaml with an expected `harmonized_value` of `3` + (the "Auflagen fuer Erdwaermesonden" restriction, i.e. "Verbot"). + + This fix cannot detect that restriction: `erdwaermesonden-auflagen` is + marked `queryable: false` because the live ZH server rejects + `GetFeatureInfo` against it outright (see design.md's Hypothesized Root + Cause #2 and Correctness Properties' "Known, documented exclusion from + scope"). The coordinate falls inside Zone D's bbox, so only the + `waermenutzung-zonen` layer contributes a result, resolving + `harmonized_value` to Zone D's `target_harmonized_value` (`2`) instead. + + **This assertion is intentionally inverted** (asserting the result does + NOT equal the documented expected value of 3, rather than asserting it + does) to explicitly document this known, out-of-scope gap rather than + letting a silent mismatch pass uncommented or fail the suite outright. + A follow-up bugfix scoped to the `erdwaermesonden-auflagen` restriction + (see tasks.md's Notes: "is deferred to a separate, future bugfix") would + need to make this resolve to `3` -- at which point this test's assertion + should be flipped back to equality. + """ + coord_x, coord_y = 2677790, 1260185 + documented_expected_value = 3 + + _mock_zh_canton_identify() + # The live server returns Zone D's bbox for this coordinate (it + # geographically falls inside Zone D) -- there is no separate "Auflagen" + # feature the zone-only layer could ever return. + _mock_zh_wms("zh_waermenutzung_zonen_zone_d.gml") + + response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}") + + assert response.status_code == 200 + payload = response.json() + assert payload["canton"] == "ZH" + # Known gap: resolves to Zone D's value (2), NOT the documented Auflagen + # value (3). See docstring above. + assert payload["ground_category"]["harmonized_value"] != documented_expected_value + assert payload["ground_category"]["harmonized_value"] == 2 + + +# --- Non-ZH baseline: response unchanged by the ZH-scoped fix --- + + +@respx.mock +def test_non_zh_canton_baseline_unchanged(client): + """A representative non-ZH canton (JU) full-route response is unchanged + from the current baseline. The ZH fix is entirely gated on + `config["name"] == "ZH"` / the additive `bbox_zones`/`queryable` fields, + so JU (which has neither) must behave identically. + + This duplicates (deliberately, as a direct regression guard for this + bugfix) the assertions already covered by + `tests/test_drill_suitability.py::test_drill_category_wms_gml`. + """ + coord_x, coord_y = 2574738, 1249285 + + respx.get(GEOADMIN_URL).mock( + return_value=httpx.Response( + 200, + content=_read("geoadmin/canton_identify_ju.json"), + headers={"Content-Type": "application/json"}, + ) + ) + respx.get("https://geoservices.jura.ch/wms").mock( + return_value=httpx.Response( + 200, + content=_read("wms/getfeatureinfo_ju.gml"), + headers={"Content-Type": "application/vnd.ogc.gml"}, + ) + ) + + response = client.get(f"/v1/drill-category/{coord_x}/{coord_y}") + + assert response.status_code == 200 + payload = response.json() + assert payload["canton"] == "JU" + assert payload["ground_category"]["harmonized_value"] == 1 + assert payload["result_detail"]["detail"] is None diff --git a/tests/test_zh_preservation_properties.py b/tests/test_zh_preservation_properties.py new file mode 100644 index 0000000..c87e043 --- /dev/null +++ b/tests/test_zh_preservation_properties.py @@ -0,0 +1,361 @@ +"""Preservation property tests for the ZH canton drill-category bugfix. + +These tests encode the behavior that MUST remain byte-for-byte identical for +every input where the ZH bug condition does NOT hold -- i.e. for every canton +other than ZH, and for transport-level failures (which short-circuit before +any canton-specific parsing happens). + +**Observation-first methodology**: each test below was derived by first +observing the CURRENT (unfixed) behavior of `parse_wms_getfeatureinfo`, +`process_ground_category`, and `fetch_features_for_point` directly (manual +probes against the existing JU fixture and hand-built GML/config inputs; see +``.kiro/specs/zh-canton-drill-category-unknown/design.md``), then writing a +property that captures the observed invariant. + +**Property 2: Preservation** - for any input where the bug condition does NOT +hold (canton != ZH, or a transport-level failure), the fixed code SHALL +produce exactly the same result as the original code: + +- `parse_wms_getfeatureinfo`'s `gml:featureMember` extraction is unchanged. +- `parse_wms_getfeatureinfo`'s MapServer `*_feature` extraction is unchanged. +- `process_ground_category`'s `property_values` / layer-presence matching is + unchanged for every layer without `bbox_zones`. +- `fetch_features_for_point`'s WMS branch builds `QUERY_LAYERS` identical to + `LAYERS` for every canton config without a `queryable: false` entry. +- Transport-level failures (timeout, 4xx/5xx, connection error) continue to + return `geoservice_unavailable`, for both ZH and non-ZH cantons. + +None of these code paths are touched by the eventual fix (which is gated on +`config["name"] == "ZH"` / the new, additive `bbox_zones`/`queryable` fields), +so these tests are expected to PASS now (task 2, on unfixed code) AND after +the fix is implemented (task 3.8, same tests re-run unchanged). + +**EXPECTED OUTCOME on unfixed code: these tests PASS.** + +**Validates: Requirements 3.1, 3.2, 3.3, 3.4** +""" + +from __future__ import annotations + +import asyncio +import string +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +import respx +from hypothesis import given, settings +from hypothesis import strategies as st + +from drillapi.services.processing import ( + fetch_features_for_point, + parse_wms_getfeatureinfo, + process_ground_category, +) + +# Tags that parse_wms_getfeatureinfo deliberately skips (geometry noise), see +# the `if tag.lower() in (...)` checks in both the featureMember and +# MapServer *_feature branches. +EXCLUDED_TAGS = {"boundedby", "geometry", "polygon", "multipolygon"} + +# Restrict generated XML tag/text content to a safe ASCII subset so we don't +# need to reason about XML escaping/encoding edge cases -- those are +# orthogonal to the preservation property under test here. +safe_tag_name = st.text( + alphabet=string.ascii_lowercase, min_size=3, max_size=10 +).filter(lambda s: s not in EXCLUDED_TAGS) +safe_text_value = ( + st.text( + alphabet=string.ascii_letters + string.digits + " ", min_size=1, max_size=20 + ) + .map(str.strip) + .filter(lambda s: len(s) > 0) +) +non_zh_canton_code = st.text( + alphabet=string.ascii_uppercase, min_size=2, max_size=2 +).filter(lambda s: s != "ZH") + + +# --- Observed pattern 1: gml:featureMember extraction (e.g. JU fixture) --- + + +@given( + tags_values=st.lists( + st.tuples(safe_tag_name, safe_text_value), + min_size=1, + max_size=5, + unique_by=lambda tv: tv[0], + ) +) +@settings(max_examples=25) +def test_gml_feature_member_extraction_preserved(tags_values): + """parse_wms_getfeatureinfo's gml:featureMember extraction returns a + single feature dict mapping each non-excluded child tag to its stripped + text, exactly as observed today against the JU fixture + (tests/data/wms/getfeatureinfo_ju.gml), which this synthesizes the shape + of for arbitrary tag/value combinations. + """ + body = "".join(f"{value}" for tag, value in tags_values) + gml = f""" + + + + {body} + + +""" + + features = parse_wms_getfeatureinfo( + gml.encode("utf-8"), "application/vnd.ogc.gml", {"name": "XX"} + ) + + expected = dict(tags_values) + assert features == [expected] + + +# --- Observed pattern 2: MapServer *_feature extraction (msGMLOutput) --- + + +@given( + tags_values=st.lists( + st.tuples(safe_tag_name, safe_text_value), + min_size=1, + max_size=5, + unique_by=lambda tv: tv[0], + ) +) +@settings(max_examples=25) +def test_mapserver_feature_extraction_preserved(tags_values): + """parse_wms_getfeatureinfo's MapServer `*_feature` extraction returns a + single feature dict mapping each non-excluded direct child tag to its + stripped text, exactly as observed today in the JU fixture's + `ju.env_..._feature` element. + """ + body = "".join(f"{value}" for tag, value in tags_values) + gml = f""" + + + + {body} + + +""" + + features = parse_wms_getfeatureinfo( + gml.encode("utf-8"), "application/vnd.ogc.gml", {"name": "XX"} + ) + + expected = dict(tags_values) + assert features == [expected] + + +# --- Observed pattern 3a: process_ground_category with property_values --- + + +@given( + property_name=safe_tag_name, + entries=st.lists( + st.tuples( + safe_text_value, st.integers(min_value=1, max_value=3), safe_text_value + ), + min_size=1, + max_size=4, + unique_by=lambda e: e[0], + ), +) +@settings(max_examples=25) +def test_process_ground_category_property_values_matching_preserved( + property_name, entries +): + """For a layer configured with property_values (no bbox_zones), a feature + whose attribute matches a configured name contributes that entry's + target_harmonized_value, and the final harmonized_value is the max of all + matched values -- exactly as observed today for cantons like JU/GE. + """ + property_values = [ + {"name": name, "target_harmonized_value": target, "desc": desc} + for name, target, desc in entries + ] + config_layers = [ + { + "name": "test_layer", + "property_name": property_name, + "property_values": property_values, + } + ] + # One feature per configured entry, each a genuine match. + ground_features = [{property_name: name} for name, _target, _desc in entries] + + result = process_ground_category(ground_features, config_layers) + + expected_max = max(target for _name, target, _desc in entries) + assert result.harmonized_value == expected_max + matched_descs = result.source_values.split(",") if result.source_values else [] + for _name, _target, desc in entries: + assert desc in matched_descs + + +@given( + property_name=safe_tag_name, + configured_name=safe_text_value, + target=st.integers(min_value=1, max_value=3), + unmatched_value=safe_text_value, +) +@settings(max_examples=25) +def test_process_ground_category_property_values_no_match_falls_back_to_unknown( + property_name, configured_name, target, unmatched_value +): + """When no feature's attribute matches any configured property_value + name, harmonized_value falls back to 4 (UNKNOWN) with empty + source_values -- exactly as observed today (test_processing.py's + test_process_ground_category_no_matching_features). + """ + if unmatched_value == configured_name: + unmatched_value += "_x" + + config_layers = [ + { + "name": "test_layer", + "property_name": property_name, + "property_values": [ + { + "name": configured_name, + "target_harmonized_value": target, + "desc": "d", + } + ], + } + ] + ground_features = [{property_name: unmatched_value}] + + result = process_ground_category(ground_features, config_layers) + + assert result.harmonized_value == 4 + assert result.source_values == "" + + +# --- Observed pattern 3b: process_ground_category with layer-presence matching --- + + +@given( + property_name=safe_tag_name, + target_value=st.integers(min_value=1, max_value=3), + matches=st.booleans(), +) +@settings(max_examples=25) +def test_process_ground_category_layer_presence_matching_preserved( + property_name, target_value, matches +): + """For a layer with no property_values, matching is purely by comparing + the layer's property_name against the feature's layerName; a match + contributes the layer-level target_harmonized_value, exactly as observed + today (test_processing.py's test_process_ground_category_layer_name_matching). + """ + layer_cfg = { + "name": "layer", + "property_name": property_name, + "property_values": None, + "target_harmonized_value": target_value, + } + layer_name = property_name if matches else f"{property_name}_other" + ground_features = [{"layerName": layer_name}] + + result = process_ground_category(ground_features, [layer_cfg]) + + assert result.harmonized_value == (target_value if matches else 4) + + +# --- Observed pattern 4: QUERY_LAYERS identical to LAYERS (no queryable: false) --- + + +@given( + canton_code=non_zh_canton_code, + layer_names=st.lists(safe_tag_name, min_size=1, max_size=5, unique=True), +) +# deadline=None: each example performs a real (mocked) HTTP round trip via +# respx/httpx, which routinely exceeds hypothesis's default 200ms deadline on +# this machine; the property itself is deterministic and has nothing to do +# with timing. +@settings(max_examples=15, deadline=None) +def test_query_layers_equals_layers_without_queryable_false(canton_code, layer_names): + """fetch_features_for_point's WMS branch builds QUERY_LAYERS as the exact + same comma-joined list as LAYERS, for any canton config whose layers have + no `queryable: false` entry (every canton today, since the field doesn't + exist yet) -- observed directly against the current implementation. + """ + config = { + "name": canton_code, + "info_format": "application/vnd.ogc.gml", + "bbox_delta": 10, + "query_url": "https://example.test/wms", + "layers": [{"name": n} for n in layer_names], + "style": "", + } + minimal_gml = ( + b"" + ) + + with respx.mock: + respx.get(config["query_url"]).mock( + return_value=httpx.Response( + 200, + content=minimal_gml, + headers={"Content-Type": "application/vnd.ogc.gml"}, + ) + ) + result = asyncio.run(fetch_features_for_point(2600000, 1200000, config)) + + query = parse_qs(urlparse(result["full_url"]).query) + query_layers = query.get("QUERY_LAYERS", [""])[0] + layers_param = query.get("LAYERS", [""])[0] + expected = ",".join(layer_names) + + assert query_layers == expected + assert layers_param == expected + assert query_layers == layers_param + + +# --- Observed pattern 5: transport-level failures -> geoservice_unavailable --- + + +def _minimal_wms_config(canton_code: str) -> dict: + return { + "name": canton_code, + "info_format": "application/vnd.ogc.gml", + "bbox_delta": 10, + "query_url": "https://example.test/wms", + "layers": [{"name": "some_layer"}], + "style": "", + } + + +@pytest.mark.parametrize("canton_code", ["ZH", "JU"]) +@pytest.mark.parametrize( + "mock_kwargs", + [ + {"side_effect": httpx.ConnectTimeout("Connection timed out")}, + {"side_effect": httpx.ConnectError("Connection refused")}, + {"return_value": httpx.Response(404, text="Not Found")}, + {"return_value": httpx.Response(500, text="Internal Server Error")}, + ], + ids=["timeout", "connect_error", "http_404", "http_500"], +) +@respx.mock +def test_transport_failures_return_geoservice_unavailable(canton_code, mock_kwargs): + """WMS transport-level failures (timeout, connection error, 4xx, 5xx) + continue to return `geoservice_unavailable: True` with empty features, + for both ZH and non-ZH cantons -- unaffected by the ZH-specific fix since + the failure is caught before any canton-specific parsing runs. + """ + config = _minimal_wms_config(canton_code) + respx.get(config["query_url"]).mock(**mock_kwargs) + + result = asyncio.run(fetch_features_for_point(2600000, 1200000, config)) + + assert result["features"] == [] + assert result.get("geoservice_unavailable") is True + assert result["error"] is not None diff --git a/uv.lock b/uv.lock index c823929..e8812d0 100644 --- a/uv.lock +++ b/uv.lock @@ -253,6 +253,7 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "hypothesis" }, { name = "pygments" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -267,6 +268,7 @@ dev = [ requires-dist = [ { name = "fastapi", specifier = ">=0.142.2" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.170.5" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "mangum", specifier = ">=0.22.0" }, { name = "owslib", specifier = ">=0.36.0" }, @@ -339,6 +341,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "hypothesis" +version = "6.170.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/a4/49ae9a9a23523490b1bbd39114dcb900ac24cc5a4197b47787a4cccf7708/hypothesis-6.170.5.tar.gz", hash = "sha256:b147f57c7903bda934d9f6ee6df462006e0c70a69b25a55e99663671666b249d", size = 518022, upload-time = "2026-10-20T00:53:37.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/7b/39c3f960a81d92c4f5dcd87b8766e1a6d8733bb676b0be7195dc12eff629/hypothesis-6.170.5-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:838cfda9e377bc153eabe551e0f388744f772fb2bcc33086da69ffbd2063cc45", size = 796559, upload-time = "2026-10-20T00:51:51.225Z" }, + { url = "https://files.pythonhosted.org/packages/b7/51/1458d7aa11faff54bd58512cc3df958fc7e88ed0eb7a29ad3b865a2d8046/hypothesis-6.170.5-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:6dc2ba7961fb6300711861a5b3aded8de7fe68e7b697a72c9e24f38c6b9a0d57", size = 791539, upload-time = "2026-10-20T00:53:23.394Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6e/e603d459a0bd31ad58523cd50ece74c9a54f75321b881f382c5de4ae3d01/hypothesis-6.170.5-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57fb5b807fe9861fc78ee04c55a0eee3ea6c2fba58743972eef29945f9327f09", size = 1120889, upload-time = "2026-10-20T00:52:37.898Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f4/ef5bc35d9d4f372be952a12df6a6091c384fd95cc349c98881a987063d70/hypothesis-6.170.5-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1c42821b507547c27216ac0efb107866559a9d6997cf6f7ddaf948eb483c54d", size = 1151120, upload-time = "2026-10-20T00:52:52.97Z" }, + { url = "https://files.pythonhosted.org/packages/e7/91/e0f4a505efeda83501af7be1867a068e38f41137f5a5f6fbdc6acb2b6b67/hypothesis-6.170.5-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d86f02f0d01a6c3c5688d8c0000b875d38b6ba54cbf89635a42786f7e0bf501", size = 1145590, upload-time = "2026-10-20T00:52:58.372Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7e/3cb049ad82d7ee8f711dcc46e9ddd39efb8bbc7a1d88bffb2fd4b9d0ea30/hypothesis-6.170.5-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5b5d4d63cce7be3ccb02e754f22e576aef0d2d044c9f88e799703c46bf1734f", size = 1197825, upload-time = "2026-10-20T00:53:35.716Z" }, + { url = "https://files.pythonhosted.org/packages/d3/24/dc2d4e86b108d7f0e3a9ebdda572925697f99ace4105a6a6df8dc086c8d6/hypothesis-6.170.5-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01ab2e0ffb72baad5c0ebc6157540d3f6e5c35f6b9cc082debf358cd9070d383", size = 1164106, upload-time = "2026-10-20T00:52:27.331Z" }, + { url = "https://files.pythonhosted.org/packages/da/31/cc65acc697a12f4fae3bb2ae69a5cf137f3770e9af54159f342950f3cee2/hypothesis-6.170.5-cp311-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:669c1b26e9f7d96a5ca2edadbe7309d87ae52e4042b2bb6eaf06b678edd808da", size = 1119651, upload-time = "2026-10-20T00:52:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4c/9381519d3309b6f53e5c517fcefd557e4596867841fc7eb35550d90009b8/hypothesis-6.170.5-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1adba3fe4d41e5a7de601c311d01335c3f11046c27d45f8b2843f4782b35a2f4", size = 1158664, upload-time = "2026-10-20T00:53:17.903Z" }, + { url = "https://files.pythonhosted.org/packages/31/e5/48616c050b9e315b7efad7af8ecb2abd370c062d9534e985ec435e12b6c3/hypothesis-6.170.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8265998f2e0c78909ee3b69193d37ab8b059fa85552c44d12a47a29e04bd5fd5", size = 1296069, upload-time = "2026-10-20T00:52:47.016Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b3/217e7523d0cb421879f057764ad9421fe032ebf09c2c9ce03fd16087551f/hypothesis-6.170.5-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:d7627bc2830dfdd3719cc9309ae8455ba3e7242cc38f6683be74b966a70451cd", size = 1424276, upload-time = "2026-10-20T00:52:01.536Z" }, + { url = "https://files.pythonhosted.org/packages/11/d1/de0ac0270aa5325035021b8501d060b4dc2b76ab3ea96e71de5a248c4d10/hypothesis-6.170.5-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:396444a3794d2f1063fe7dc7168992866850d4f5bb4ff3c170d5221b87828f5c", size = 1378214, upload-time = "2026-10-20T00:52:08.513Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4d/8518bf91f54963e2163f6f6f7a73169e688321b94d69a505bc14ed4847c4/hypothesis-6.170.5-cp311-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:eb26df2995173cec058e60d0fd6c3d7ceee74af741e2060905b793c906f8e207", size = 1274972, upload-time = "2026-10-20T00:53:31.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/d1/ac5eef47486b09c304920151ff6e30cc34a9eccdcd6b564e03626a8a5dbb/hypothesis-6.170.5-cp311-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fb9a95b98c793b50bb6a4bf5b7029bbb496dc1fc81932eead8cf4e6432f955d0", size = 1290050, upload-time = "2026-10-20T00:52:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/43/fb98aab4ede6e4eef2ce43546dfedd29262838f039451a6f4e3f3a126f32/hypothesis-6.170.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:59daccc69f4599983458a0d0ef89b8918b497d035531a06739c9c0000fe9cfbc", size = 1329855, upload-time = "2026-10-20T00:52:16.389Z" }, + { url = "https://files.pythonhosted.org/packages/05/1f/5d49e48087c81194e00153caf0b06e23184ad0d3a11ec72f2a4c24aa3c7c/hypothesis-6.170.5-cp311-abi3-win32.whl", hash = "sha256:ac68c095c28ed99bd3e2134aa29a905bc6f900c458f9b7320dc8b9a016b9d5bc", size = 683794, upload-time = "2026-10-20T00:53:01.138Z" }, + { url = "https://files.pythonhosted.org/packages/29/87/13eb06ca89fbe598ed9b324b8a697388b3a6d5373d4921aa5a51581d826c/hypothesis-6.170.5-cp311-abi3-win_amd64.whl", hash = "sha256:152824724ecaeacf808441b0f1b7fdf79d09a53bd48d771f0a001f4c6d52881b", size = 690317, upload-time = "2026-10-20T00:52:45.827Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9c/7a25e63a80022d41f1a62ed8d10324efe4b69411817913b2ac11caccd90d/hypothesis-6.170.5-cp311-abi3-win_arm64.whl", hash = "sha256:434563d5c39ad79763a92f0b30e6055aac2953b91af5c49f9b22d61b842a126f", size = 688446, upload-time = "2026-10-20T00:52:24.544Z" }, + { url = "https://files.pythonhosted.org/packages/53/c2/e0dafe7f1885651a222e9f69b603693ccf43d70e936330209cbdf843e9c6/hypothesis-6.170.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:f47ca310b1294285fe49a3e01670da1f4319eed41c6cf7bf6e480b5b1bc6d725", size = 798399, upload-time = "2026-10-20T00:53:02.542Z" }, + { url = "https://files.pythonhosted.org/packages/6b/78/7569ddbbd308aa27c06470bd9709d130b986d12fce6a4d95e7a16a7d4264/hypothesis-6.170.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eb74769bdd176e89b5f70243c990e98ae73ae7e1869774d249017e0d3f416ad1", size = 790120, upload-time = "2026-10-20T00:53:25.014Z" }, + { url = "https://files.pythonhosted.org/packages/12/4f/0b0411c51a138c3f0169f5aac492614cf692c600452004d80abe618763fc/hypothesis-6.170.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99211a76371bb2fc76fa0f62ae7d61df673928bab56da30541d458157b5039c", size = 1120541, upload-time = "2026-10-20T00:52:51.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f0/4880c765d3f2ba1f2afd8e0d0a9b7f4654cefeedcaf4412e66c9e977cdb6/hypothesis-6.170.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6291dc74ec0148884e49d323067c00eb80fa40f31f791849266b21549981cec8", size = 1163836, upload-time = "2026-10-20T00:53:10.558Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fa/1ee118695591aaaced88a8ac7016c7926a205cbcdd25bfcb3a34b89b5ddd/hypothesis-6.170.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6afb3939b016492de7002fb7e31d70b3adc110d22a4b344d72a2d8bd6f0d7772", size = 1295078, upload-time = "2026-10-20T00:52:54.383Z" }, + { url = "https://files.pythonhosted.org/packages/1d/31/9e1d616c7a001ff3bb61b369d4b82da41be62db76b7c4b8afca6129f9e33/hypothesis-6.170.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e75ef99625bfec395175886609aca219865892213ce3fd0b47fe9b99c8949d3a", size = 1329598, upload-time = "2026-10-20T00:53:16.625Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0e/2888187b57376a47f5d478031ef949106d4dabd18886b6c73a37827250d3/hypothesis-6.170.5-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:4f9b6272dbcec1f25890189ec445a7cbc7eb188ed1dfa875932221a435ab4e50", size = 630450, upload-time = "2026-10-20T00:53:03.735Z" }, + { url = "https://files.pythonhosted.org/packages/b1/28/107e157ddfa5d03299e984ab3b54d921453bd2e0e4677365bd66a5e88eee/hypothesis-6.170.5-cp314-cp314-win_amd64.whl", hash = "sha256:0efa491f78e345c327c3806cce4780ef51467c611539fa8f854730383c8f5532", size = 687512, upload-time = "2026-10-20T00:53:20.67Z" }, + { url = "https://files.pythonhosted.org/packages/18/b9/11cf155092ab4013ba533d0580ce2a28079d09ddd0680e5cc859ac3392d3/hypothesis-6.170.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:b267eafc5bc01110b62699c6e7337ba06c956f1425ff9e5db0d0474c0df4b9c2", size = 796601, upload-time = "2026-10-20T00:53:04.962Z" }, + { url = "https://files.pythonhosted.org/packages/d0/24/053ae1cde209fe57b45dff8004cce946b9797a2883b288710d46c19606e6/hypothesis-6.170.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:486abd585465b50a16512e594ec9cc54fd8d38a75150cf553ddd626d67ccaff0", size = 788856, upload-time = "2026-10-20T00:52:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ca/ef0273d9bdbda03601de7ccb63d5c9e75b6c57e4adefd75c4bca43141774/hypothesis-6.170.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8189ccfba95e6f0c20cdcfff257903e217556c183afff7002d57ba00e14692d6", size = 1118365, upload-time = "2026-10-20T00:53:22.153Z" }, + { url = "https://files.pythonhosted.org/packages/29/11/548d47eb2bf53e7c2a2bd407154208008735394f6aff921ae5be81783c28/hypothesis-6.170.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a814be57158520d25988ecc9f245450c0423d6527c814ba4945d65c537ddb30b", size = 1162755, upload-time = "2026-10-20T00:51:58.322Z" }, + { url = "https://files.pythonhosted.org/packages/45/04/3c9c089e52f12fa90afa7fce41fe5bc4b98687836233498f68fdee70cf7b/hypothesis-6.170.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd0ebb8632555b7b3ca8a5d97ba1c717cd84008ca95c66da756a84bfaa6768f4", size = 1293263, upload-time = "2026-10-20T00:52:33.603Z" }, + { url = "https://files.pythonhosted.org/packages/61/88/64946ebb2e01b471aa3f3ed6a1cb3f95bc9db5a89f10b8e676b5b631de78/hypothesis-6.170.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5076e0cbea439e175bcbf827771e341c3c6916cd0c72e89b1aa3e42f6777789f", size = 1328411, upload-time = "2026-10-20T00:52:15.012Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/39ed1c39175f9d33fd733bfa9e1bd1891f73b8cda743ade231d10b8a2c81/hypothesis-6.170.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5ed9cea5b9a81439b877a38a214b04d89497c94977d8c82ee7dddacb5291fca2", size = 687636, upload-time = "2026-10-20T00:53:26.417Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/1169da03577546c8a83476b3a55b2c3c406c8e6ab062c98a128db0abeff5/hypothesis-6.170.5-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:58636b41483a705f47e1e92b6e0726cd2f19f65927a91c6812f792203c7148a8", size = 796065, upload-time = "2026-10-20T00:53:32.958Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3d/731a463932285f598cb9b86ad30d00ea4af203cdce1d4fe3ae2e2b48a58f/hypothesis-6.170.5-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:2791266c112f35dbf97368fd990d89c5f6a7f4a789d20ea9d19bd998e92fec16", size = 788468, upload-time = "2026-10-20T00:51:43.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/47/6b4dbbb8df28ca78cf9f563fd95b48199e1c65491cb40f7831223474c0c2/hypothesis-6.170.5-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41be0cf9a80676d246526ca78c0c2fbb7ecd33f0828df44a55335d4657ef7151", size = 1117616, upload-time = "2026-10-20T00:52:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/7e/29/1df07813041f2c13ca174974781c8056fa9348f2d7588f8ac7e7cf65d4ed/hypothesis-6.170.5-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79346cc5f18a2b4e8f69c3232f30fc9659b3b9897d9d2f01e4e02b504769426f", size = 1148991, upload-time = "2026-10-20T00:52:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/70/83/f157761d0ec4b076d6a2296af1703f9e88b60e77134b3a647c50803e70fc/hypothesis-6.170.5-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c20e00119a762a1e2ed04270bceb937770db33772824558b257c9ab7af5de6da", size = 1142156, upload-time = "2026-10-20T00:53:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/1b66e4f10e09657a2ffd0e06e492cb9366806721a5d330e7c54e492e528c/hypothesis-6.170.5-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec49fcec438a94da29930e0667d1ded14149dd74016fe9c533531475906c2b98", size = 1195811, upload-time = "2026-10-20T00:53:29.639Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/de3cd2b7b624c14ab32be12cb0116069038abf9d6c113ee96a90c68568b1/hypothesis-6.170.5-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40cdf1c17af80cb18ae0abf419a76dd28fba54d7d0f696d1ace404f04f968b57", size = 1161971, upload-time = "2026-10-20T00:52:28.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/dd/ef169f084e4bca4184c239632fc1fad16fe0b2e45da5b8faeb61bd1da73e/hypothesis-6.170.5-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d5bb6dbe6a36d53150187447fc6b96f2616bc878f550d55cac895e3c51be902", size = 1116160, upload-time = "2026-10-20T00:52:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d7/c00040bf7ed364ce81e5b248bac90cd24c7a325ab89e90e82ae2d0a2e71e/hypothesis-6.170.5-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9402518491386e11be47d3b2cfd20f3e8045e3a109e432287714c37b3d25c874", size = 1154763, upload-time = "2026-10-20T00:53:07.519Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1c/5e861db799c21181ab44640000a9483bd5312fe6e406733ebf1eba6daf3a/hypothesis-6.170.5-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:b026b63ff15959713169440660af6652519350f39a348931e65b131f1803f15a", size = 1292284, upload-time = "2026-10-20T00:52:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/ec/68/1b015a804f574ff3018bb3605151a8a59775ac8af1208c99a80c345f8612/hypothesis-6.170.5-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:912886887fb42852496c0cfbaf0bc352db9b18234870e47399fbe2805c31bc90", size = 1421717, upload-time = "2026-10-20T00:52:05.128Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7c/c0b4aae02e96c8bb2881af2baf7597e6cbef65f493cf34df8bc8978b3a3d/hypothesis-6.170.5-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:431825ae8d358f7c4bdc1ac126bf54d74d722ec187d6e85732c9fbc4aed5a1aa", size = 1374778, upload-time = "2026-10-20T00:53:19.382Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/8d2d44d82593d7838b752d5039b62439d1b3e32a0581bea39a177ded193d/hypothesis-6.170.5-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:47d3eabb3eebafd332a26eba917694b36aac3a529bcaf7e2de7d37311cd88284", size = 1270833, upload-time = "2026-10-20T00:52:13.492Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/602d79e09ebb7c2a1a35a88cd36a5cd18da3be9d1ee6f0e0097b6d422b48/hypothesis-6.170.5-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:4080f6e4fe292f89b7d5cb372d34f6c141ef1ad8bcc529d813aa7fdf2b15b7c0", size = 1288056, upload-time = "2026-10-20T00:53:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/95/35/113b268b65b41b9d062ba61b689598810ee16453a2490dd246c0a28f32d8/hypothesis-6.170.5-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c7fe5905492038f8fea07d067277c9a2002dc82313b1ac86e99f4b78f8000ced", size = 1327702, upload-time = "2026-10-20T00:52:55.703Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5d/91e954d833ceb1c38e54b41d748465e22899de60e6d48d735dc29e459a8a/hypothesis-6.170.5-cp315-abi3.abi3t-win32.whl", hash = "sha256:9e3075974831b04ee96204c5aee62d8657de60711133e59614d9e786040fcd34", size = 681022, upload-time = "2026-10-20T00:52:40.768Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/960111740a57cb3be918c6664ed14283ae20212e549cf75804986d927deb/hypothesis-6.170.5-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:42fb68a80c06739a99ac855a6f339f3f68774e324a2a1a3bca80ee4fd1689c7d", size = 687355, upload-time = "2026-10-20T00:51:55.231Z" }, + { url = "https://files.pythonhosted.org/packages/78/47/61ed84d916a83b01776c8877b43801d852ed1f021368790c0fa58a10cd46/hypothesis-6.170.5-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9a4e7a45780ee0d0f5ac9d4d9f84e92b82ea8cbd326dd9600e1baffe51434e12", size = 685304, upload-time = "2026-10-20T00:51:53.739Z" }, +] + [[package]] name = "idna" version = "3.20" @@ -834,6 +898,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/8b/1d359f38706b4097d9a943bf8bd22599f537de4cbaff1e622d3e3936e164/slowapi-0.1.10-py3-none-any.whl", hash = "sha256:3acb61561dc9d687e3d3669362ff6a439de9ba44e2fed3a9c165da26b4b83e28", size = 14921, upload-time = "2026-06-13T11:59:30.485Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "starlette" version = "1.7.0"