diff --git a/Makefile b/Makefile index 13bca16c8..b1350ec4b 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ XCODEGEN ?= xcodegen # Must match XCODEGEN_VERSION in .github/workflows/ios.yml; other versions write a different project. XCODEGEN_VERSION := 2.46.0 -.PHONY: check server web ios ios-project +.PHONY: check server web ios ios-project smoke check: server web ios @@ -34,3 +34,9 @@ ios-project: exit 1; \ fi $(XCODEGEN) generate --spec ios/project.yml + +# Posts each scene in server/examples to a placement server and prints its decision: +# make smoke URL=https://house-scanning-server.vercel.app [KEY_FILE=server/.env.private.local] +smoke: + @test -n "$(URL)" || { echo "usage: make smoke URL= [KEY_FILE=]" >&2; exit 2; } + python3 server/examples/smoke.py "$(URL)" $(if $(KEY_FILE),--key-file "$(KEY_FILE)") diff --git a/server/.gitignore b/server/.gitignore index 6cff60015..ca91c4098 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -3,3 +3,6 @@ __pycache__/ *.py[cod] .pytest_cache/ .ruff_cache/ +.hypothesis/ +.vercel/ +.env* diff --git a/server/.vercelignore b/server/.vercelignore new file mode 100644 index 000000000..5da980132 --- /dev/null +++ b/server/.vercelignore @@ -0,0 +1,11 @@ +# Only the app and its public rules deploy. private/ holds Base's thresholds: it lives outside +# server/, and is listed here too so a copy placed inside can never upload. +private/ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.hypothesis/ +.vercel/ +tests/ +.env* diff --git a/server/README.md b/server/README.md index b614c3225..5912db53a 100644 --- a/server/README.md +++ b/server/README.md @@ -1,24 +1,132 @@ # server -The FastAPI server from `docs/02-implementation-plan.md` (lanes B and C). It will take a capture from the phone, build the wall geometry, run the recognition checks and pick a battery placement. Modules sit flat in this directory (`ingest.py`, `geometry.py`, `solver.py`, `rules.yaml` and so on) as the lanes add them. +The placement server. It takes a scan of the wall around the electric meter and answers whether a Base Power battery fits, where, and why. The decision is plain code; every threshold lives in `rules.yaml` with its citation. -For now it holds `units.py`. ARKit reports meters, while `scene.json` and the rules use feet and inches; convert with `units.py` and format lengths for people with `format_ft_in`. +## Run -## Requirements +Needs [uv](https://docs.astral.sh/uv/) 0.11. From `server/`: -[uv](https://docs.astral.sh/uv/) 0.11. It installs Python 3.12 (`.python-version`) and the locked dev tools. +| Command | What it does | +| --- | --- | +| `uv sync --locked` | Install | +| `uv run uvicorn api:app --host 0.0.0.0 --port 8000` | Serve the API | +| `uv run python siteplan.py scene.json plan.svg` | Solve one scene and write its site plan | +| `uv run pytest -q`, `uv run ruff check .`, `uv run ruff format .` | Test and lint (`make server` runs what CI runs). With `CI` set, as GitHub Actions sets it, the property tests run a fixed set of examples; locally they search at random | -## Commands +Without the web layer: `loaded = load_rules()`, then `solve(parse_scene(raw, loaded.rules), loaded)` (from `rules`, `scene` and `solver`). -Run from `server/`: +## Deploy -| Command | What it does | +Two Vercel projects deploy this directory. Neither is connected to Git, so a merge doesn't redeploy them; only `server/` uploads (`.vercelignore`), so the repository's `private/` folder never reaches a deployment. + +| Deployment | Rules | Access | +| --- | --- | --- | +| **https://house-scanning-server.vercel.app** (`house-scanning-server`) | public, under the demo policy | open | +| **https://house-scanning-server-private.vercel.app** (`house-scanning-server-private`) | public with the private rules merged over them | `Authorization: Bearer ` on every route but `/health` | + +Redeploy the public one from this directory: + +```sh +npx vercel@latest deploy --prod --scope sam-gus-projects-7a4b6082 +``` + +The private one gets its rules from environment variables set in its Vercel project, never from an uploaded file: `HOUSESCAN_PRIVATE_RULES_B64` holds `private/rules.yaml` base64-encoded, and `HOUSESCAN_API_KEY` the key. While private rules are loaded, every route but `/health` answers 401 without the key and 503 if no key is set, and `/health` says only that private rules are loaded. The key is in `server/.env.private.local` (git-ignored, mode 600) on the machine that set it up. This directory is linked to the public project, so deploy the private one from a copy of it that is linked to `house-scanning-server-private`: + +```sh +rsync -a --exclude .venv --exclude .vercel --exclude '.env*' --exclude tests ./ /tmp/private-deploy/ +cd /tmp/private-deploy +npx vercel@latest link --yes --project house-scanning-server-private --scope sam-gus-projects-7a4b6082 +npx vercel@latest deploy --prod --scope sam-gus-projects-7a4b6082 +cd - && rm -rf /tmp/private-deploy # the link leaves a Vercel token in .env.local there +``` + +Vercel caps a request body at 4.5 MB, so send bare `scene.json`: the solver doesn't read the images, and a zip must hold every JPEG its `scene.json` names or it is refused with 422 `missing_bundle_file`. `examples/` has scenes and `curl` commands for both deployments, and `make smoke URL=...` posts them all. + +## API + +| Endpoint | Body | Returns | +| --- | --- | --- | +| `GET /health` | | Which rules are loaded, and their hash | +| `POST /v1/placements` | a scene | The result, `schemas/result.schema.json` | +| `POST /v1/placements/site-plan.svg` | a scene | The site plan, `image/svg+xml` | +| `GET /v1/schemas/scene.json`, `GET /v1/schemas/result.json` | | The contract this server honours | + +A scene is bare `scene.json` (`application/json`), a zip holding `scene.json` and the JPEGs it names (`application/zip`), or either one as the multipart field `bundle`. `/openapi.json` declares all three. + +```sh +curl -s localhost:8000/v1/placements -H 'Content-Type: application/json' \ + --data-binary @tests/fixtures/example-scene.json | jq .decision +``` + +A refusal is `{"error": {"code", "message", "path"}}`, where `path` is the JSON pointer of the field at fault: + +| Status | When | | --- | --- | -| `uv sync --locked` | Create `.venv` from `uv.lock` | -| `uv run ruff check .` | Lint | -| `uv run ruff format .` | Format; CI runs `ruff format --check .` | -| `uv run pytest -q` | Tests in `tests/` | +| 400 | The body or zip can't be read, or a zip entry has an absolute or `..` path | +| 413 | Over `HOUSESCAN_MAX_UPLOAD_MB` (default 256), unzipped over `HOUSESCAN_MAX_UNZIPPED_MB` (default 512), or `scene.json` itself over `HOUSESCAN_MAX_SCENE_MB` (default 10) | +| 415 | Not JSON, zip or multipart | +| 422 | The scene breaks `schemas/scene.schema.json`, names an image the zip lacks, can't be placed (for example the meter is far from its wall), or needs more than 50,000 positions or 8 s to check (`scene_too_complex`; refused as soon as the pace of the first positions shows it) | + +## Writing a scene + +`schemas/scene.schema.json` is the contract; it only gains optional fields. Lengths are feet. + +- Wall `baseline` points run **left to right as seen from outside**, and walls are listed in that order. That fixes each wall's outward side. +- `s` is feet along the walls from the meter, negative to the left. `span_ft` and coverage use it. +- `coverage` lists what the capture actually saw, and whether each end of the walk is a real `limit` or `unexplored`. Anything unseen makes the checks that depend on it UNSURE, so a scene without `coverage` can't pass. +- A limit end (a fence, a corner) doesn't clear the ground beyond it: a pool there still counts. Ground seen past a limit end, a `span_ft` beyond the chain's end, covers both sides of the wall's continued line, so pointing the camera past the end settles it, also where it lies within reach of an unexplored end at the other side. Past an unexplored end only walking on does. +- Consecutive walls meet when the space between one's end and the next one's start is within both walls' position errors (capped at `sweep.wall_join_ft`, 0.6; walls with zero declared error meet only where their ends coincide); `s` then continues from the first wall's end without that space. A wider space is a gap: a stretch with no wall that no battery backs onto and no cable crosses, and `s` counts its length. The ground behind a gap (a side passage) is yard like the ground in front. A ground view shows it only when the view's span lies within the gap, the camera pointed into the passage; such a view covers both sides of the gap's line out to its `out_ft`. A view that runs along the walls across a gap shows only the front of it. A scene with more than 200 straight wall segments, counted after nearly collinear taps merge, is refused, because the solve grows faster than their square. +- Give an object a plan `footprint` when it stands off the wall (a regulator, an AC unit), or clearances are measured to its stretch of wall line. +- Omit `plus_minus_ft` and AR-placed positions get `rules.yaml`'s default error for their source plus 0.16 ft per foot along the walls from the meter, from measured ARKit drift. Send your own when you know better. + +## What settles each check + +A check is settled when everything it depends on is observed and measured; then it passes or fails on the numbers. What follows is the coverage (`coverage.observed`) and the measurements each check needs, so a capture that supplies exactly this gets a decision on the first upload. Values in brackets are the public rules (`rules.yaml`); private rules may differ, and a request always names the exact span and depth. + +Notation, all in feet: the battery stands at s from `s0` to `s1` (width W = 2.58, depth D = 1.83). `e` is the battery's position error: its wall's error at the battery's far edge from the meter (the wall's `plus_minus_ft`, or the default for its `source`: tap 0.3, the default when `source` is absent; mesh 0.5; plane 0.75, an untested estimate; plus 0.16 per foot along the walls from the meter), plus the meter's error against anything placed in plan (an object's `footprint`, a ground patch, a wall's end), or plus how far the battery can slide against anything placed along the walls in s (a span); a check that compares with both kinds takes the larger. The battery is placed by its offset from the meter, so it moves with the meter against anything placed in plan. Against a span it moves only when it stands round a corner from the meter: moving the meter or a corner between them then slides it by that move's error times the difference between the two walls' directions (0 on the meter's own wall, √2 at a right angle). A hazard with no `footprint` lies on its wall's line at its span, so against one on another wall a clearance also adds, for each wall between the two other than the battery's, that wall's error times one plus its turn against the battery's wall: the wall's line moves, and its corners slide what lies past them. `r` is a check's rule value. A ground band "over [a, b] out to d" means an observed `{"band": "ground", "span_ft": [a, b], "out_ft": d}` (or several that together cover it). A wall band "seen higher than h" means wall entries whose `out_ft`, the height up the face the view reached, exceeds h; an entry without `out_ft` counts as seen to headroom height (`headroom.min_ft`), inclusive: it settles a check needing up to that height, and no more. An explicit `out_ft` must be strictly higher than the height a check needs, so an explicit 6.5 does not settle a check needing 6.5 while an omitted one does. The asymmetry keeps every answer for scenes that omit wall heights unchanged, and the finite default stops an omitted view from settling a check that needs more (an 8 ft battery's back). Heights come from `rules.yaml` and are compared as given: nothing in the rules models a vertical error, so report a height you are sure of. + +| Check | Reads | Coverage that settles it | +| --- | --- | --- | +| `wall_backing` | `walls`, and `height_ft` when given | the footprint clear of its straight segment's ends by e (within e: UNSURE), and wall band over [s0 − e, s1 + e], seen higher than the battery (3.29); each wall behind the battery that declares `height_ft` must be taller than the battery | +| `ground_surface` | `ground` patches | ground over [s0 − e, s1 + e] out to D + e, and a patch of an allowed type under the whole footprint | +| `meter_working_space` | `meter` | nothing to observe | +| `gas_clearance` (r = 3) | `objects` of type `gas_meter`, with `footprint` when it stands off the wall | ground over [s0 − r − e, s1 + r + e] out to D + r + e, and wall band over the same span, seen higher than headroom height (6.5) | +| `ac_clearance` (r = 3), `pool_clearance` (r = 10) | `objects` of type `ac`, `pool` | ground over [s0 − r − e, s1 + r + e] out to D + r + e | +| `battery_clearance` (r = 3), when the scene marks one or the rules set it above `gas_ft` | `objects` of type `battery`, with `footprint` (a battery already installed; issue #27) | ground over [s0 − r − e, s1 + r + e] out to D + r + e, and wall band over the same span, seen higher than headroom height (6.5). The cable detours over an existing battery on its route | +| `drive_clearance` (r = 5) | `ground` patches of type `drive` | as above | +| `opening_clearance` (r = 3) | `objects` of type `door`, `window`, `garage_door`; `attrs.operable` and `attrs.well` for windows | wall band over [s0 − r − e, s1 + r + e], seen higher than headroom height (6.5), or than `openings.exempt_bottom_above_ft` when the rules set a lower one; rules setting it above headroom height are refused at load | +| `wall_equipment_above` (r = 0) | `objects` of type `elec_box`, `vent` | wall band over [s0 − r − e, s1 + r + e], seen higher than headroom height (6.5) | +| `facing_gap` (r = 3, from the battery's front) | `facing` measurements | facing band over [s0 − e, s1 + e], every place the battery may sit. Where no `facing` entry covers it, the band's `out_ft` must exceed D + r (4.83): a walked path proves the space clear out to where the homeowner walked. No `out_ft` means the view reached whatever faces the wall, and it is in `facing` | +| `headroom` (r = 6.5) | `overheads` measurements | overhead band over [s0 − e, s1 + e]. Where no `overheads` entry covers it, the band's `out_ft` (height seen clear) must exceed r. No `out_ft` means seen clear all the way up, as from a tilt-up view of open sky | +| `route_path` | `walls`, openings on the route (the run starts at the meter, so its distance from the wall's line counts toward `route_length`, and every wall it runs along adds its error) | wall band from the meter to the battery's near edge, widened at each end by that end's error (the meter's, the wall's), seen higher than the cable's run (`route.height_ft`, 1.0) | +| `route_length` | `walls`, `meter` | nothing to observe | + +Errors: a measured value passes only when it clears the rule by more than its error. Objects take the default error for their `source` (tape 0.05, tap 0.3, vlm 1.5, plus 0.16 per foot along the walls for tap and vlm) unless they carry `plus_minus_ft`, and walls likewise (tap 0.3, mesh 0.5, plane 0.75, each plus the same drift). Send a wall's `source` when its line comes from the mesh or detected planes rather than taps, so the error bars match how it was measured; `facing` and `overheads` entries default to the mesh error, 0.5. Every `out_ft` is taken as exact, so report the distance you are sure of (for a walked path, the distance from the wall less your position error). Ground that was seen but has no recorded surface may be a driveway, so within `drive_clearance`'s reach it leaves that check UNSURE as an unknown attribute until a `ground` patch records it, as it does for `ground_surface` under the battery. When a check at the best spot needs ground past an unexplored end, the answer asks to walk past that end, even an end too far for a spot past it to reach. The meter's working space is drawn in front of the wall under it, so its check adds that wall's error to the meter's and the battery's wall's. A battery overlapping it from another wall is measured by how deep the overlap is. The cable run's length is judged over the range it can truly take: moving the meter by d moves both the route's start and the battery, changing the run by d·(v ± u) less up to twice the meter's standoff (v: the difference between the battery's and the meter's wall directions, signed by the battery's side; u: from the wall to the meter), so it can lengthen by up to max(e·|v + u|, e·|v − u| − 2·standoff), √5·e round a right angle with the meter on its wall's line, and shorten only as far as the standoff allows. + +Ends and corners: + +- A corner the walk follows is the next wall in `walls`, starting at the corner point; the chain is then continuous and the corner raises nothing. Keep walking until the answer's `ends..beyond_reach` is true or the wall really ends. +- `unexplored` means the wall continues. Within cable reach it raises a `past_end` request when no spot passes, because a spot may be round it; nothing else settles it. +- `limit` means no usable wall past it. Ground past a limit end still counts for clearances (a pool behind a fence is still a pool): show it by pointing the camera past the end, reported as a ground span beyond the chain's end, which covers both sides of the wall's continued line. + +Ground behind a scanned wall that is also on the house side of every scanned wall's line is the house and never counts as unseen; anything else in front of any wall is yard, so a wall running back behind another can't hide the yard in front of it. Seen ground closes gaps narrower than the 0.01 ft tolerance and no wider. A ground request's `out_ft` is the smallest depth, over its span, for which the unseen ground in question would be covered, computed with the same geometry the checks use. Supplying exactly what a request in `missing_evidence` names settles it: a `coverage.observed` entry with its `band` and `span_ft`, and an `out_ft` at least the request's `out_ft` (ground, facing, overhead and wall requests carry one). + +## Reading a result + +`decision` is `pass`, `manual_review` or `reject`. `spot` gives the battery's plan footprint and `meter_offset_ft`, its offset from the meter for AR; `route` is the cable run. Each entry in `checks` has an outcome, a reason, the measurement, its error and the rule it was held to: + +| Check | Means | +| --- | --- | +| `wall_backing` | Flush on one straight, observed stretch of wall | +| `ground_surface` | Standing on an allowed surface | +| `meter_working_space` | Clear of the NEC 110.26 space in front of the meter | +| `gas_clearance`, `ac_clearance`, `pool_clearance`, `drive_clearance`, `opening_clearance` | Far enough from every part of each object, and the area in between was seen | +| `wall_equipment_above` | No box or vent on the wall above | +| `facing_gap`, `headroom` | Enough room in front and above | +| `route_path`, `route_length` | The cable runs along continuous wall, and isn't too long | + +A check passes only when its margin exceeds its error and fails only when it misses by more than its error; anything else is UNSURE with an `unsure_cause`. `missing_evidence` lists the views that would settle an unseen area; showing exactly what a `band` request names settles it, and a `past_end` request asks to walk on. When a measurement only might lie in front of or over the battery (its end is within the wall's position error of the battery's edge), `measured_ft ± plus_minus_ft` spans the values the check could have, so the numbers give the same UNSURE. A reject needs every spot within cable reach to fail and both ends of the walk known. `objects_not_used` names marks the server didn't measure: objects with no `footprint` past an `unexplored` end, where the wall may turn, so their place is unknown. Among UNSURE spots, one whose best estimate is past a rule (for example overlapping the meter's working space) ranks after every spot whose estimates all clear, whatever its cable run. -`make server` from the repository root runs what CI (`.github/workflows/server.yml`, job "Server checks") runs. Add dependencies with `uv add ` and commit `uv.lock`. +## Rules -Recognition prompts go in `server/prompts/`, which git ignores; they are never committed. +`rules.yaml` holds the public values. `placeholder: true` marks a value with no public source (the pool's 10 ft and the driveway's 5 ft among them). Its `demo` policy decides automatically anyway, so the team can test real passes and rejects, and every answer says so: `policy.notice` and the end of `summary` read "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." `HOUSESCAN_POLICY=strict` selects the earlier behaviour instead: `auto_approve: false`, so every would-be pass or reject goes to manual review. At startup the server merges private rules over the public ones when there are any: `HOUSESCAN_PRIVATE_RULES_B64` (the YAML, base64-encoded), else `HOUSESCAN_PRIVATE_RULES` (a path), else `/private/rules.yaml` if it exists. Setting both variables is an error. Private rules replace the policy and its demo notice; while any public placeholder is still in effect, `policy.notice` names the checks it decides (and any other placeholder settings), so a partial private file never reads as complete. Every value the private file sets must carry its own `source`, or the server refuses to start; answers show those citations only as "Private rules". Never commit Base's values. Base-derived tests live beside them in `private/tests/`: `uv run pytest ../private/tests -q`. diff --git a/server/api.py b/server/api.py new file mode 100644 index 000000000..f4d2d62ac --- /dev/null +++ b/server/api.py @@ -0,0 +1,467 @@ +"""HTTP front of the placement server. + +One upload in, one solver result out. The body is a bare scene.json, a zip bundle (scene.json plus +the keyframe and still images it names), or a multipart form whose single `bundle` field holds +either. Every refusal is the same JSON envelope, {"error": {"code", "message", "path"}}, where +`path` is a JSON pointer into scene.json when one field is to blame; a client never sees a stack +trace. + +The zip is read in memory and never extracted, so entry names only matter as lookups; entries with +absolute paths or `..` are still refused because a bundle carrying them was not made by our app. + +Run: uv run uvicorn api:app --host 0.0.0.0 --port 8000 +""" + +import hmac +import io +import json +import logging +import os +import re +import zipfile +import zlib +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse, Response +from starlette.concurrency import run_in_threadpool +from starlette.datastructures import UploadFile +from starlette.exceptions import HTTPException + +import siteplan +from rules import LoadedRules, load_rules +from scene import Scene, SceneError, parse_scene +from solver import SCHEMA_VERSION, SceneTooComplex, solve + +log = logging.getLogger("housescan.api") + +_MB = 1024 * 1024 + + +def _limit_bytes(env: str, default_mb: float) -> int: + raw = os.environ.get(env) + if raw is None: + return int(default_mb * _MB) + try: + mb = float(raw) + except ValueError: + mb = -1.0 + if not mb > 0 or mb == float("inf"): + raise ValueError(f"{env}={raw!r} must be a positive number of megabytes") + return int(mb * _MB) + + +# Read once at import so a bad value stops the server at startup. Tests monkeypatch these names. +MAX_UPLOAD_BYTES = _limit_bytes("HOUSESCAN_MAX_UPLOAD_MB", 256) +MAX_UNZIPPED_BYTES = _limit_bytes("HOUSESCAN_MAX_UNZIPPED_MB", 512) +# Parsing JSON takes many times its size in memory (10.5 MB took 255 MB), and a real scene.json +# is well under 1 MB, so scene.json itself is capped far below the bundle. +MAX_SCENE_BYTES = _limit_bytes("HOUSESCAN_MAX_SCENE_MB", 10) + +# Loaded at import: invalid rules must stop the server before it answers anything. +LOADED: LoadedRules = load_rules() +# Required on every route but /health while private rules are loaded: answers carry each check's +# threshold, so a server holding Base's values must not answer strangers. +API_KEY: str | None = os.environ.get("HOUSESCAN_API_KEY") or None + +_ZIP_MAGIC = b"PK\x03\x04" +_JSON_TYPES = {"application/json"} +_ZIP_TYPES = {"application/zip", "application/x-zip-compressed"} +# No declared type, or a generic binary one: the body's first bytes decide. +_SNIFF_TYPES = {"", "application/octet-stream"} +_MULTIPART = "multipart/form-data" +_BUNDLE_FIELD = "bundle" +# macOS Finder adds this folder of resource forks to every zip it makes. +_MACOS_JUNK = "__MACOSX/" +_DRIVE = re.compile(r"^[A-Za-z]:") + + +class ApiError(Exception): + def __init__(self, status: int, code: str, message: str, path: str | None = None) -> None: + super().__init__(message) + self.status = status + self.code = code + self.message = message + self.path = path + + +def _error_response(status: int, code: str, message: str, path: str | None) -> JSONResponse: + return JSONResponse( + {"error": {"code": code, "message": message, "path": path}}, status_code=status + ) + + +app = FastAPI( + title="House scanning placement server", + version=SCHEMA_VERSION, + description="Turns a phone capture (scene.json or a zip bundle) into a battery placement.", +) + + +def _private() -> bool: + return "private" in LOADED.sources + + +@app.middleware("http") +async def _require_key(request: Request, call_next: Any) -> Response: + # A browser's preflight carries no credentials; the request that follows it does. + if not _private() or request.url.path == "/health" or request.method == "OPTIONS": + return await call_next(request) + if API_KEY is None: + return _error_response( + 503, + "no_api_key", + "This server holds private rules but has no HOUSESCAN_API_KEY, so it answers nothing.", + None, + ) + if not _key_matches(request.headers.get("authorization", "")): + response = _error_response( + 401, + "unauthorized", + "This server holds private rules: send the header Authorization: Bearer .", + None, + ) + response.headers["WWW-Authenticate"] = "Bearer" + return response + return await call_next(request) + + +def _key_matches(header: str) -> bool: + scheme, _, key = header.partition(" ") + return ( + API_KEY is not None + and scheme.lower() == "bearer" + and hmac.compare_digest(key.strip().encode(), API_KEY.encode()) + ) + + +# Added after the key check, so it wraps it: refusals carry CORS headers a browser can read. +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["GET", "POST"], + allow_headers=["*"], +) + + +@app.exception_handler(ApiError) +async def _api_error(_request: Request, exc: ApiError) -> JSONResponse: + return _error_response(exc.status, exc.code, exc.message, exc.path) + + +_HTTP_CODES = { + 400: "bad_request", + 404: "not_found", + 405: "method_not_allowed", + 413: "body_too_large", + 415: "unsupported_media_type", +} + + +@app.exception_handler(HTTPException) +async def _http_error(_request: Request, exc: HTTPException) -> JSONResponse: + code = _HTTP_CODES.get(exc.status_code, f"http_{exc.status_code}") + return _error_response(exc.status_code, code, str(exc.detail), None) + + +@app.exception_handler(Exception) +async def _internal_error(_request: Request, exc: Exception) -> JSONResponse: + log.exception("unhandled error", exc_info=exc) + return _error_response( + 500, "internal_error", "The server failed while handling this upload.", None + ) + + +def _policy() -> dict[str, Any]: + p = LOADED.rules.policy + return { + "id": p.id, + "version": p.version, + # Same rule as solver.solve: no policy id means nothing is approved automatically. + "auto_approve": p.auto_approve and p.id is not None, + "sources": list(LOADED.sources), + "rules_sha256": LOADED.sha256, + "notice": p.notice, + } + + +@app.get("/health") +def health(request: Request) -> dict[str, Any]: + if _private() and not _key_matches(request.headers.get("authorization", "")): + # Anyone may learn that private rules are loaded and a key is needed, not which rules. + return { + "status": "ok", + "schema_version": SCHEMA_VERSION, + "policy": {"sources": list(LOADED.sources)}, + "auth": "bearer", + } + return {"status": "ok", "schema_version": SCHEMA_VERSION, "policy": _policy()} + + +# The contract, served by the server that honours it, so clients can fetch the version they talk to. +SCHEMAS = Path(__file__).resolve().parent / "schemas" + + +@app.get("/v1/schemas/scene.json") +def scene_schema() -> FileResponse: + return FileResponse(SCHEMAS / "scene.schema.json", media_type="application/schema+json") + + +@app.get("/v1/schemas/result.json") +def result_schema() -> FileResponse: + return FileResponse(SCHEMAS / "result.schema.json", media_type="application/schema+json") + + +# --- reading the upload --------------------------------------------------------------------------- + + +def _media_type(request: Request) -> str: + return request.headers.get("content-type", "").split(";")[0].strip().lower() + + +async def _read_body(request: Request) -> bytes: + """The whole body, refused with 413 as soon as it passes MAX_UPLOAD_BYTES.""" + limit = MAX_UPLOAD_BYTES + too_large = ApiError( + 413, "body_too_large", f"The upload is larger than the {limit // _MB} MB limit." + ) + declared = request.headers.get("content-length") + if declared is not None and declared.isdigit() and int(declared) > limit: + raise too_large + chunks: list[bytes] = [] + size = 0 + async for chunk in request.stream(): + size += len(chunk) + if size > limit: + raise too_large + chunks.append(chunk) + return b"".join(chunks) + + +async def _bundle_field(request: Request, body: bytes) -> tuple[bytes, str]: + """The `bundle` field of a multipart form and its declared media type.""" + + async def replay() -> dict[str, Any]: + return {"type": "http.request", "body": body, "more_body": False} + + form_request = Request(request.scope, replay) + try: + form = await form_request.form(max_files=1, max_fields=1) + except HTTPException as exc: + raise ApiError(400, "unreadable_multipart", str(exc.detail)) from None + try: + extra = sorted(k for k in form if k != _BUNDLE_FIELD) + if extra: + raise ApiError( + 400, + "unexpected_form_field", + f"The form may only carry one field named {_BUNDLE_FIELD!r}; got {extra}.", + ) + field = form.get(_BUNDLE_FIELD) + if field is None: + raise ApiError(400, "missing_bundle", f"The form has no field named {_BUNDLE_FIELD!r}.") + if isinstance(field, UploadFile): + return await field.read(), (field.content_type or "").split(";")[0].strip().lower() + return field.encode(), "" + finally: + await form.close() + + +def _reject_constant(name: str) -> float: + raise ValueError(f"{name} is not a number JSON allows") + + +def _load_json(data: bytes, what: str) -> Any: + try: + return json.loads(data, parse_constant=_reject_constant) + except json.JSONDecodeError as exc: + raise ApiError( + 422, "invalid_json", f"{what} is not valid JSON: {exc.msg} at line {exc.lineno}" + ) from None + except (ValueError, RecursionError) as exc: + raise ApiError(422, "invalid_json", f"{what} is not valid JSON: {exc}") from None + + +def _unsafe(name: str) -> bool: + n = name.replace("\\", "/") + return n.startswith("/") or bool(_DRIVE.match(n)) or ".." in n.split("/") + + +def _check_scene_size(size: int) -> None: + if size > MAX_SCENE_BYTES: + raise ApiError( + 413, + "scene_too_large", + f"scene.json is {size} bytes, over the {MAX_SCENE_BYTES // _MB} MB limit.", + ) + + +def _open_bundle(data: bytes) -> tuple[bytes, set[str], str]: + """scene.json's bytes, every file name in the bundle, and the folder scene.json sits in.""" + try: + zf = zipfile.ZipFile(io.BytesIO(data)) + except (zipfile.BadZipFile, zlib.error, ValueError, EOFError) as exc: + raise ApiError(400, "unreadable_zip", f"The bundle is not a readable zip: {exc}") from None + with zf: + infos = zf.infolist() + for info in infos: + if _unsafe(info.filename): + raise ApiError( + 400, + "unsafe_zip_entry", + f"The bundle entry {info.filename!r} has an absolute path or '..'.", + ) + total = sum(info.file_size for info in infos) + if total > MAX_UNZIPPED_BYTES: + raise ApiError( + 413, + "bundle_too_large", + f"The bundle unpacks to {total} bytes, over the " + f"{MAX_UNZIPPED_BYTES // _MB} MB limit.", + ) + names = {i.filename for i in infos if not i.is_dir()} + prefix = _scene_folder(names) + _check_scene_size(zf.getinfo(prefix + "scene.json").file_size) + try: + # Read at most one byte past the cap: a declared size can lie. + with zf.open(prefix + "scene.json") as entry: + scene_bytes = entry.read(MAX_SCENE_BYTES + 1) + _check_scene_size(len(scene_bytes)) + except (zipfile.BadZipFile, zlib.error, RuntimeError, NotImplementedError, EOFError) as exc: + raise ApiError( + 400, "unreadable_zip", f"scene.json in the bundle can't be read: {exc}" + ) from None + return scene_bytes, names, prefix + + +def _scene_folder(names: set[str]) -> str: + if "scene.json" in names: + return "" + tops = {n.split("/", 1)[0] for n in names if not n.startswith(_MACOS_JUNK)} + if len(tops) == 1: + (top,) = tops + if f"{top}/scene.json" in names: + return f"{top}/" + raise ApiError( + 400, + "missing_scene_json", + "The bundle has no scene.json at its root or inside a single top-level folder.", + ) + + +def _pointer_token(key: str) -> str: + return key.replace("~", "~0").replace("/", "~1") + + +def _check_bundle_files(raw: dict[str, Any], names: set[str], prefix: str) -> None: + """Every image scene.json names must be in the bundle, next to scene.json.""" + wanted = [ + (f"/keyframes/{i}/img", kf["img"]) for i, kf in enumerate(raw.get("keyframes", [])) + ] + [(f"/stills/{_pointer_token(k)}", v) for k, v in raw.get("stills", {}).items()] + missing = [(path, name) for path, name in wanted if prefix + name not in names] + if missing: + listed = ", ".join(repr(name) for _, name in missing) + raise ApiError( + 422, + "missing_bundle_file", + f"scene.json names files the bundle doesn't contain: {listed}.", + missing[0][0], + ) + + +async def _upload(request: Request) -> tuple[bytes, bool]: + """The scene payload and whether it is a zip bundle.""" + media = _media_type(request) + if media not in _JSON_TYPES | _ZIP_TYPES | _SNIFF_TYPES | {_MULTIPART}: + raise ApiError( + 415, + "unsupported_media_type", + f"Content-Type {media!r} is not supported; send application/json, application/zip " + f"or multipart/form-data with a {_BUNDLE_FIELD!r} file.", + ) + body = await _read_body(request) + if media == _MULTIPART: + body, media = await _bundle_field(request, body) + if not body: + raise ApiError(400, "empty_body", "The upload is empty.") + return body, media in _ZIP_TYPES or body.startswith(_ZIP_MAGIC) + + +def _parse(payload: bytes, is_zip: bool) -> Scene: + if is_zip: + scene_bytes, names, prefix = _open_bundle(payload) + raw = _load_json(scene_bytes, "scene.json") + else: + scene_bytes, names, prefix = payload, None, "" + _check_scene_size(len(payload)) + raw = _load_json(payload, "The body") + try: + scene = parse_scene(raw, LOADED.rules, scene_bytes) + except SceneError as exc: + raise ApiError(422, "invalid_scene", exc.message, exc.path) from None + if names is not None: + _check_bundle_files(raw, names, prefix) + return scene + + +def _solve(payload: bytes, is_zip: bool) -> tuple[Scene, dict[str, Any]]: + scene = _parse(payload, is_zip) + try: + return scene, solve(scene, LOADED) + except SceneTooComplex as exc: + raise ApiError( + 422, "scene_too_complex", f"The server can't place this scene: {exc}." + ) from None + + +# The routes read the raw body so one endpoint can take JSON, a zip or a form, so FastAPI can't +# infer the request body; publish it explicitly for clients that read /openapi.json. +SCENE_BODY: dict[str, Any] = { + "requestBody": { + "required": True, + "description": "A scene: bare scene.json, a zip bundle (scene.json plus the images it " + "names), or a multipart form whose one field `bundle` holds either. See " + "schemas/scene.schema.json.", + "content": { + "application/json": {"schema": {"type": "object", "title": "scene.json"}}, + "application/zip": {"schema": {"type": "string", "format": "binary"}}, + "multipart/form-data": { + "schema": { + "type": "object", + "required": [_BUNDLE_FIELD], + "properties": {_BUNDLE_FIELD: {"type": "string", "format": "binary"}}, + } + }, + }, + } +} + + +@app.post( + "/v1/placements", + openapi_extra=SCENE_BODY, + responses={200: {"description": "The result (schemas/result.schema.json)"}}, +) +async def placements(request: Request) -> JSONResponse: + payload, is_zip = await _upload(request) + _scene, result = await run_in_threadpool(_solve, payload, is_zip) + return JSONResponse(result) + + +@app.post( + "/v1/placements/site-plan.svg", + openapi_extra=SCENE_BODY, + response_class=Response, + responses={200: {"content": {"image/svg+xml": {}}, "description": "The site plan"}}, +) +async def site_plan(request: Request) -> Response: + payload, is_zip = await _upload(request) + + def render() -> str: + scene, result = _solve(payload, is_zip) + return siteplan.render(scene, result, LOADED) + + svg = await run_in_threadpool(render) + return Response(content=svg, media_type="image/svg+xml") diff --git a/server/examples/README.md b/server/examples/README.md new file mode 100644 index 000000000..c750a72ee --- /dev/null +++ b/server/examples/README.md @@ -0,0 +1,43 @@ +# Example calls + +Three synthetic scenes, one per outcome under the public demo policy: + +| Scene | Demo decision | Why | +| --- | --- | --- | +| `pass-clear-side-wall.json` | `pass` | a long side wall, everything around the spot seen | +| `reject-garage-in-the-way.json` | `reject` | a garage door and gas meters leave no spot; both ends of the walk are real ends | +| `review-corner-not-walked.json` | `manual_review` | the walk stopped at a corner; `missing_evidence` lists the views to add | + +Under the private rules the decisions can differ; that is what the private deployment is for. + +## Post them all + +From the repository root, with the decision per scene: + +```sh +make smoke URL=https://house-scanning-server.vercel.app +make smoke URL=https://house-scanning-server-private.vercel.app KEY_FILE=server/.env.private.local +``` + +`make smoke` runs `server/examples/smoke.py`, which needs only Python 3. It exits 1 when any request fails. With a key it sends only to an `https://` URL (or `http://localhost`) and doesn't follow redirects, so the key can't travel in the clear or to another server. + +## One call + +Public deployment: + +```sh +curl -s https://house-scanning-server.vercel.app/v1/placements \ + -H 'content-type: application/json' \ + --data-binary @server/examples/pass-clear-side-wall.json | python3 -m json.tool +``` + +Private deployment, reading the key from the git-ignored file: + +```sh +curl -s https://house-scanning-server-private.vercel.app/v1/placements \ + -H "Authorization: Bearer $(sed -n 's/^HOUSESCAN_API_KEY=//p' server/.env.private.local)" \ + -H 'content-type: application/json' \ + --data-binary @server/examples/review-corner-not-walked.json | python3 -m json.tool +``` + +The site plan for the same scene: post it to `/v1/placements/site-plan.svg` instead and write the answer to a file (`-o plan.svg`). diff --git a/server/examples/pass-clear-side-wall.json b/server/examples/pass-clear-side-wall.json new file mode 100644 index 000000000..d1cb2672a --- /dev/null +++ b/server/examples/pass-clear-side-wall.json @@ -0,0 +1,174 @@ +{ + "schema_version": "1.0", + "meter": { + "pos": [ + 0.0, + 5.0, + 0.0 + ], + "wall_id": "side", + "plus_minus_ft": 0.1 + }, + "walls": [ + { + "id": "side", + "baseline": [ + [ + -20.0, + 0.0 + ], + [ + 24.0, + 0.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "objects": [ + { + "type": "gas_meter", + "wall_id": "side", + "span_ft": [ + -6.0, + -5.0 + ], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tap", + "plus_minus_ft": 0.1, + "footprint": [ + [ + -6.0, + 0.0 + ], + [ + -5.0, + 0.0 + ], + [ + -5.0, + 1.0 + ], + [ + -6.0, + 1.0 + ] + ] + }, + { + "type": "window", + "wall_id": "side", + "span_ft": [ + -14.0, + -11.0 + ], + "bottom_ft": 3.0, + "top_ft": 7.0, + "attrs": { + "operable": true + }, + "source": "tap", + "plus_minus_ft": 0.1 + } + ], + "ground": [ + { + "type": "mulch", + "polygon": [ + [ + -40.0, + 0.0 + ], + [ + 40.0, + 0.0 + ], + [ + 40.0, + 4.0 + ], + [ + -40.0, + 4.0 + ] + ], + "plus_minus_ft": 0.1 + }, + { + "type": "lawn", + "polygon": [ + [ + -40.0, + 4.0 + ], + [ + 40.0, + 4.0 + ], + [ + 40.0, + 20.0 + ], + [ + -40.0, + 20.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "overheads": [], + "facing": [ + { + "wall_id": "side", + "span_ft": [ + -20.0, + 24.0 + ], + "depth_ft": 16.0, + "plus_minus_ft": 0.2 + } + ], + "coverage": { + "ends": { + "left": { + "kind": "limit" + }, + "right": { + "kind": "limit" + } + }, + "observed": [ + { + "band": "wall", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "ground", + "span_ft": [ + -20.0, + 30.0 + ], + "out_ft": 20.0 + }, + { + "band": "overhead", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "facing", + "span_ft": [ + -20.0, + 30.0 + ] + } + ] + } +} diff --git a/server/examples/reject-garage-in-the-way.json b/server/examples/reject-garage-in-the-way.json new file mode 100644 index 000000000..f515de37c --- /dev/null +++ b/server/examples/reject-garage-in-the-way.json @@ -0,0 +1,216 @@ +{ + "schema_version": "1.0", + "meter": { + "pos": [ + 0.0, + 5.0, + 0.0 + ], + "wall_id": "side", + "plus_minus_ft": 0.1 + }, + "walls": [ + { + "id": "side", + "baseline": [ + [ + -20.0, + 0.0 + ], + [ + 24.0, + 0.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "objects": [ + { + "type": "gas_meter", + "wall_id": "side", + "span_ft": [ + -6.0, + -5.0 + ], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tap", + "plus_minus_ft": 0.1, + "footprint": [ + [ + -6.0, + 0.0 + ], + [ + -5.0, + 0.0 + ], + [ + -5.0, + 1.0 + ], + [ + -6.0, + 1.0 + ] + ] + }, + { + "type": "window", + "wall_id": "side", + "span_ft": [ + -14.0, + -11.0 + ], + "bottom_ft": 3.0, + "top_ft": 7.0, + "attrs": { + "operable": true + }, + "source": "tap", + "plus_minus_ft": 0.1 + }, + { + "type": "garage_door", + "wall_id": "side", + "span_ft": [ + 1.5, + 24.0 + ], + "bottom_ft": 0.0, + "top_ft": 7.0, + "source": "tap", + "plus_minus_ft": 0.1 + }, + { + "type": "gas_meter", + "wall_id": "side", + "span_ft": [ + -18.0, + -17.0 + ], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tap", + "plus_minus_ft": 0.1, + "footprint": [ + [ + -18.0, + 0.0 + ], + [ + -17.0, + 0.0 + ], + [ + -17.0, + 1.0 + ], + [ + -18.0, + 1.0 + ] + ] + } + ], + "ground": [ + { + "type": "mulch", + "polygon": [ + [ + -40.0, + 0.0 + ], + [ + 40.0, + 0.0 + ], + [ + 40.0, + 4.0 + ], + [ + -40.0, + 4.0 + ] + ], + "plus_minus_ft": 0.1 + }, + { + "type": "lawn", + "polygon": [ + [ + -40.0, + 4.0 + ], + [ + 40.0, + 4.0 + ], + [ + 40.0, + 20.0 + ], + [ + -40.0, + 20.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "overheads": [], + "facing": [ + { + "wall_id": "side", + "span_ft": [ + -20.0, + 24.0 + ], + "depth_ft": 16.0, + "plus_minus_ft": 0.2 + } + ], + "coverage": { + "ends": { + "left": { + "kind": "limit" + }, + "right": { + "kind": "limit" + } + }, + "observed": [ + { + "band": "wall", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "ground", + "span_ft": [ + -20.0, + 30.0 + ], + "out_ft": 20.0 + }, + { + "band": "overhead", + "span_ft": [ + -20.0, + 30.0 + ] + }, + { + "band": "facing", + "span_ft": [ + -20.0, + 30.0 + ] + } + ] + } +} diff --git a/server/examples/review-corner-not-walked.json b/server/examples/review-corner-not-walked.json new file mode 100644 index 000000000..9913cbbb9 --- /dev/null +++ b/server/examples/review-corner-not-walked.json @@ -0,0 +1,128 @@ +{ + "schema_version": "1.0", + "meter": { + "pos": [ + 0.0, + 5.0, + 0.0 + ], + "wall_id": "side", + "plus_minus_ft": 0.1 + }, + "walls": [ + { + "id": "side", + "baseline": [ + [ + -3.0, + 0.0 + ], + [ + 9.0, + 0.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "objects": [], + "ground": [ + { + "type": "mulch", + "polygon": [ + [ + -40.0, + 0.0 + ], + [ + 40.0, + 0.0 + ], + [ + 40.0, + 4.0 + ], + [ + -40.0, + 4.0 + ] + ], + "plus_minus_ft": 0.1 + }, + { + "type": "lawn", + "polygon": [ + [ + -40.0, + 4.0 + ], + [ + 40.0, + 4.0 + ], + [ + 40.0, + 20.0 + ], + [ + -40.0, + 20.0 + ] + ], + "plus_minus_ft": 0.1 + } + ], + "overheads": [], + "facing": [ + { + "wall_id": "side", + "span_ft": [ + -3.0, + 9.0 + ], + "depth_ft": 16.0, + "plus_minus_ft": 0.2 + } + ], + "coverage": { + "ends": { + "left": { + "kind": "limit" + }, + "right": { + "kind": "unexplored" + } + }, + "observed": [ + { + "band": "wall", + "span_ft": [ + -20.0, + 9.0 + ] + }, + { + "band": "ground", + "span_ft": [ + -20.0, + 9.0 + ], + "out_ft": 20.0 + }, + { + "band": "overhead", + "span_ft": [ + -20.0, + 9.0 + ] + }, + { + "band": "facing", + "span_ft": [ + -20.0, + 9.0 + ] + } + ] + } +} diff --git a/server/examples/smoke.py b/server/examples/smoke.py new file mode 100644 index 000000000..9668f57f9 --- /dev/null +++ b/server/examples/smoke.py @@ -0,0 +1,104 @@ +"""Post each example scene to a placement server and print its decision. + + python3 server/examples/smoke.py URL [--key-file PATH] + +The key file holds a line HOUSESCAN_API_KEY= (server/.env.private.local for the private +deployment). Standard library only, so it runs without the server's environment. Exits 1 when a +request fails. +""" + +import argparse +import json +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +EXAMPLES = Path(__file__).resolve().parent + + +def read_key(path: Path) -> str: + for line in path.read_text().splitlines(): + name, _, value = line.partition("=") + if name.strip() == "HOUSESCAN_API_KEY" and value.strip(): + return value.strip() + raise SystemExit(f"{path}: no HOUSESCAN_API_KEY= line") + + +# Plain http may carry the key only to this machine, where it never crosses a network. +LOOPBACK = {"localhost", "127.0.0.1", "::1"} + + +def check_keyed_url(url: str) -> None: + """Refuse to send the key where someone on the network could read it.""" + parts = urllib.parse.urlsplit(url) + if parts.scheme != "https" and not (parts.scheme == "http" and parts.hostname in LOOPBACK): + raise SystemExit(f"{url}: the key is only sent over https (or to localhost)") + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + """urllib copies Authorization onto a followed redirect, even to another server; a keyed + request reports the redirect instead of following it.""" + + def redirect_request(self, *args: object) -> None: + return None + + +# An explicit empty proxy table: with http_proxy set, a keyed request would otherwise go through +# the proxy, key included. +_KEYED = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect) + + +def post(url: str, body: bytes, key: str | None) -> tuple[int, dict]: + headers = {"Content-Type": "application/json"} + opener = urllib.request.urlopen + if key: + check_keyed_url(url) + headers["Authorization"] = f"Bearer {key}" + opener = _KEYED.open + request = urllib.request.Request(url, data=body, headers=headers, method="POST") + try: + with opener(request, timeout=30) as response: + return response.status, json.load(response) + except urllib.error.HTTPError as error: + if key and 300 <= error.code < 400: + where = error.headers.get("Location") + return error.code, { + "error": {"code": "redirect_refused", "message": f"not following to {where}"} + } + text = error.read().decode(errors="replace") + try: + return error.code, json.loads(text) + except json.JSONDecodeError: + # Not the placement server's error format: another server, or its host. + return error.code, {"error": {"code": "not_json", "message": text[:120]}} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument( + "url", help="server root, for example https://house-scanning-server.vercel.app" + ) + parser.add_argument("--key-file", type=Path, help="file with HOUSESCAN_API_KEY=") + args = parser.parse_args() + key = read_key(args.key_file) if args.key_file else None + if key: + check_keyed_url(args.url) + endpoint = args.url.rstrip("/") + "/v1/placements" + failed = False + for scene in sorted(EXAMPLES.glob("*.json")): + status, body = post(endpoint, scene.read_bytes(), key) + if status == 200: + # Which rules answered, not the private policy's name. + rules = "+".join(body["policy"]["sources"]) + print(f"{scene.stem:30} {body['decision']:14} rules {rules}: {body['summary']}") + else: + failed = True + error = body.get("error", {}) + print(f"{scene.stem:30} HTTP {status} {error.get('code')}: {error.get('message')}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/pyproject.toml b/server/pyproject.toml index 3d32bde13..9a24657c1 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -4,10 +4,19 @@ version = "0.1.0" description = "FastAPI server that turns a phone capture into a battery placement (lanes B and C)" readme = "README.md" requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "fastapi>=0.141.1", + "jsonschema>=4.26.0", + "python-multipart>=0.0.32", + "pyyaml>=6.0.3", + "shapely>=2.1.2", + "uvicorn>=0.54.0", +] [dependency-groups] dev = [ + "httpx>=0.28.1", + "hypothesis>=6.168.1", "pytest>=9.1.1", "ruff>=0.16.9", ] @@ -15,8 +24,14 @@ dev = [ [tool.uv] # Modules sit flat in this directory and run in place; there is nothing to build or install. package = false -# setup-uv reads this in CI, so local and CI runs use the same uv minor version as uv.lock. -required-version = ">=0.11.15,<0.12" +# setup-uv reads this in CI and installs the newest uv in range. The floor is 0.10.11 because +# Vercel's Python build runs that version, and it reads this uv.lock (checked with +# `uvx --from uv==0.10.11 uv sync --locked`). +required-version = ">=0.10.11,<0.12" + +# Vercel deploys this directory as its own project and serves this FastAPI app. +[tool.vercel] +entrypoint = "api:app" [tool.ruff] line-length = 100 diff --git a/server/rules.py b/server/rules.py new file mode 100644 index 000000000..9b4d0b6b1 --- /dev/null +++ b/server/rules.py @@ -0,0 +1,373 @@ +"""Load rules.yaml, merge the private override over it, and validate the result. + +Every threshold the solver compares against lives in the rules file. A missing or misspelled key is +a startup error naming the key, never a silent default. +""" + +import base64 +import binascii +import copy +import hashlib +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, model_validator + +SERVER_DIR = Path(__file__).resolve().parent +PUBLIC_RULES = SERVER_DIR / "rules.yaml" +PRIVATE_RULES_ENV = "HOUSESCAN_PRIVATE_RULES" # a path to the private file +# The private file's YAML itself, base64-encoded: how a deployment gets it without uploading a file. +PRIVATE_RULES_B64_ENV = "HOUSESCAN_PRIVATE_RULES_B64" +DEFAULT_PRIVATE_RULES = SERVER_DIR.parent / "private" / "rules.yaml" +# Which public policy applies when no private rules are loaded: rules.yaml's demo policy, which +# decides automatically, or the strict one, which sends every answer to a person because some +# public values are placeholders. +POLICY_ENV = "HOUSESCAN_POLICY" +STRICT_POLICY = { + "id": "public-strict", + "auto_approve": False, + "notice": "Public rules with placeholder values; a person confirms every answer.", +} + +Effect = Literal["fail", "review", "detour", "allow"] +ObjectType = Literal[ + "window", + "door", + "garage_door", + "ac", + "gas_meter", + "elec_box", + "vent", + "downspout", + "pool", + "battery", +] +GroundType = Literal["drive", "concrete", "gravel", "lawn", "mulch", "deck"] + + +class _Strict(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class Value(_Strict): + value: float = Field(ge=0, allow_inf_nan=False) + source: str = Field(min_length=1) + placeholder: bool = False + + +class Policy(_Strict): + id: str | None + version: str | None + auto_approve: bool + allow_reject: bool + # Shown with every answer: whose rules these are. + notice: str | None = None + + +class Battery(_Strict): + width_ft: Value + depth_ft: Value + height_ft: Value + + +class Errors(_Strict): + tap_ft: Value + vlm_ft: Value + mesh_ft: Value + plane_ft: Value + tape_ft: Value + wall_ft: Value + meter_ft: Value + drift_per_ft: Value + + +class Sweep(_Strict): + step_ft: Value + wall_join_ft: Value + meter_to_wall_max_ft: Value + + @model_validator(mode="after") + def _positive_step(self) -> "Sweep": + # Every solve steps the battery along the wall by this much; zero or less can't advance. + if self.step_ft.value <= 0: + raise ValueError(f"sweep.step_ft must be positive, got {self.step_ft.value}") + return self + + +class Clearances(_Strict): + gas_ft: Value + ac_ft: Value + battery_ft: Value + opening_ft: Value + drive_ft: Value + pool_ft: Value + wall_equipment_ft: Value + + +class Openings(_Strict): + types: list[ObjectType] + exempt_fixed_windows: bool + exempt_bottom_above_ft: float | None + + +class WallEquipment(_Strict): + types: list[ObjectType] + + +class Facing(_Strict): + min_ft: Value + measured_from: Literal["battery_front", "wall"] + + +class Headroom(_Strict): + min_ft: Value + + +class MeterWorkingSpace(_Strict): + width_ft: Value + depth_ft: Value + + +class Ground(_Strict): + allowed: list[GroundType] + drivable: list[GroundType] + source: str = Field(min_length=1) + placeholder: bool + + +class Route(_Strict): + max_ft: Value + confident_reach_ft: Value + height_ft: Value + corner_allowance_ft: Value + crossing: dict[ObjectType, Effect] + + @model_validator(mode="after") + def _confident_within_max(self) -> "Route": + # Past the confident reach a run goes to review, past the maximum it fails; a confident + # reach beyond the maximum would let a run over the maximum pass. + if self.confident_reach_ft.value > self.max_ft.value: + raise ValueError( + f"route.confident_reach_ft ({self.confident_reach_ft.value}) must not exceed " + f"route.max_ft ({self.max_ft.value})" + ) + return self + + +class Rules(_Strict): + policy: Policy + battery: Battery + errors: Errors + sweep: Sweep + clearances: Clearances + openings: Openings + wall_equipment: WallEquipment + facing: Facing + headroom: Headroom + meter_working_space: MeterWorkingSpace + ground: Ground + route: Route + + @model_validator(mode="after") + def _exemption_within_the_opening_checks_height(self) -> "Rules": + # The opening check needs the wall seen only up to headroom height; a window above that + # would still count under a higher exemption but could be missed, unseen, above the view. + exempt = self.openings.exempt_bottom_above_ft + if exempt is not None and exempt > self.headroom.min_ft.value: + raise ValueError( + f"openings.exempt_bottom_above_ft ({exempt}) must not exceed headroom.min_ft " + f"({self.headroom.min_ft.value}), the wall height the opening check requires seen" + ) + return self + + +@dataclass(frozen=True) +class LoadedRules: + rules: Rules + sources: tuple[str, ...] + sha256: str + # Dotted keys the private file overrides. Answers withhold their source text. + private_keys: frozenset[str] = frozenset() + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Mappings merge key by key; any other override value replaces the base value.""" + merged = copy.deepcopy(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = deep_merge(merged[key], value) + else: + merged[key] = copy.deepcopy(value) + return merged + + +class _UniqueKeyLoader(yaml.SafeLoader): + """SafeLoader that refuses a repeated key. PyYAML keeps the last copy silently, so a rules + file with two `clearances:` blocks would load a threshold nobody meant.""" + + +def _unique_mapping(loader: yaml.SafeLoader, node: yaml.MappingNode, deep: bool = False) -> Any: + seen: set[Any] = set() + for key_node, _ in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in seen: + raise ValueError( + f"duplicate key {key!r} at line {key_node.start_mark.line + 1} of " + f"{key_node.start_mark.name}" + ) + seen.add(key) + return loader.construct_mapping(node, deep=deep) + + +_UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _unique_mapping) + + +def _read_yaml(path: Path) -> dict[str, Any]: + return _parse_yaml(path.read_text(), str(path)) + + +def _parse_yaml(text: str, where: str) -> dict[str, Any]: + data = yaml.load(text, Loader=_UniqueKeyLoader) + if not isinstance(data, dict): + raise ValueError(f"{where}: expected a mapping at the top level") + return data + + +def rules_from_dict( + data: dict[str, Any], + sources: tuple[str, ...] = ("public",), + private_keys: frozenset[str] = frozenset(), +) -> LoadedRules: + rules = Rules.model_validate(data) + canonical = json.dumps(rules.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + return LoadedRules(rules, sources, hashlib.sha256(canonical.encode()).hexdigest(), private_keys) + + +def overridden_keys(public: dict[str, Any], private: dict[str, Any], path: str = "") -> set[str]: + """Dotted keys the private file sets. A private threshold must carry its own source: merged + over the public one, it would otherwise be shown with the public citation.""" + keys: set[str] = set() + for key, value in private.items(): + dotted = f"{path}{key}" + base = public.get(key) + if isinstance(value, dict) and isinstance(base, dict): + if "value" in base and "source" in base and "source" not in value: + raise ValueError( + f"private rules: {dotted} overrides a cited value without its own source" + ) + if "value" in base: + keys.add(dotted) + else: + keys |= overridden_keys(base, value, f"{dotted}.") + else: + keys.add(dotted) + return keys + + +def public_rules_dict() -> dict[str, Any]: + return _read_yaml(PUBLIC_RULES) + + +def load_rules(private_path: Path | None = None) -> LoadedRules: + """Public rules under the policy HOUSESCAN_POLICY names, with the private rules merged over + them when there are any: from `private_path`, else HOUSESCAN_PRIVATE_RULES_B64 (the YAML + itself) or HOUSESCAN_PRIVATE_RULES (a path), else private/rules.yaml if it exists.""" + data = _public_policy(public_rules_dict()) + private = _private_rules(private_path) + if private is None: + return rules_from_dict(data) + private_keys = frozenset(overridden_keys(data, private)) + merged = deep_merge(data, private) + # The public notice says the answers are not under Base's rules, which private rules make + # untrue; but any public placeholder they leave in place still decides answers, so the + # notice names those checks instead of going quiet. + own = private.get("policy", {}).get("notice") + merged["policy"]["notice"] = _mixed_notice(own, _placeholders_left(merged, private_keys)) + return rules_from_dict(merged, ("public", "private"), private_keys) + + +# The check each placeholder rule decides; placeholders not tied to one check are named by key. +PLACEHOLDER_CHECKS = { + "clearances.drive_ft": "drive_clearance", + "clearances.pool_ft": "pool_clearance", + "clearances.wall_equipment_ft": "wall_equipment_above", + "headroom.min_ft": "headroom", + "ground": "ground_surface", + "route.confident_reach_ft": "route_length", + "route.corner_allowance_ft": "route_length", + "route.height_ft": "route_path", +} + + +def _placeholders_left(merged: dict[str, Any], private_keys: frozenset[str]) -> list[str]: + """Dotted keys still marked placeholder that the private file did not set.""" + left: list[str] = [] + + def walk(node: dict[str, Any], path: str) -> None: + if node.get("placeholder") is True: + # A cited value is replaced when the private file sets it; a group (such as ground) + # only when it sets every child that decides something, not just one of them. + if "value" in node: + replaced = path in private_keys + else: + children = [k for k in node if k not in ("source", "placeholder")] + replaced = all( + any(p == f"{path}.{k}" or p.startswith(f"{path}.{k}.") for p in private_keys) + for k in children + ) + if not replaced: + left.append(path) + return + for key, value in node.items(): + if isinstance(value, dict): + walk(value, f"{path}.{key}" if path else key) + + walk(merged, "") + return left + + +def _mixed_notice(own: str | None, left: list[str]) -> str | None: + if not left: + return own + checks = sorted({PLACEHOLDER_CHECKS[k] for k in left if k in PLACEHOLDER_CHECKS}) + other = sorted(k for k in left if k not in PLACEHOLDER_CHECKS) + parts = [] + if checks: + parts.append("these checks still use public placeholder values: " + ", ".join(checks)) + if other: + parts.append("placeholder settings still apply: " + ", ".join(other)) + mixed = "Private rules, but " + "; ".join(parts) + "." + return f"{own} {mixed}" if own else mixed + + +def _public_policy(data: dict[str, Any]) -> dict[str, Any]: + name = os.environ.get(POLICY_ENV, "demo") + if name == "demo": + return data + if name == "strict": + return deep_merge(data, {"policy": STRICT_POLICY}) + raise ValueError(f"{POLICY_ENV}={name!r}: expected 'demo' or 'strict'") + + +def _private_rules(private_path: Path | None) -> dict[str, Any] | None: + if private_path is not None: + return _read_yaml(private_path) if private_path.is_file() else None + encoded = os.environ.get(PRIVATE_RULES_B64_ENV) + path = os.environ.get(PRIVATE_RULES_ENV) + if encoded and path: + raise ValueError(f"both {PRIVATE_RULES_B64_ENV} and {PRIVATE_RULES_ENV} are set; set one") + if encoded: + try: + text = base64.b64decode(encoded, validate=True).decode() + except (binascii.Error, UnicodeDecodeError): + raise ValueError(f"{PRIVATE_RULES_B64_ENV} is not base64-encoded UTF-8") from None + return _parse_yaml(text, PRIVATE_RULES_B64_ENV) + if path: + if not Path(path).is_file(): + raise FileNotFoundError(f"{PRIVATE_RULES_ENV}={path} does not name a file") + return _read_yaml(Path(path)) + return _read_yaml(DEFAULT_PRIVATE_RULES) if DEFAULT_PRIVATE_RULES.is_file() else None diff --git a/server/rules.yaml b/server/rules.yaml new file mode 100644 index 000000000..e774c6edb --- /dev/null +++ b/server/rules.yaml @@ -0,0 +1,113 @@ +# Placement rules. Every length is in feet. +# +# Each number is {value, source}; `placeholder: true` marks a demo value with no public source. +# The solver hardcodes no distance: everything it compares against comes from here. +# +# At startup the server deep-merges a git-ignored private/rules.yaml over this file when one exists +# (path: $HOUSESCAN_PRIVATE_RULES, else /private/rules.yaml). Mappings merge key by key; +# anything else in the private file replaces the public value. Never commit Base's values. + +policy: + id: demo + version: "2026-09-26" + # Decides automatically so the team can test real passes and rejects, although the driveway, + # pool and reach values below are placeholders. HOUSESCAN_POLICY=strict sends every answer to a + # person instead (rules.py STRICT_POLICY). + auto_approve: true + # Whether this policy may reject a site at all (some policies only pass or send to review). + allow_reject: true + notice: "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." + +battery: + width_ft: {value: 2.583333333333, source: "Base Core spec: 30.68 in wide, rounded up to 31 in (docs/04, What Base does today)"} + depth_ft: {value: 1.833333333333, source: "Base Core spec: 22 in deep"} + height_ft: {value: 3.291666666667, source: "Base Core spec: 39.5 in tall"} + +# Default measurement error by how a position was measured, used when an item has no plus_minus_ft. +# Day-1 estimates, not measured error bars (docs/00, Conventions the code relies on); the field test in experiments/evals/field checks them. +errors: + tap_ft: {value: 0.3, source: "Day-1 estimate for AR taps; untested (docs/00, Conventions)"} + vlm_ft: {value: 1.5, source: "Day-1 estimate for photo detections; untested (docs/00, Conventions)"} + mesh_ft: {value: 0.5, source: "Day-1 estimate for the LiDAR mesh; untested (docs/00, Conventions)"} + # Walls from ARKit's detected planes on phones without LiDAR. No measurement exists; assumed + # coarser than the mesh until the tape test measures it. + plane_ft: {value: 0.75, source: "Untested estimate for ARKit plane detection without LiDAR; no measurement", placeholder: true} + tape_ft: {value: 0.05, source: "Tape measure read to the nearest half inch", placeholder: true} + wall_ft: {value: 0.3, source: "Walls come from AR taps: day-1 estimate; untested (docs/00, Conventions)"} + meter_ft: {value: 0.3, source: "The meter is an AR tap: day-1 estimate; untested (docs/00, Conventions)"} + # Added per foot walked from the meter to the default error of AR-placed positions (tap and + # vlm objects, walls, ground patches); not to tape, the meter itself or explicit plus_minus_ft. + # 0.3 ft + 0.16 ft/ft covers S1's p90 at 3, 10 and 30 ft walked (0.78, 1.9, 5.1 ft). + drift_per_ft: {value: 0.16, source: "S1 real-data evals (PR #12): ARKit drift on outdoor ADVIO walks, p90 8.2 in after 3 ft, 22.3 in after 10 ft, 60.1 in after 30 ft, mostly ~7% scale error. 2018 iPhone 6s, ARKit 1.0; current phones untested"} + +sweep: + step_ft: {value: 0.166666666667, source: "Slide the footprint in 2 in steps (docs/00, Decisions)"} + # Wall ends closer than this are one corner, not a stretch with no wall. Two taps of one corner can + # differ by twice the tap error. + wall_join_ft: {value: 0.6, source: "Twice the AR tap error estimate", placeholder: true} + # The meter must lie this close to its wall's line, or the scene is refused as malformed. + meter_to_wall_max_ft: {value: 3.0, source: "Input sanity bound", placeholder: true} + +clearances: + gas_ft: {value: 3.0, source: "Base help page: 3 ft from the gas meter; Austin Energy §1.9; Texas Gas Service"} + ac_ft: {value: 3.0, source: "Base help page: 3 ft from AC units, fences and other batteries"} + # A battery already on the wall (objects of type battery; issue #27). + battery_ft: {value: 3.0, source: "Base help page: 3 ft from AC units, fences and other batteries; IRC 2021 R328: 3 ft between batteries unless UL 9540A testing allows closer (docs/04, Public rule values)"} + opening_ft: {value: 3.0, source: "IRC 2021 R328.4: 3 ft from doors and windows directly entering the dwelling. Applying it to garage doors (openings.types) is a demo choice the code does not require"} + drive_ft: {value: 5.0, source: "No public value (docs/04, Public rule values)", placeholder: true} + pool_ft: {value: 10.0, source: "No public value (docs/04, Public rule values)", placeholder: true} + # Wall-mounted equipment directly above the battery: minimum sideways gap along the wall. + wall_equipment_ft: {value: 0.0, source: "No public value; 0 means only overlap fails", placeholder: true} + +openings: + # A garage door counts: a battery must not stand in front of one. + types: [door, garage_door, window] + # When true, a window recorded as not operable does not count. Unknown operability always counts. + exempt_fixed_windows: false + # A window whose bottom edge is higher than this does not count (upper floors). null: all count. + exempt_bottom_above_ft: null + +wall_equipment: + types: [elec_box, vent] + +facing: + min_ft: {value: 3.0, source: "Base help page: 3 ft from fences"} + # battery_front: the gap is measured from the battery's front face (wall gap minus battery depth). + # wall: the gap is measured from the wall, and min_ft already includes the battery's depth. + measured_from: battery_front + +headroom: + min_ft: {value: 6.5, source: "NEC 110.26(A)(3) working-space headroom, applied to the battery as a demo choice", placeholder: true} + +meter_working_space: + width_ft: {value: 2.5, source: "NEC 110.26(A)(2): 30 in wide working space"} + depth_ft: {value: 3.0, source: "NEC 110.26(A)(1): 36 in deep for 0-150 V to ground"} + +ground: + # Surfaces a battery may stand on. Anything else under the footprint fails; unknown ground is UNSURE. + allowed: [concrete, gravel, lawn, mulch] + # Surfaces vehicles reach, measured by clearances.drive_ft. + drivable: [drive] + source: "Ground types from schemas/scene.schema.json; which surfaces are allowed is a demo choice" + placeholder: true + +route: + max_ft: {value: 20.0, source: "Base help page: within 20 ft of the meter"} + # Past this length the route is UNSURE; beyond max_ft it fails. + confident_reach_ft: {value: 15.0, source: "No public value", placeholder: true} + height_ft: {value: 1.0, source: "Cable run height above ground", placeholder: true} + corner_allowance_ft: {value: 0.5, source: "Day-1 estimate: a little per corner; untested", placeholder: true} + # What a wall object across the route does: fail, review (UNSURE for a person), detour (go over or + # under it at route height), allow (pass behind it). Types not listed are allowed. + crossing: + door: fail + garage_door: fail + window: detour + elec_box: detour + vent: detour + gas_meter: detour + ac: allow + # Flush against the wall like a box, so the cable can't pass behind it; it goes over. + battery: detour + downspout: allow + pool: allow diff --git a/server/scene.py b/server/scene.py new file mode 100644 index 000000000..5db9f5c13 --- /dev/null +++ b/server/scene.py @@ -0,0 +1,1082 @@ +"""Turn an uploaded scene.json into plan geometry the solver can measure. + +The walls are chained left to right and unrolled into one coordinate s, the distance along the +chain from the meter (negative to the left). Every straight baseline segment knows its outward +direction from its point order (scene.schema.json: points run left to right as seen from outside, +so outward is the direction turned 90 degrees clockwise viewed from above). Clearances are measured +in plan (x, z) with shapely, so two things close together round an inside corner are close even +when they are far apart in s. + +What the capture observed is kept as geometry too. The unobserved part of the outdoor area near the +walls is precomputed once, so "was everything within 3 ft of this footprint seen?" is one distance. +""" + +import hashlib +import itertools +import json +import math +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any + +import shapely +from jsonschema import Draft202012Validator +from shapely import Geometry, LineString, Point, Polygon, unary_union +from shapely.validation import explain_validity + +from rules import Rules + +SCHEMA_PATH = Path(__file__).resolve().parent / "schemas" / "scene.schema.json" +_VALIDATOR = Draft202012Validator(json.loads(SCHEMA_PATH.read_text())) + +# Tolerance for comparing lengths in feet. Far below any measurement error; it only keeps floating +# point noise (3.3 - 0.3 != 3.0) from deciding a check. +EPS = 1e-9 +# Arc resolution for the outdoor-area wedges at corners. +_ARC_STEPS = 8 + +Point2 = tuple[float, float] + + +class SceneError(ValueError): + """The upload is not a usable scene. `path` points at the offending field.""" + + def __init__(self, path: str, message: str) -> None: + super().__init__(f"{path}: {message}") + self.path = path + self.message = message + + +def _sub(a: Point2, b: Point2) -> Point2: + return (a[0] - b[0], a[1] - b[1]) + + +def _norm(v: Point2) -> float: + return math.hypot(v[0], v[1]) + + +@dataclass(frozen=True) +class Piece: + """A straight stretch of the chain in s: a wall segment, a gap with no wall, or an extension + that continues an end segment past the end of the walk.""" + + kind: str # "wall", "gap" or "extension" + wall_id: str | None + index: int # segment index within its wall; -1 for gaps and extensions + a: Point2 + b: Point2 + s0: float + s1: float + along: Point2 + outward: Point2 + plus_minus: float + # Extra error per foot walked from the meter, for walls placed by AR taps without their own + # plus_minus_ft (rules.yaml errors.drift_per_ft). + drift: float = 0.0 + + def error_at(self, s: float) -> float: + """Position error of this piece's line at s.""" + return self.plus_minus + self.drift * abs(s) + + def point(self, s: float, out: float = 0.0) -> Point2: + t = s - self.s0 + return ( + self.a[0] + self.along[0] * t + self.outward[0] * out, + self.a[1] + self.along[1] * t + self.outward[1] * out, + ) + + def rect(self, s_lo: float, s_hi: float, out_lo: float, out_hi: float) -> Polygon: + return Polygon( + [ + self.point(s_lo, out_lo), + self.point(s_hi, out_lo), + self.point(s_hi, out_hi), + self.point(s_lo, out_hi), + ] + ) + + def local(self, p: Point2) -> Point2: + """(s, out) of a plan point relative to this piece's line.""" + d = _sub(p, self.a) + return ( + self.s0 + d[0] * self.along[0] + d[1] * self.along[1], + d[0] * self.outward[0] + d[1] * self.outward[1], + ) + + +@dataclass(frozen=True) +class SceneObject: + index: int + type: str + wall_id: str + span: tuple[float, float] + bottom: float | None + top: float | None + operable: bool | None + well: bool | None + source: str + plus_minus: float + geom: Geometry + # Placed by a plan outline (`footprint`), not along the walls in s: the battery moves against + # it with the meter's error, not with the walls' slide. + in_plan: bool = False + + @property + def label(self) -> str: + return f"objects[{self.index}] {self.type}" + + +@dataclass(frozen=True) +class GroundPatch: + index: int + type: str + polygon: Polygon + plus_minus: float + + +@dataclass(frozen=True) +class Measured: + """An overhead clearance or a facing gap over a stretch of wall.""" + + index: int + span: tuple[float, float] + value: float + plus_minus: float + + +@dataclass +class Scene: + input_sha256: str + pieces: list[Piece] # extension, walls and gaps in chain order, extension + meter_pos: tuple[float, float, float] + meter_plus_minus: float + meter_piece: Piece + # Each uploaded wall segment, numbered as uploaded, before collinear points and walls are + # merged: (wall id, segment index, s start, s end, the wall's declared height_ft or None). + # What a spot's wall_id and segment refer to, and the height a battery backs onto. + wall_spans: list[tuple[str, int, float, float, float | None]] + objects: list[SceneObject] + # Objects with no footprint past an unexplored end: (index, type, side, s at their middle). + # The wall may turn there, so they have no known place and are not measured (issue #42). + set_aside: list[tuple[int, str, str, float]] + ground: list[GroundPatch] + overheads: list[Measured] + facing: list[Measured] + observed: dict[str, list[tuple[float, float, float | None]]] + end_kinds: dict[str, str] + reach_ft: float # how far out from the walls the outdoor area is modelled + # A wall view without out_ft reached headroom height (headroom.min_ft), per the contract. + wall_default_ft: float = math.inf + _cache: dict[str, Any] = field(default_factory=dict, repr=False) + + # --- chain ----------------------------------------------------------------------------------- + + @property + def walls(self) -> list[Piece]: + return [p for p in self.pieces if p.kind == "wall"] + + @property + def gaps(self) -> list[Piece]: + return [p for p in self.pieces if p.kind == "gap"] + + @property + def s_min(self) -> float: + return self.walls[0].s0 + + @property + def s_max(self) -> float: + return self.walls[-1].s1 + + @property + def meter_xz(self) -> Point2: + return (self.meter_pos[0], self.meter_pos[2]) + + def segment_at(self, s: float) -> tuple[str, int]: + """The uploaded wall and its segment under s: both from the same original segment, since + a joined straight piece can span two walls.""" + wid, index, _, _, _ = min(self.wall_spans, key=lambda w: max(w[2] - s, s - w[3], 0.0)) + return wid, index + + def lowest_wall(self, s_lo: float, s_hi: float) -> float | None: + """The lowest declared height of the uploaded walls over [s_lo, s_hi], or None.""" + heights = [ + h + for _, _, a, b, h in self.wall_spans + if h is not None and a < s_hi - EPS and b > s_lo + EPS + ] + return min(heights, default=None) + + def wall_at(self, s: float) -> str: + return self.segment_at(s)[0] + + def piece_at(self, s: float) -> Piece: + for p in self.pieces: + if p.s0 - EPS <= s <= p.s1 + EPS: + return p + return self.pieces[0] if s < self.pieces[0].s0 else self.pieces[-1] + + def point_at(self, s: float, out: float = 0.0) -> Point2: + return self.piece_at(s).point(s, out) + + def polyline(self, s_a: float, s_b: float) -> list[Point2]: + """Plan points along the chain from s_a to s_b (either order), corners included.""" + lo, hi = min(s_a, s_b), max(s_a, s_b) + pts = [self.point_at(lo)] + for p in self.pieces: + if lo + EPS < p.s1 < hi - EPS: + pts.append(p.b) + pts.append(self.point_at(hi)) + return pts if s_a <= s_b else pts[::-1] + + def _past_limit(self, p: Piece) -> bool: + """Whether p is the straight continuation past an end the walk marked as a limit.""" + side = "left" if p is self.pieces[0] else "right" if p is self.pieces[-1] else None + return side is not None and self.end_kinds.get(side) == "limit" + + def band_polygon(self, s_lo: float, s_hi: float, out: float) -> Geometry: + """The ground strip in front of the chain from s_lo to s_hi, out to `out` from the wall, + with the wedges that fill the outside of convex corners. Past a limit end there is no + house behind the continued line, so the strip there covers both sides of it: a fence or + corner doesn't make the ground beyond it clear, and a camera pointed past the end from + where the walk stopped sees both sides.""" + if s_hi - s_lo <= EPS or out <= EPS: + return Polygon() + parts: list[Geometry] = [] + inside = [p for p in self.pieces if p.s1 > s_lo + EPS and p.s0 < s_hi - EPS] + for p in inside: + near = -out if self._past_limit(p) else 0.0 + parts.append(p.rect(max(s_lo, p.s0), min(s_hi, p.s1), near, out)) + for prev, nxt in itertools.pairwise(inside): + parts.append(_wedge(prev.b, prev.outward, nxt.outward, out)) + return unary_union(parts) + + def passages(self, out: float) -> list[Geometry]: + """The ground behind each gap's line out to `out`: a side passage between two walls. No + wall faces it and it isn't the house, so it is yard like the ground in front.""" + return [g.rect(g.s0, g.s1, -out, 0.0) for g in self.gaps] + + def outdoor_band(self, out: float, span: tuple[float, float] | None = None) -> Geometry: + """The ground within `out` of the chain (over `span`, the whole chain by default) that can + hold a hazard: in front of it, and behind each gap (house() is still to be taken + out).""" + lo, hi = span or (self.pieces[0].s0, self.pieces[-1].s1) + return unary_union([self.band_polygon(lo, hi, out), *self.passages(out)]) + + def view_polygon(self, s_lo: float, s_hi: float, out: float) -> Geometry: + """What a ground view over [s_lo, s_hi] out to `out` shows: the band in front of the + chain, and where its span lies within a gap, the passage behind the gap's line as well: + the camera pointed into the passage from the opening, as a view past a limit end shows + both sides of the continued line. A view that runs along the walls across a gap shows + only the front (final review: a side passage counted as seen from a view along the + walls).""" + parts = [self.band_polygon(s_lo, s_hi, out)] + for g in self.gaps: + if s_lo >= g.s0 - GAP_VIEW_SLACK_FT and s_hi <= g.s1 + GAP_VIEW_SLACK_FT and out > EPS: + parts.append(g.rect(max(s_lo, g.s0), min(s_hi, g.s1), -out, 0.0)) + return unary_union(parts) + + def s_of(self, p: Point2) -> float: + """s of the chain point nearest to a plan point.""" + best = min(self.pieces, key=lambda q: Point(p).distance(LineString([q.a, q.b]))) + return min(max(best.local(p)[0], best.s0), best.s1) + + def s_extent(self, geom: Geometry) -> tuple[float, float] | None: + """The stretch of chain a region lies in front of.""" + if geom.is_empty: + return None + coords: list[Point2] = [] + for part in getattr(geom, "geoms", [geom]): + ring = part.exterior if hasattr(part, "exterior") else part + coords += [(x, z) for x, z in ring.coords] + values = [self.s_of(c) for c in coords] + return (min(values), max(values)) + + def wall_line(self, s_lo: float, s_hi: float) -> Geometry: + if s_hi - s_lo <= EPS: + return Point(self.point_at(s_lo)) + return LineString(self.polyline(s_lo, s_hi)) + + # --- coverage -------------------------------------------------------------------------------- + + def observed_intervals( + self, band: str, up_to: float | None = None + ) -> list[tuple[float, float]]: + """Observed stretches of a 1D band. With `up_to`, only views that reached above that + height count: a wall entry's out_ft is how high up the face was seen, and one without + out_ft saw the whole band.""" + return merge_intervals( + [ + (a, b) + for a, b, out in self.observed.get(band, []) + if up_to is None + or (out is None and up_to <= self._default_reach(band) + EPS) + or (out is not None and out > up_to) + ] + ) + + def _default_reach(self, band: str) -> float: + """How high a view without out_ft reached: headroom height for the wall, which settles + every check that needs no more; all the way up for the other bands.""" + return self.wall_default_ft if band == "wall" else math.inf + + def missing( + self, band: str, s_lo: float, s_hi: float, up_to: float | None = None + ) -> list[tuple[float, float]]: + """Parts of [s_lo, s_hi] not observed in a 1D band (above `up_to`, see + observed_intervals). A gap narrower than COVERAGE_TOLERANCE_FT is rounding between the + capture's spans and the unrolled walls (the app reported a wall seen to 15 ft that + unrolls to 15.00006 ft), not an unseen stretch.""" + gaps = subtract_intervals((s_lo, s_hi), self.observed_intervals(band, up_to)) + return [(a, b) for a, b in gaps if b - a >= COVERAGE_TOLERANCE_FT] + + def unobserved_ground(self) -> Geometry: + if "ground" not in self._cache: + self._cache["ground"] = self.unobserved_ground_given(self.observed.get("ground", [])) + return self._cache["ground"] + + def unobserved_ground_given(self, ground: list[tuple[float, float, float | None]]) -> Geometry: + """The ground nobody saw, given these ground views: what unobserved_ground is for the + scene's own views, and what a request is checked against with its capture added.""" + # Behind a scanned wall is the house: at an inside corner one wall's strip reaches back + # across the other's line, and that ground no view in front of a wall can show. + outdoor = _minus(self.outdoor_band(self.reach_ft), self.house()) + seen = unary_union([self.view_polygon(a, b, out or 0.0) for a, b, out in ground]) + # Growing what was seen (SEEN_GROWTH_FT) closes gaps between observed spans narrower + # than the tolerance, as missing() ignores them for the 1D bands. + unseen = _minus(outdoor, seen.buffer(SEEN_GROWTH_FT)) + # Remove floating-point seams where the strips and corner wedges of two polygons meet, + # even when they spur off a larger unseen area: no view can be requested for them, so + # they would leave a check nothing settles. Only seams this thin go, so the growth above + # stays the whole tolerance and a gap wider than it stays unseen. + unseen = unseen.buffer(-SEAM_FT, join_style="mitre").buffer(SEAM_FT, join_style="mitre") + return unary_union([unseen, *self._unexplored_discs(ground)]) + + def house(self) -> Geometry: + """Ground that is surely the house, not yard a hazard could hide on: behind a scanned + wall segment (out to the modelled reach) and on the house side of every scanned wall's + line. A strip behind one wall alone can cross another facade into its yard (a wall + running back parallel behind another), so when in doubt a point is yard: that can only + cost an extra request, where calling yard house could pass unseen ground.""" + if "house" not in self._cache: + behind = unary_union([p.rect(p.s0, p.s1, -self.reach_ft, 0.0) for p in self.walls]) + far = 10 * self.reach_ft + sum(p.s1 - p.s0 for p in self.walls) + for p in self.walls: + behind = behind.intersection(p.rect(p.s0 - far, p.s1 + far, -far, 0.0)) + self._cache["house"] = behind + return self._cache["house"] + + def _unexplored_discs( + self, ground: list[tuple[float, float, float | None]] | None = None + ) -> list[Geometry]: + """Round an unexplored end the walls may turn any way, so the straight extension past it + proves nothing: everything within reach of that end counts as unseen, except ground that + was actually observed where a view can show it (coverable_span: in front of the scanned + walls and past a limit end) and the house behind the walls. A disc can reach past the + other end; ground seen there past a limit end is as real as anywhere else.""" + ends = {"left": self.walls[0].a, "right": self.walls[-1].b} + discs = [ + Point(ends[side]).buffer(self.reach_ft) + for side, kind in self.end_kinds.items() + if kind == "unexplored" + ] + if not discs: + return [] + lo, hi = self.coverable_span() + seen_in_front = unary_union( + [ + self.view_polygon(max(a, lo), min(b, hi), out or 0.0) + for a, b, out in (self.observed.get("ground", []) if ground is None else ground) + ] + ).buffer(SEEN_GROWTH_FT) + known = unary_union([seen_in_front, self.house()]) + return [d.difference(known) for d in discs] + + def seen_to(self, band: str, s_lo: float, s_hi: float) -> float: + """How far out (or up) the band was seen over all of [s_lo, s_hi]: at each s the + deepest view covering it, and over the stretch the shallowest of those. A view without + `out_ft` saw all the way (infinite); a stretch no view covers gives 0.""" + spans = [ + (a, b, math.inf if out is None else out) for a, b, out in self.observed.get(band, []) + ] + cuts = sorted({s_lo, s_hi} | {x for a, b, _ in spans for x in (a, b) if s_lo < x < s_hi}) + least = math.inf + for p, q in itertools.pairwise(cuts): + if q - p <= EPS: + continue + here = max((out for a, b, out in spans if a <= p + EPS and q - EPS <= b), default=0.0) + least = min(least, here) + return least + + def farthest_out(self, region: Geometry) -> float: + """The largest distance from the chain's line (walls and their continuations past the + ends) of any point of `region`: how far out a ground view must reach to cover it.""" + line = LineString(self.polyline(self.pieces[0].s0, self.pieces[-1].s1)) + points = [ + Point(x, z) + for part in getattr(region, "geoms", [region]) + if not part.is_empty + for x, z in (part.exterior.coords if hasattr(part, "exterior") else part.coords) + ] + return max((line.distance(p) for p in points), default=0.0) + + def view_to_cover( + self, region: Geometry, s_lo: float, s_hi: float + ) -> tuple[float, float, float]: + """The ground view (s_lo, s_hi, out_ft) after which none of `region` is unseen, with the + smallest depth, found by bisection on exactly the geometry the checks use, so capturing + it settles the request in one round. The region's distance from the chain's lines is + not enough: near a corner a point can be close to one line but in front of another + wall, and past an unexplored end only ground in front of the scanned walls counts. A + span ending exactly at a convex corner misses the wedge in front of it, so if no depth + settles the region the span is widened by the tolerance at both ends and tried again.""" + ground = list(self.observed.get("ground", [])) + + area = polygonal(region) + + def covers(a: float, b: float, depth: float) -> bool: + # The checks count any unseen ground within their radius, however small. At 1e-9 sq + # ft a lens where a clearance circle meets the view's edge survived 1.7e-5 ft inside + # the radius (test_final_review, the 108 degree corner); 1e-12 is still far above + # the snapped overlays' noise (around 1e-18). + unseen = polygonal(self.unobserved_ground_given([*ground, (a, b, depth)])) + return shapely.intersection(area, unseen, grid_size=1e-9).area <= 1e-12 + + tol = COVERAGE_TOLERANCE_FT + # A region's nearest wall need not be the one it lies in front of (at an inside corner, + # ground just behind one wall's line is in front of the next): last, the span is widened + # over every wall whose strip holds part of the region. + fronted = [s_lo, s_hi] + for p in self.walls: + strip = p.rect(p.s0, p.s1, 0.0, self.reach_ft) + part = shapely.intersection(area, strip, grid_size=1e-9) + if part.area > 1e-9: + us = [p.local((x, z))[0] for x, z in shapely.get_coordinates(part)] + fronted += [max(min(us), p.s0), min(max(us), p.s1)] + spans = ((s_lo, s_hi), (s_lo - tol, s_hi + tol), (min(fronted) - tol, max(fronted) + tol)) + for a, b in spans: + lo, hi = 0.0, max(self.farthest_out(region), 1e-3) + while not covers(a, b, hi) and hi <= 4 * self.reach_ft + 100: + lo, hi = hi, hi * 2 + if not covers(a, b, hi): + continue + for _ in range(30): + mid = (lo + hi) / 2 + if covers(a, b, mid): + hi = mid + else: + lo = mid + return a, b, hi + # No view settles it (a view's span decides what it can reach); the distance from the + # chain's lines is the honest lower bound. + return s_lo, s_hi, self.farthest_out(region) + + def coverable_span(self) -> tuple[float, float]: + """The s range a view can settle: the scanned walls, plus the continuation past a limit + end. It stops at an unexplored end, past which the walls may turn any way.""" + left = self.s_min if self.end_kinds.get("left") == "unexplored" else self.pieces[0].s0 + right = self.s_max if self.end_kinds.get("right") == "unexplored" else self.pieces[-1].s1 + return left, right + + def coverable(self, band: str) -> Geometry: + """Where observing `band` can settle what is unseen: in front of the scanned walls, and + past a limit end. Past an unexplored end the walls may turn any way, so no view settles + it; only walking on does (a past_end request).""" + key = f"coverable-{band}" + if key not in self._cache: + lo, hi = self.coverable_span() + if band == "ground": + self._cache[key] = _minus(self.outdoor_band(self.reach_ft, (lo, hi)), self.house()) + else: + self._cache[key] = self.wall_line(lo, hi).buffer(1e-6, cap_style="flat") + return self._cache[key] + + def unobserved_wall_lines(self, up_to: float | None = None) -> Geometry: + """Stretches of the chain's line nobody saw up to height `up_to`, the part a view of the + wall settles.""" + key = f"wall-lines-{up_to}" + if key not in self._cache: + lo, hi = self.pieces[0].s0, self.pieces[-1].s1 + gaps = self.missing("wall", lo, hi, up_to) + self._cache[key] = unary_union([self.wall_line(a, b) for a, b in gaps]) + return self._cache[key] + + def unexplored_area(self) -> Geometry: + """Where the walls past an unexplored end might run: in front of the scanned walls only + a view of the ground there rules that out.""" + if "unexplored" not in self._cache: + self._cache["unexplored"] = unary_union(self._unexplored_discs()) + return self._cache["unexplored"] + + def unobserved_wall(self, up_to: float | None = None) -> Geometry: + key = f"wall-{up_to}" + if key not in self._cache: + self._cache[key] = unary_union( + [self.unobserved_wall_lines(up_to), self.unexplored_area()] + ) + return self._cache[key] + + +def polygonal(geom: Geometry) -> Geometry: + """Only the areas of a geometry: overlays can leave line or point fragments beside them, + which an area overlay refuses as mixed-dimension input.""" + parts = [g for g in getattr(geom, "geoms", [geom]) if g.geom_type.endswith("Polygon")] + return unary_union(parts) if parts else Polygon() + + +def merge_intervals(intervals: list[tuple[float, float]]) -> list[tuple[float, float]]: + out: list[tuple[float, float]] = [] + for a, b in sorted(intervals): + if out and a <= out[-1][1] + EPS: + out[-1] = (out[-1][0], max(out[-1][1], b)) + else: + out.append((a, b)) + return out + + +def subtract_intervals( + target: tuple[float, float], covered: list[tuple[float, float]] +) -> list[tuple[float, float]]: + lo, hi = target + missing: list[tuple[float, float]] = [] + cursor = lo + for a, b in merge_intervals(covered): + if b <= cursor + EPS: + continue + if a >= hi - EPS: + break + if a > cursor + EPS: + missing.append((cursor, min(a, hi))) + cursor = max(cursor, b) + if cursor >= hi - EPS: + break + if cursor < hi - EPS: + missing.append((cursor, hi)) + return missing + + +def _wedge(v: Point2, n1: Point2, n2: Point2, radius: float) -> Polygon: + a1, a2 = math.atan2(n1[1], n1[0]), math.atan2(n2[1], n2[0]) + delta = (a2 - a1 + math.pi) % (2 * math.pi) - math.pi + pts = [v] + for k in range(_ARC_STEPS + 1): + ang = a1 + delta * k / _ARC_STEPS + # Scale up so the polygon's chords stay outside the arc. + r = radius / math.cos(abs(delta) / (2 * _ARC_STEPS)) + pts.append((v[0] + r * math.cos(ang), v[1] + r * math.sin(ang))) + poly = Polygon(pts) + return poly if poly.is_valid and poly.area > EPS else Polygon() + + +# Baseline points closer than this (0.6 in) to the line through their neighbours are one straight +# wall. Deliberately well below the tap error: a real step in the wall must stay a corner. +COLLINEAR_FT = 0.05 + + +def _uploaded_spans( + wid: str, uploaded: list[Point2], s0: float, s1: float, height: float | None +) -> list[tuple[str, int, float, float, float | None]]: + """Each uploaded segment of a wall with its stretch of s, numbered as uploaded. The segments + are laid out by their lengths, scaled to the merged wall's [s0, s1], since merging points + within COLLINEAR_FT of a line shortens the path by a hair.""" + lengths = [_norm(_sub(b, a)) for a, b in itertools.pairwise(uploaded)] + scale = (s1 - s0) / sum(lengths) + out, s = [], s0 + for i, length in enumerate(lengths): + out.append((wid, i, s, s + length * scale, height)) + s += length * scale + return out + + +def _merge_collinear(pts: list[Point2], tol: float, path: str) -> list[Point2]: + """Drop baseline points that lie on the straight line between their neighbours (within the + wall's error), so a tap in the middle of a straight wall is not a corner the battery can't + straddle.""" + out = [pts[0]] + dropped: list[Point2] = [] + for i in range(1, len(pts) - 1): + a, b = out[-1], pts[i + 1] + # Every point dropped since the last kept one must stay on the new chord, so error + # can't accumulate along a gently curving line of taps. + if all(_on_chord(a, q, b, tol, f"{path}/{i + 1}") for q in [*dropped, pts[i]]): + dropped.append(pts[i]) + else: + out.append(pts[i]) + dropped = [] + out.append(pts[-1]) + return out + + +def _on_chord(a: Point2, p: Point2, b: Point2, tol: float, path: str) -> bool: + ab = _sub(b, a) + length = _norm(ab) + if length < 1e-6: + raise SceneError(path, "repeats an earlier point") + t_along = ((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1]) / (length * length) + off = abs((p[0] - a[0]) * ab[1] - (p[1] - a[1]) * ab[0]) / length + return 0 < t_along < 1 and off <= tol + + +def _join_straight_walls(pieces: list[Piece]) -> list[Piece]: + """Two walls that meet in a straight line are one straight stretch a battery can span. The + joined piece keeps the first wall's id; Scene.wall_at names the wall under any s.""" + out: list[Piece] = [] + for p in pieces: + prev = out[-1] if out else None + if ( + prev is not None + and prev.kind == p.kind == "wall" + and prev.wall_id != p.wall_id + and _norm(_sub(prev.b, p.a)) <= COLLINEAR_FT + and _on_chord(prev.a, prev.b, p.b, COLLINEAR_FT, "/walls") + ): + length = _norm(_sub(p.b, prev.a)) + along = ((p.b[0] - prev.a[0]) / length, (p.b[1] - prev.a[1]) / length) + out[-1] = Piece( + "wall", + prev.wall_id, + prev.index, + prev.a, + p.b, + prev.s0, + prev.s0 + length, + along, + _outward(along), + max(prev.plus_minus, p.plus_minus), + max(prev.drift, p.drift), + ) + else: + out.append(p) + return out + + +def _outward(along: Point2) -> Point2: + # Points run left to right seen from outside, so outward is `along` turned clockwise viewed + # from above: (ux, uz) -> (-uz, ux). + return (-along[1], along[0]) + + +def _xz(point: list[float]) -> Point2: + return (_flush(point[0]), _flush(point[1])) + + +def _flush(v: float) -> float: + """A plan coordinate, with anything below the overlay grid (OVERLAY_GRID_FT) read as 0. A + subnormal one (7.8e-314 ft in a wall's end) made GEOS drop the ground behind a gap from the + unseen area (test_final_opus_review).""" + v = float(v) + return 0.0 if abs(v) < OVERLAY_GRID_FT else v + + +def _span(value: list[float], path: str) -> tuple[float, float]: + a, b = float(value[0]), float(value[1]) + if a > b: + raise SceneError(path, f"span_ft must be [start, end] with start <= end, got [{a}, {b}]") + return (a, b) + + +def _geometry(points: list[Point2], path: str) -> Geometry: + if len(points) == 1: + return Point(points[0]) + if len(points) == 2: + return LineString(points) + poly = Polygon(points) + if not poly.is_valid: + raise SceneError(path, f"polygon is not simple ({explain_validity(poly)})") + return poly + + +def _error(item: dict[str, Any], default: float) -> float: + return float(item["plus_minus_ft"]) if "plus_minus_ft" in item else default + + +# The most straight wall segments a scene may have, counted after nearly collinear taps merge. +# The solve grows faster than their square (house() and the unseen ground intersect every +# segment's strips): zigzag walls took 0.37 s at 200 segments, 2.7 s at 398 and 19-29 s at 798 +# (final review probe r_time2; this machine under load). A house's outline needs far fewer; the +# schema allows 50 walls. +MAX_WALL_PIECES = 200 + +# Grid the ground overlays snap to, far below any measurement. Unsnapped, subtracting the house +# from the band in front of two exact walls meeting at a 51 degree corner gave a polygon with a +# hole outside its shell, and the next overlay raised "side location conflict" +# (test_within_errors). +OVERLAY_GRID_FT = 1e-9 + + +def _minus(a: Geometry, b: Geometry) -> Geometry: + """a less b, snapped. Only their areas: a snapped overlay refuses the line left where two + walls' strips meet at a corner they don't quite share.""" + return shapely.difference(polygonal(a), polygonal(b), grid_size=OVERLAY_GRID_FT) + + +# Unseen ground thinner than twice this is a seam between polygons (float noise), not a gap. +SEAM_FT = 1e-4 + +# Seen ground grows by this on each side, closing gaps under 2 * (this + SEAM_FT): exactly the +# coverage tolerance, so the growth and the seam removal together never close a wider gap. +SEEN_GROWTH_FT = 0.005 - SEAM_FT + +# Coverage gaps narrower than this (1/8 in) are float noise between a capture's rounded spans and +# the unrolled walls, 25 times smaller than the smallest default position error (tape, 0.05 ft). +COVERAGE_TOLERANCE_FT = 0.01 + +# How far past a gap's ends a view may start or end and still count as pointed into the passage: +# a request's span can be widened by the tolerance when it is computed. +GAP_VIEW_SLACK_FT = 2 * COVERAGE_TOLERANCE_FT + +# Coordinates beyond this are not a house scan; they would only exhaust memory in the sweep. +MAX_COORDINATE_FT = 1e5 + + +def _check_numbers(raw: Any) -> None: + """JSON Schema accepts NaN and infinities as numbers; nothing in a scene may be either. + Walks with an explicit stack, so deeply nested input can't exhaust Python's recursion.""" + stack: list[tuple[Any, str]] = [(raw, "")] + while stack: + value, path = stack.pop() + if isinstance(value, dict): + stack.extend((v, f"{path}/{k}") for k, v in value.items()) + elif isinstance(value, list): + stack.extend((v, f"{path}/{i}") for i, v in enumerate(value)) + elif isinstance(value, float | int) and not isinstance(value, bool): + if not math.isfinite(value): + raise SceneError(path or "/", f"{value!r} is not a finite number") + if abs(value) > MAX_COORDINATE_FT: + raise SceneError( + path or "/", f"{value} ft is beyond the {MAX_COORDINATE_FT:g} ft bound" + ) + + +def validate_schema(raw: Any) -> None: + _check_numbers(raw) + errors = sorted(_VALIDATOR.iter_errors(raw), key=lambda e: list(e.absolute_path)) + if errors: + e = errors[0] + path = "/" + "/".join(str(p) for p in e.absolute_path) + raise SceneError(path, _capped(e.message, e.instance)) + + +# jsonschema quotes the whole offending value in its message (" is too long"); a refusal +# shouldn't echo a large upload back. The quote is cut to its start, keeping the verdict after it, +# and the path already says where the problem is. +QUOTE_CHARS = 200 +MESSAGE_CHARS = 1000 + + +def _capped(message: str, instance: Any) -> str: + quoted = repr(instance) + if len(quoted) > QUOTE_CHARS and quoted in message: + cut = len(quoted) - QUOTE_CHARS + message = message.replace(quoted, f"{quoted[:QUOTE_CHARS]}… ({cut} characters cut)", 1) + if len(message) > MESSAGE_CHARS: + message = f"{message[:MESSAGE_CHARS]}…" + return message + + +def parse_scene(raw: dict[str, Any], rules: Rules, input_bytes: bytes | None = None) -> Scene: + """Validate against scene.schema.json, then build the unrolled chain and plan geometry.""" + validate_schema(raw) + if input_bytes is None: + input_bytes = json.dumps(raw, sort_keys=True, separators=(",", ":")).encode() + errors = rules.errors + drift = errors.drift_per_ft.value + source_error = { + "tap": errors.tap_ft.value, + "vlm": errors.vlm_ft.value, + "tape": errors.tape_ft.value, + } + + # Chain the walls. s is provisional (0 at the chain's left end) until the meter is placed. + join_tol = rules.sweep.wall_join_ft.value + pieces: list[Piece] = [] + spans: list[tuple[str, int, float, float, float | None]] = [] + wall_ids: set[str] = set() + s = 0.0 + prev_end: Point2 | None = None + prev_err = prev_drift = 0.0 + for wi, wall in enumerate(raw["walls"]): + wid = wall["id"] + if wid in wall_ids: + raise SceneError(f"/walls/{wi}/id", f"duplicate wall id {wid!r}") + wall_ids.add(wid) + pts = [_xz(p) for p in wall["baseline"]] + # How the wall's line was found sets its default error: AR taps (also when `source` is + # absent), the LiDAR mesh, or detected planes. All three drift with distance walked. + default = { + "tap": errors.wall_ft, + "mesh": errors.mesh_ft, + "plane": errors.plane_ft, + }[wall.get("source", "tap")] + wall_err = _error(wall, default.value) + wall_drift = 0.0 if "plus_minus_ft" in wall else drift + uploaded = pts + pts = _merge_collinear(pts, COLLINEAR_FT, f"/walls/{wi}/baseline") + if prev_end is not None: + gap = _norm(_sub(pts[0], prev_end)) + # Two walls meet when the space between their ends is within both walls' errors (the + # taps may simply disagree), capped at sweep.wall_join_ft. A wider space is a real + # gap: a stretch with no wall, which s counts. Exact walls keep any gap above float + # noise (EPS); coverage rounding says nothing about where walls end. + meets = min(join_tol, max(prev_err + wall_err, EPS)) + if gap > meets: + along = ( + (pts[0][0] - prev_end[0]) / gap, + (pts[0][1] - prev_end[1]) / gap, + ) + # The gap's length is only known to within both walls' errors. + gap_err = prev_err + wall_err + pieces.append( + Piece( + "gap", + None, + -1, + prev_end, + pts[0], + s, + s + gap, + along, + _outward(along), + gap_err, + prev_drift + wall_drift, + ) + ) + s += gap + wall_s0 = s + for i in range(len(pts) - 1): + length = _norm(_sub(pts[i + 1], pts[i])) + if length < 1e-6: + raise SceneError(f"/walls/{wi}/baseline/{i + 1}", "repeats the previous point") + along = ((pts[i + 1][0] - pts[i][0]) / length, (pts[i + 1][1] - pts[i][1]) / length) + pieces.append( + Piece( + "wall", + wid, + i, + pts[i], + pts[i + 1], + s, + s + length, + along, + _outward(along), + wall_err, + wall_drift, + ) + ) + s += length + spans += _uploaded_spans(wid, uploaded, wall_s0, s, wall.get("height_ft")) + prev_end, prev_err, prev_drift = pts[-1], wall_err, wall_drift + + # Place the meter: s = 0 at its projection onto its own wall. + meter = raw["meter"] + if meter["wall_id"] not in wall_ids: + raise SceneError("/meter/wall_id", f"no wall with id {meter['wall_id']!r}") + mx, my, mz = (float(v) for v in meter["pos"]) + mpos = (mx, my, mz) + mxz = (mpos[0], mpos[2]) + best: tuple[float, Piece, float, float] | None = None + for p in pieces: + if p.wall_id != meter["wall_id"]: + continue + s_local, _out = p.local(mxz) + s_clamped = min(max(s_local, p.s0), p.s1) + dist = _norm(_sub(mxz, p.point(s_clamped))) + if best is None or dist < best[0]: + best = (dist, p, s_clamped, abs(s_local - s_clamped)) + assert best is not None + meter_err = _error(meter, errors.meter_ft.value) + overshoot = best[3] + if overshoot > meter_err + best[1].plus_minus + EPS: + # Clamping would silently shorten every cable route by the overshoot. + raise SceneError( + "/meter/pos", + f"the meter is {overshoot:.2f} ft past the end of wall {meter['wall_id']!r}, more " + "than the meter's and the wall's errors allow; extend the wall to the meter", + ) + max_off = rules.sweep.meter_to_wall_max_ft.value + if best[0] > max_off: + raise SceneError( + "/meter/pos", + f"the meter is {best[0]:.2f} ft from wall {meter['wall_id']!r}; " + f"more than {max_off} ft means the scene is misaligned", + ) + shift = best[2] + pieces = [replace(p, s0=p.s0 - shift, s1=p.s1 - shift) for p in pieces] + wall_spans = [(wid, i, a - shift, b - shift, h) for wid, i, a, b, h in spans] + pieces = _join_straight_walls(pieces) + walls_n = sum(1 for p in pieces if p.kind == "wall") + if walls_n > MAX_WALL_PIECES: + raise SceneError( + "/walls", + f"the walls have {walls_n} straight wall segments, more than the {MAX_WALL_PIECES} a " + "house's outline needs; the solve grows faster than their square, so a scene this " + "fragmented is refused before it starts (taps within 0.05 ft of a straight line are " + "already merged)", + ) + meter_piece = next(p for p in pieces if p.kind == "wall" and p.s0 - EPS <= 0 <= p.s1 + EPS) + + # How far out from the walls the outdoor area matters: the largest clearance, plus the + # battery, plus the largest wall error anywhere, because a check must have seen everything + # within its clearance plus the footprint's own error. Anything out there nobody observed + # then counts as unseen. + c = rules.clearances + reach = max( + c.gas_ft.value, + c.ac_ft.value, + c.battery_ft.value, + c.opening_ft.value, + c.drive_ft.value, + c.pool_ft.value, + ) + wall_error = max(p.error_at(max(abs(p.s0), abs(p.s1))) for p in pieces if p.kind == "wall") + reach += rules.battery.depth_ft.value + wall_error + 1.0 + first, last = pieces[0], pieces[-1] + ext_len = reach + rules.battery.width_ft.value + left_ext = Piece( + "extension", + None, + -1, + first.point(first.s0 - ext_len), + first.a, + first.s0 - ext_len, + first.s0, + first.along, + first.outward, + first.plus_minus, + first.drift, + ) + right_ext = Piece( + "extension", + None, + -1, + last.b, + last.point(last.s1 + ext_len), + last.s1, + last.s1 + ext_len, + last.along, + last.outward, + last.plus_minus, + last.drift, + ) + pieces = [left_ext, *pieces, right_ext] + + scene = Scene( + input_sha256=hashlib.sha256(input_bytes).hexdigest(), + pieces=pieces, + meter_pos=mpos, + meter_plus_minus=meter_err, + meter_piece=meter_piece, + wall_spans=wall_spans, + objects=[], + set_aside=[], + ground=[], + overheads=[], + facing=[], + observed={}, + end_kinds={"left": "unexplored", "right": "unexplored"}, + reach_ft=reach, + wall_default_ft=rules.headroom.min_ft.value, + ) + + # What each end is decides where an object without a footprint can be placed, so it is read + # before the objects. + for side, end in raw.get("coverage", {}).get("ends", {}).items(): + scene.end_kinds[side] = end["kind"] + + for i, obj in enumerate(raw.get("objects", [])): + path = f"/objects/{i}" + if obj["wall_id"] not in wall_ids: + raise SceneError(f"{path}/wall_id", f"no wall with id {obj['wall_id']!r}") + span = _span(obj["span_ft"], f"{path}/span_ft") + bottom, top = obj.get("bottom_ft"), obj.get("top_ft") + if bottom is not None and top is not None and bottom > top: + raise SceneError(path, f"bottom_ft {bottom} is above top_ft {top}") + if "footprint" in obj: + geom = _geometry([_xz(p) for p in obj["footprint"]], f"{path}/footprint") + else: + # Without a footprint an object lies on the wall's line. Past an unexplored end that + # line may not exist (the wall may turn), so only the part on the scanned wall counts. + lo, hi = span + if scene.end_kinds["left"] == "unexplored": + lo = max(lo, scene.s_min) + if scene.end_kinds["right"] == "unexplored": + hi = min(hi, scene.s_max) + # Set aside when nothing of a mark with length remains on the scanned wall (its span + # can start exactly at the end, leaving a single point); a point mark stays. + if hi < lo - EPS or (span[1] - span[0] > EPS and hi - lo <= EPS): + # A span starting exactly at the right end is past it too. + side = "right" if span[0] >= scene.s_max - EPS else "left" + scene.set_aside.append((i, obj["type"], side, (span[0] + span[1]) / 2)) + continue + geom = scene.wall_line(lo, max(lo, hi)) + attrs = obj.get("attrs", {}) + scene.objects.append( + SceneObject( + index=i, + type=obj["type"], + wall_id=obj["wall_id"], + span=span, + bottom=None if bottom is None else float(bottom), + top=None if top is None else float(top), + operable=attrs.get("operable"), + well=attrs.get("well"), + source=obj["source"], + plus_minus=_error( + obj, + source_error[obj["source"]] + + (drift * max(abs(span[0]), abs(span[1])) if obj["source"] != "tape" else 0), + ), + geom=geom, + in_plan="footprint" in obj, + ) + ) + + for i, patch in enumerate(raw.get("ground", [])): + poly = _geometry([_xz(p) for p in patch["polygon"]], f"/ground/{i}/polygon") + walked = max(abs(scene.s_of(c)) for c in poly.exterior.coords) + default = errors.tap_ft.value + drift * walked + scene.ground.append(GroundPatch(i, patch["type"], poly, _error(patch, default))) + + for key, target, value_key in ( + ("overheads", scene.overheads, "clearance_ft"), + ("facing", scene.facing, "depth_ft"), + ): + for i, item in enumerate(raw.get(key, [])): + if item["wall_id"] not in wall_ids: + raise SceneError(f"/{key}/{i}/wall_id", f"no wall with id {item['wall_id']!r}") + target.append( + Measured( + i, + _span(item["span_ft"], f"/{key}/{i}/span_ft"), + float(item[value_key]), + _error(item, errors.mesh_ft.value), + ) + ) + + coverage = raw.get("coverage", {}) + for i, obs in enumerate(coverage.get("observed", [])): + span = _span(obs["span_ft"], f"/coverage/observed/{i}/span_ft") + scene.observed.setdefault(obs["band"], []).append((span[0], span[1], obs.get("out_ft"))) + + _check_orientation(scene, raw.get("keyframes", [])) + return scene + + +def _check_orientation(scene: Scene, keyframes: list[dict[str, Any]]) -> None: + """Cameras stand outside the house. If most keyframes sit on the inward side of the wall they + face, the baseline points were almost certainly ordered right to left.""" + walls = scene.walls + inward = total = 0 + for kf in keyframes: + pose = kf["pose"] + cam = (float(pose[12]), float(pose[14])) + nearest = min(walls, key=lambda p: Point(cam).distance(LineString([p.a, p.b]))) + s_local, out = nearest.local(cam) + if nearest.s0 <= s_local <= nearest.s1: + total += 1 + inward += out < 0 + if total >= 3 and inward * 2 > total: + raise SceneError( + "/walls", + f"{inward} of {total} keyframe cameras sit on the inward side of the wall they face; " + "baseline points must run left to right as seen from outside", + ) diff --git a/server/schemas/result.schema.json b/server/schemas/result.schema.json new file mode 100644 index 000000000..e7ae5a67b --- /dev/null +++ b/server/schemas/result.schema.json @@ -0,0 +1,237 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/SamGu-NRX/house-scanning/server/schemas/result.schema.json", + "title": "Battery placement result", + "description": "The placement server's answer for one scene (contract C2). Lengths are in feet; plan coordinates [x, z] and s follow scene.schema.json. The result settles placement only, never the whole installation: electrical checks (panel, meter) are outside it.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "decision", "summary", "reasons", "policy", "spot", "route", "checks", "missing_evidence", "ends", "sweep", "stats"], + "properties": { + "schema_version": {"const": "1.0"}, + "decision": { + "enum": ["pass", "manual_review", "reject"], + "description": "pass: a fully observed spot passes every check under an approved policy. manual_review: a person must decide, because the best spot has an UNSURE check, the policy is not approved for automatic decisions, or an area that could hold a valid spot was not seen. reject: every spot within reach fails by a clear margin, and both ends of the walk are known." + }, + "summary": {"type": "string", "description": "One sentence for the homeowner or reviewer."}, + "reasons": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { + "enum": ["all_checks_pass", "policy_not_approved", "unsure_checks", "unobserved_area", "unexplored_end", "all_spots_fail", "no_wall_segment_fits"] + }, + "message": {"type": "string"}, + "checks": {"type": "array", "items": {"type": "string"}, "description": "Check ids behind this reason."} + } + } + }, + "policy": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "auto_approve", "sources", "rules_sha256"], + "properties": { + "id": {"type": ["string", "null"]}, + "version": {"type": ["string", "null"]}, + "auto_approve": {"type": "boolean", "description": "Whether the rules allow automatic decisions: false when no policy is selected or the rules set auto_approve false (the public strict policy does, because some values are placeholders; the public demo policy decides anyway and says so in notice). When false, every would-be pass or reject becomes manual_review. Each check's rule.placeholder says which values decided it."}, + "sources": {"type": "array", "items": {"enum": ["public", "private"]}, "description": "Which rules files were loaded."}, + "rules_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "Hash of the merged rules the decision used."}, + "notice": {"type": ["string", "null"], "description": "Whose rules these are, to show with the answer, for example that the public demo policy is not Base's. Null for rules that need no such label."} + } + }, + "spot": { + "description": "The chosen battery position: the best passing spot, or for manual_review the best spot with no failing check. Null when no such spot exists, including every reject.", + "oneOf": [{"type": "null"}, {"$ref": "#/$defs/spot"}] + }, + "route": { + "description": "Cable route from the meter to the chosen spot. Null when `spot` is null.", + "oneOf": [{"type": "null"}, {"$ref": "#/$defs/route"}] + }, + "checks": { + "description": "Every check at the chosen spot, or, when `spot` is null, at `nearest_considered`.", + "type": "array", + "items": {"$ref": "#/$defs/check"} + }, + "nearest_considered": { + "description": "When `spot` is null: the evaluated spot closest to passing (fewest failing checks, then fewest unsure, then shortest route), whose checks explain why it fails. Null otherwise.", + "oneOf": [{"type": "null"}, {"$ref": "#/$defs/spot"}] + }, + "missing_evidence": { + "description": "Views that would settle an UNSURE result. Only areas nobody observed appear here; an UNSURE caused by a measurement inside its error band needs a person, not more photos. Empty for pass and reject. Supplying exactly what a band request names settles it: a coverage.observed entry with the same band and span_ft, and out_ft at least the request's out_ft.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "message"], + "properties": { + "kind": {"enum": ["band", "past_end"]}, + "band": {"enum": ["wall", "ground", "overhead", "facing"]}, + "span_ft": {"$ref": "#/$defs/span"}, + "out_ft": {"type": "number", "minimum": 0, "description": "For ground, facing, overhead and wall requests: how far the view must reach, out from the wall (ground, facing) or up from the ground (overhead, wall). Report it as the observed entry's out_ft; seeing further also settles it."}, + "side": {"enum": ["left", "right"]}, + "checks": {"type": "array", "items": {"type": "string"}}, + "message": {"type": "string"} + } + } + }, + "objects_not_used": { + "description": "Marked objects the server did not use: ones with no footprint past an unexplored end of the walk, where the wall may turn, so they have no known place. Tell the homeowner those marks weren't counted; walking further or giving them a footprint lets them count.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["object", "side", "message"], + "properties": { + "object": {"type": "string", "description": "objects[] , as in checks' subject."}, + "side": {"enum": ["left", "right"]}, + "message": {"type": "string"} + } + } + }, + "ends": { + "type": "object", + "additionalProperties": false, + "required": ["left", "right"], + "properties": { + "left": {"$ref": "#/$defs/end"}, + "right": {"$ref": "#/$defs/end"} + } + }, + "sweep": { + "description": "Outcome of every battery start position along the wall, merged into runs of equal outcome. Shows why each stretch fails; the site plan colours the wall with it.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["wall_id", "start_ft", "outcome", "failing", "unsure"], + "properties": { + "wall_id": {"type": "string"}, + "segment": {"type": "integer", "minimum": 0, "description": "Straight baseline segment of `wall_id`; runs never cross a corner."}, + "start_ft": {"$ref": "#/$defs/span", "description": "Range of battery start positions (left edge, in s) in this run. Stretches too far along the wall for any cable route are one failing run, not evaluated start by start."}, + "outcome": {"$ref": "#/$defs/outcome"}, + "failing": {"type": "array", "items": {"type": "string"}}, + "unsure": {"type": "array", "items": {"type": "string"}} + } + } + }, + "stats": { + "type": "object", + "additionalProperties": false, + "required": ["candidates", "pass", "unsure", "fail", "elapsed_ms", "input_sha256"], + "properties": { + "candidates": {"type": "integer", "minimum": 0}, + "pass": {"type": "integer", "minimum": 0}, + "unsure": {"type": "integer", "minimum": 0}, + "fail": {"type": "integer", "minimum": 0}, + "elapsed_ms": {"type": "number", "minimum": 0}, + "input_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "Hash of the scene JSON as uploaded."} + } + } + }, + "$defs": { + "point2": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}}, + "span": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}}, + "outcome": {"enum": ["pass", "fail", "unsure"]}, + "end": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "s_ft", "point"], + "properties": { + "kind": {"enum": ["limit", "unexplored"]}, + "s_ft": {"type": "number", "description": "Where the wall chain ends, in s."}, + "point": {"$ref": "#/$defs/point2"}, + "beyond_reach": {"type": "boolean", "description": "True when this end is so far along the wall that no spot past it could pass the route-length check, so an unexplored end there does not block a reject."} + } + }, + "check": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "outcome", "reason", "measured_ft", "plus_minus_ft", "threshold_ft", "comparison", "rule"], + "properties": { + "id": {"type": "string", "description": "Stable identifier, for example gas_clearance or route_length."}, + "label": {"type": "string"}, + "outcome": {"$ref": "#/$defs/outcome"}, + "unsure_cause": { + "enum": ["margin", "unobserved", "unknown_attribute", "rule_requires_review"], + "description": "Only for unsure. margin: measured inside the error band, a person must judge. unobserved: the deciding area was not seen, more photos fix it. unknown_attribute: a fact the camera did not establish (for example whether a window opens). rule_requires_review: the policy sends this situation to a person (for example a cable routed over a door)." + }, + "reason": {"type": "string"}, + "measured_ft": {"type": ["number", "null"], "description": "The deciding measurement, for example the gap to the nearest gas meter. Null when nothing relevant was found or it was not observed."}, + "plus_minus_ft": {"type": ["number", "null"]}, + "threshold_ft": {"type": ["number", "null"]}, + "review_threshold_ft": {"type": "number", "description": "Present on checks with a review band (route_length): a value that doesn't clear this line (at_most: measured + error < review_threshold_ft) is at best unsure, even when it clears threshold_ft. Same convention as the scoring harness's review_threshold."}, + "comparison": {"enum": ["at_least", "at_most", null], "description": "at_least: pass needs measured - error > threshold. at_most: pass needs measured + error < threshold."}, + "subject": {"type": ["string", "null"], "description": "What the measurement is to, for example \"objects[2] gas_meter\"."}, + "rule": { + "type": "object", + "additionalProperties": false, + "required": ["key", "source", "placeholder"], + "properties": { + "key": {"type": "string", "description": "Parameter name in rules.yaml."}, + "source": {"type": "string", "description": "Citation for the value."}, + "placeholder": {"type": "boolean", "description": "True for a demo value with no public source."} + } + } + } + }, + "spot": { + "type": "object", + "additionalProperties": false, + "required": ["outcome", "wall_id", "segment", "span_ft", "width_ft", "depth_ft", "height_ft", "footprint", "center", "along", "outward", "meter_offset_ft", "route_length_ft"], + "properties": { + "outcome": {"$ref": "#/$defs/outcome"}, + "wall_id": {"type": "string"}, + "segment": {"type": "integer", "minimum": 0, "description": "Index of the straight baseline segment of `wall_id` the battery backs onto."}, + "span_ft": {"$ref": "#/$defs/span", "description": "Battery's stretch of wall in s, left edge first."}, + "width_ft": {"type": "number"}, + "depth_ft": {"type": "number"}, + "height_ft": {"type": "number"}, + "footprint": {"type": "array", "minItems": 4, "maxItems": 4, "items": {"$ref": "#/$defs/point2"}, "description": "Plan corners [x, z]: back-left, back-right, front-right, front-left."}, + "center": {"$ref": "#/$defs/point2", "description": "Plan centre of the footprint."}, + "along": {"$ref": "#/$defs/point2", "description": "Unit vector along the wall toward +s."}, + "outward": {"$ref": "#/$defs/point2", "description": "Unit vector from the wall out toward the battery's front."}, + "meter_offset_ft": { + "type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}, + "description": "Footprint centre minus the meter's plan position, [dx, dz] in the scene frame's axes. scene.json carries no ground height, so the app sets the height by resting the box on its detected ground plane. Attach AR content to the meter's anchor with this offset, rotated by the anchor's orientation if the anchor is not axis-aligned with the scene frame." + }, + "route_length_ft": {"type": ["number", "null"]} + } + }, + "route": { + "type": "object", + "additionalProperties": false, + "required": ["outcome", "length_ft", "plus_minus_ft", "height_ft", "polyline", "detours", "crossings"], + "properties": { + "outcome": {"$ref": "#/$defs/outcome"}, + "length_ft": {"type": "number", "description": "Length along the supported wall from the meter to the battery's near edge, including detours around wall objects."}, + "plus_minus_ft": {"type": "number"}, + "height_ft": {"type": "number", "description": "Height above ground the cable runs at, from the rules."}, + "polyline": {"type": "array", "items": {"$ref": "#/$defs/point2"}, "description": "Plan points from the meter along the wall to the battery."}, + "detours": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "extra_ft"], + "properties": {"subject": {"type": "string"}, "extra_ft": {"type": "number"}} + } + }, + "crossings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["subject", "span_ft", "effect"], + "properties": { + "subject": {"type": "string"}, + "span_ft": {"$ref": "#/$defs/span"}, + "effect": {"enum": ["fail", "review", "detour", "allow"]} + } + } + } + } + } + } +} diff --git a/server/schemas/scene.schema.json b/server/schemas/scene.schema.json new file mode 100644 index 000000000..695e5e4a9 --- /dev/null +++ b/server/schemas/scene.schema.json @@ -0,0 +1,232 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/SamGu-NRX/house-scanning/server/schemas/scene.schema.json", + "title": "House scan scene", + "description": "What the phone measured around the electric meter (the scene contract; server/README.md, \"Writing a scene\"). Every length is in feet. Plan coordinates [x, z] are the horizontal axes of one gravity-aligned scene frame (ARKit .gravity world alignment: +y up), so [x, z] ignores height. `s` is the distance along the unrolled wall chain from the meter, negative to the left as seen by someone outside facing the wall. Unknown fields are rejected so a misspelled field fails loudly instead of being ignored.", + "type": "object", + "additionalProperties": false, + "required": ["meter", "walls"], + "properties": { + "schema_version": { + "description": "Contract version. Absent means \"1.0\". Minor versions only add optional fields.", + "type": "string", + "pattern": "^1\\.[0-9]+$" + }, + "meter": { + "type": "object", + "additionalProperties": false, + "required": ["pos", "wall_id"], + "properties": { + "pos": {"$ref": "#/$defs/point3", "description": "Meter position in the scene frame, feet. Its [x, z] is projected onto `wall_id` to set s = 0."}, + "wall_id": {"type": "string", "minLength": 1}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft"} + } + }, + "walls": { + "description": "Wall chain in order from the left end to the right end, as seen from outside. Consecutive walls that share an endpoint meet at a corner; a gap between the end of one wall and the start of the next is a stretch with no wall, which no battery can back onto and no cable can cross. The first wall starts at the chain's left end.", + "type": "array", + "minItems": 1, + "maxItems": 50, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "baseline"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "baseline": { + "description": "The wall's line along the ground, [x, z] points ordered left to right as seen from outside. The outward direction of each segment is therefore its direction turned 90 degrees clockwise in the x-z plane viewed from above (+y). Each straight segment is where a battery can sit flush.", + "type": "array", + "minItems": 2, + "maxItems": 500, + "items": {"$ref": "#/$defs/point2"} + }, + "height_ft": {"type": "number", "exclusiveMinimum": 0}, + "source": {"enum": ["tap", "mesh", "plane"], "description": "How the wall's line was found: AR taps, the LiDAR mesh, or ARKit's detected planes. Sets the default error when plus_minus_ft is absent (errors.wall_ft, mesh_ft, plane_ft in rules.yaml). Absent means tap."}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error of this wall's line. Absent means the rules' default for the wall's source, growing with distance along the walls from the meter (errors.drift_per_ft)."} + } + } + }, + "objects": { + "type": "array", + "maxItems": 500, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["type", "wall_id", "span_ft", "source"], + "properties": { + "type": {"enum": ["window", "door", "garage_door", "ac", "gas_meter", "elec_box", "vent", "downspout", "pool", "battery"], "description": "battery: a battery already installed, which a new one keeps clearances.battery_ft from. Disconnects, sub-panels, EV chargers and solar equipment are elec_box."}, + "wall_id": {"type": "string", "minLength": 1}, + "span_ft": {"$ref": "#/$defs/span", "description": "Stretch of the wall the object covers, in s."}, + "bottom_ft": {"type": "number", "minimum": 0, "description": "Height of the object's lowest edge above the ground."}, + "top_ft": {"type": "number", "minimum": 0}, + "attrs": { + "type": "object", + "description": "Recognised attributes. A missing attribute is unknown, never false.", + "additionalProperties": false, + "properties": { + "operable": {"type": "boolean"}, + "well": {"type": "boolean"} + } + }, + "source": {"enum": ["vlm", "tap", "tape"], "description": "How the position was measured: a vision model, an AR tap, or a tape measure. Sets the default error."}, + "conf": {"type": "number", "minimum": 0, "maximum": 1, "description": "Recognition confidence. Not a distance error and never used as one."}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error. Absent means the rules' default for `source`; for tap and vlm it grows with distance along the walls from the meter (errors.drift_per_ft)."}, + "footprint": { + "description": "Optional ground outline of the object in plan, [x, z] points. Give it for anything that stands off the wall (a gas regulator, pipe run, AC unit, pool edge) so clearances are measured to every part. Without it the object is the stretch of wall line over `span_ft`.", + "type": "array", + "minItems": 1, + "maxItems": 500, + "items": {"$ref": "#/$defs/point2"} + } + } + } + }, + "ground": { + "type": "array", + "maxItems": 200, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["type", "polygon"], + "properties": { + "type": {"enum": ["drive", "concrete", "gravel", "lawn", "mulch", "deck"]}, + "polygon": {"type": "array", "minItems": 3, "maxItems": 500, "items": {"$ref": "#/$defs/point2"}}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Position error of the outline. Absent means the rules' tap default, growing with distance along the walls from the meter (errors.drift_per_ft)."} + } + } + }, + "overheads": { + "description": "Anything overhead (porch roof, eave, stairs, landing) along the wall, with the clear height beneath it.", + "type": "array", + "maxItems": 500, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["wall_id", "span_ft", "clearance_ft"], + "properties": { + "wall_id": {"type": "string", "minLength": 1}, + "span_ft": {"$ref": "#/$defs/span"}, + "clearance_ft": {"type": "number", "minimum": 0, "description": "Clear height from the ground to the underside."}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Absent means the rules' default for mesh measurements."} + } + } + }, + "facing": { + "description": "Gap from the wall straight out to the nearest fence, hedge or wall.", + "type": "array", + "maxItems": 500, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["wall_id", "span_ft", "depth_ft"], + "properties": { + "wall_id": {"type": "string", "minLength": 1}, + "span_ft": {"$ref": "#/$defs/span"}, + "depth_ft": {"type": "number", "minimum": 0}, + "plus_minus_ft": {"$ref": "#/$defs/plus_minus_ft", "description": "Absent means the rules' default for mesh measurements."} + } + } + }, + "coverage": { + "description": "What the capture actually saw. Anything not listed was not observed, and a check that depends on an unobserved area is UNSURE, never PASS. Absent means nothing is known to be observed.", + "type": "object", + "additionalProperties": false, + "properties": { + "ends": { + "type": "object", + "additionalProperties": false, + "properties": { + "left": {"$ref": "#/$defs/end"}, + "right": {"$ref": "#/$defs/end"} + } + }, + "observed": { + "type": "array", + "maxItems": 500, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["band", "span_ft"], + "properties": { + "band": { + "enum": ["wall", "ground", "overhead", "facing"], + "description": "wall: the wall face from the ground up to `out_ft` (absent: up to headroom height), for the battery's back, the cable route, and boxes, vents, gas and openings above the battery. ground: the ground in front of the wall out to `out_ft`. overhead: the space above the ground strip up to headroom height. facing: the gap straight out from the wall to whatever faces it." + }, + "span_ft": {"$ref": "#/$defs/span", "description": "Observed stretch in s. May extend past the chain's ends, continuing the end segments' lines. Past an end marked limit, observed ground covers both sides of that line."}, + "out_ft": {"type": "number", "minimum": 0, "description": "How far the view reached, as a distance it is sure of. ground (required): how far out from the wall the ground was seen. facing: how far out from the wall the space was seen clear, for example the homeowner's walked path; absent means the view reached whatever faces the wall, so anything there is in `facing`. overhead: how high above the ground the space was seen clear; absent means it was seen all the way up, so anything above is in `overheads`. wall: how high above the ground the wall face was seen; absent means up to headroom height (headroom.min_ft), enough for every check. Each check that reads the wall band credits an entry only where its out_ft exceeds the height the check needs (server/README.md, \"What settles each check\")."} + }, + "if": {"properties": {"band": {"const": "ground"}}}, + "then": {"required": ["out_ft"]} + } + } + } + }, + "keyframes": { + "type": "array", + "maxItems": 5000, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "pose", "intrinsics", "w", "h", "img"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "pose": { + "description": "Camera-to-world transform in the scene frame, 16 numbers in column-major order, translation in feet.", + "type": "array", "minItems": 16, "maxItems": 16, "items": {"type": "number"} + }, + "intrinsics": { + "description": "[fx, fy, cx, cy] in pixels of the unrotated (landscape, as the sensor reads it) image.", + "type": "array", "minItems": 4, "maxItems": 4, "items": {"type": "number"} + }, + "w": {"type": "integer", "minimum": 1}, + "h": {"type": "integer", "minimum": 1}, + "img": {"type": "string", "minLength": 1, "description": "File name of the JPEG inside the uploaded bundle."} + } + } + }, + "stills": { + "type": "object", + "description": "Close-up photo file names in the bundle, keyed by purpose (meter_close, meter_surround, panel_wall, panel_open).", + "additionalProperties": {"type": "string"} + }, + "gps": { + "type": "object", + "additionalProperties": false, + "required": ["lat", "lon"], + "properties": { + "lat": {"type": "number", "minimum": -90, "maximum": 90}, + "lon": {"type": "number", "minimum": -180, "maximum": 180} + } + }, + "heading": { + "type": "object", + "additionalProperties": false, + "required": ["deg"], + "properties": { + "deg": {"type": "number"}, + "accuracy_deg": {"type": "number", "minimum": 0} + } + } + }, + "$defs": { + "point2": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}}, + "point3": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number"}}, + "span": { + "description": "[start, end] in s, feet from the meter, start <= end.", + "type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"} + }, + "plus_minus_ft": {"type": "number", "minimum": 0, "description": "Measurement error in feet: the true value lies within this distance of the reported one."}, + "end": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { + "enum": ["limit", "unexplored"], + "description": "limit: the walk reached a real end, where there is no more wall a battery could use (the wall meets a fence, a neighbour's property or an impassable obstruction). unexplored: the wall continues (often round a corner) but was not walked. Absent means unexplored." + }, + "note": {"type": "string"} + } + } + } +} diff --git a/server/siteplan.py b/server/siteplan.py new file mode 100644 index 000000000..0e8153c05 --- /dev/null +++ b/server/siteplan.py @@ -0,0 +1,408 @@ +"""Draw a placement result as a top-down 2D site plan in SVG. + +The plan is drawn in the meter wall's frame: the wall runs left to right across the page with the +house above it and the yard below, whatever the scene frame's axes are. Colours: blue meter area, +purple cable, red battery spot and its clearance; gas is utility-locate yellow. +Ground nobody saw is hatched, as in the app's coverage fog. The deciding clearances are drawn as +dimension lines with their measured values, so the drawing shows why the spot works. + +No dependency beyond shapely: the SVG is built as text. Colours live in one {DEFS}") + out.append(f'') + + # Ground, then what nobody saw. + for g in scene.ground: + d = frame.path(g.polygon) + if d: + out.append(f'') + fog = frame.path(scene.unobserved_ground()) + if fog: + out.append(f'') + for o in scene.objects: + if o.type == "pool" and isinstance(o.geom, Polygon): + out.append(f'') + + # The meter's working space (blue) and the house side of each wall (hatched poché). + ws = r.meter_working_space + ws_poly = scene.band_polygon(-ws.width_ft.value / 2, ws.width_ft.value / 2, ws.depth_ft.value) + out.append(f'') + for p in walls: + out.append(f'') + + # Where on the wall a battery could stand, on the house side of the wall line. + width = r.battery.width_ft.value + for run in result.get("sweep", []): + if run["outcome"] == "fail": + continue + a, b = run["start_ft"][0], run["start_ft"][1] + width + line = scene.wall_line(a, b) + piece = scene.piece_at((a + b) / 2) + shifted = _offset(line, piece, -0.45) + out.append(f'') + + for p in walls: + out.append(f'') + for g in scene.gaps: + out.append(f'') + + # Wall objects: openings break the wall line; equipment sits on or off it. + gas_ring = r.clearances.gas_ft.value + for o in scene.objects: + name = OBJECT_NAMES[o.type] + if o.type in ("window", "door", "garage_door"): + line = scene.wall_line(*o.span) + out.append(f'') + piece = scene.piece_at(sum(o.span) / 2) + for off in (-0.25, 0.25): + out.append( + f'' + ) + _label(out, frame, scene.point_at(sum(o.span) / 2, -1.6), name, "small") + elif o.type == "gas_meter": + shape = o.geom.buffer(0.35) if not isinstance(o.geom, Polygon) else o.geom + out.append(f'') + out.append(f'') + _label(out, frame, _label_point(scene, o.geom, 1.2), name, "label") + elif o.type != "pool": + shape = o.geom.buffer(0.3) if not isinstance(o.geom, Polygon) else o.geom + out.append(f'') + _label(out, frame, _label_point(scene, o.geom, 1.2), name, "small") + + # Cable, battery and its clearance. + route = result.get("route") + if route and len(route["polyline"]) >= 2: + line = LineString(route["polyline"]) + out.append(f'') + mid = line.interpolate(0.5, normalized=True) + s_mid = scene.s_of((mid.x, mid.y)) + _label( + out, + frame, + scene.piece_at(s_mid).point(s_mid, -2.4), + f"Cable {short(route['length_ft'])}", + "cable-text", + ) + if spot: + fp = Polygon(spot["footprint"]) + ring = min(r.clearances.gas_ft.value, r.clearances.ac_ft.value) + out.append(f'') + # A nearest_considered spot is never drawn as a pass. + passed = result.get("spot") is not None and spot["outcome"] == "pass" + cls = "battery" if passed else "battery unsure" + out.append(f'') + _dimensions(out, frame, scene, fp, result) + + # Meter on top. + mx, my = frame.px(scene.meter_piece.point(0.0)) + out.append(f'') + _label(out, frame, scene.point_at(0.0, -1.6), "Meter", "label") + + _legend(out, frame, result) + out.append("") + return "\n".join(out) + + +def _offset(line: Geometry, piece: Piece, distance: float) -> Geometry: + return translate(line, piece.outward[0] * distance, piece.outward[1] * distance) + + +def _label_point(scene: Scene, geom: Geometry, out: float) -> tuple[float, float]: + """Things mounted on the wall are named on the house side, in the row with the openings, so + their names never collide with the dimension lines in the yard. Things out in the yard are + named just beyond themselves.""" + c = geom.centroid + s = scene.s_of((c.x, c.y)) + piece = scene.piece_at(s) + if geom.distance(scene.wall_line(s - 0.01, s + 0.01)) < 0.5: + return piece.point(s, -1.6) + _, v = piece.local((c.x, c.y)) + return piece.point(s, max(v, 0) + out) + + +def _label(out: list[str], frame: _Frame, at: tuple[float, float], text: str, cls: str) -> None: + x, y = frame.px(at) + out.append( + f'{_esc(text)}' + ) + + +def _dimensions( + out: list[str], frame: _Frame, scene: Scene, fp: Polygon, result: dict[str, Any] +) -> None: + """Dimension lines from the battery to the objects that decide its clearances.""" + subjects: dict[str, Geometry] = {o.label: o.geom for o in scene.objects} + subjects |= {f"ground[{g.index}] {g.type}": g.polygon for g in scene.ground} + rows = [] + for c in result.get("checks", []): + geom = subjects.get(c.get("subject") or "") + if geom is None or c["measured_ft"] is None or c["threshold_ft"] is None: + continue + margin = c["measured_ft"] - c["threshold_ft"] + rows.append((margin, c, geom)) + rows = [row for row in rows if row[0] <= DIMENSION_MARGIN_FT] + for _, c, geom in sorted(rows, key=lambda row: row[0])[:MAX_DIMENSIONS]: + a, b = nearest_points(fp, geom) + if a.distance(b) < 0.25: + continue + (x1, y1), (x2, y2) = frame.px((a.x, a.y)), frame.px((b.x, b.y)) + ang = math.atan2(y2 - y1, x2 - x1) + tx, ty = -math.sin(ang) * 5, math.cos(ang) * 5 + out.append(f'') + for x, y in ((x1, y1), (x2, y2)): + out.append( + f'' + ) + label = f"{short(c['measured_ft'])} (min {short(c['threshold_ft'])})" + lx, ly = (x1 + x2) / 2 + tx * 3.2, (y1 + y2) / 2 + ty * 3.2 + 4 + out.append( + f'{_esc(label)}' + ) + + +def _legend(out: list[str], frame: _Frame, result: dict[str, Any]) -> None: + y = frame.height - LEGEND_PX + 26 + x = PAD_PX + # Scale bar: 5 ft. + bar = 5 * frame.k + out.append( + f'' + ) + for t in (0, bar / 2, bar): + out.append( + f'' + ) + out.append(f'5 ft') + items = [ + ("battery", "Battery"), + ("cable-key", "Cable"), + ("meter-area", "Meter working space"), + ("gas", "Gas"), + ("fog", "Not seen"), + ] + cx = x + bar + 64 + for cls, text in items: + if cls == "cable-key": + out.append(f'') + else: + out.append(f'') + out.append(f'{_esc(text)}') + cx += 32 + 7.2 * len(text) + decision = {"pass": "Fits", "manual_review": "Needs review", "reject": "No spot"}.get( + result.get("decision", ""), "" + ) + out.append( + f'{_esc(decision)}' + ) + + +def main(argv: list[str]) -> int: + """python siteplan.py scene.json out.svg: solve a scene and write its site plan.""" + if len(argv) != 3: + print(main.__doc__, file=sys.stderr) + return 2 + loaded = load_rules() + raw = json.loads(Path(argv[1]).read_text()) + scene = parse_scene(raw, loaded.rules) + Path(argv[2]).write_text(render(scene, solve(scene, loaded), loaded)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/server/solver.py b/server/solver.py new file mode 100644 index 000000000..e1bc7b1a7 --- /dev/null +++ b/server/solver.py @@ -0,0 +1,2040 @@ +"""The placement solver: a pure function from a parsed scene and the rules to a result. + +It slides the battery footprint along every straight wall segment, runs every check at each start +position, and decides. Every check follows the strict rule (docs/00, Decisions: every check +answers PASS, FAIL or UNSURE): PASS only when the margin is larger than the error, FAIL only when +the value is past the threshold by more than the error, otherwise UNSURE. An area nobody observed +is never a pass. + +Start positions are a 2 in grid plus every position where some check can change its outcome (an +opening's clearance edge, a coverage boundary, a ground patch edge), plus the midpoints between +those. A feasible stretch narrower than the grid step is therefore still found (golden test 12). +""" + +import itertools +import math +import time +from collections.abc import Callable +from dataclasses import dataclass, field, replace +from functools import partial +from typing import Any + +import shapely +from shapely import Geometry, LineString, Point, Polygon, get_coordinates, unary_union + +from rules import LoadedRules, Rules, Value +from scene import ( + COVERAGE_TOLERANCE_FT, + EPS, + Measured, + Piece, + Scene, + SceneObject, + merge_intervals, + polygonal, + subtract_intervals, +) +from units import format_ft_in + +PASS, FAIL, UNSURE = "pass", "fail", "unsure" +_SEVERITY = {PASS: 0, UNSURE: 1, FAIL: 2} +# Areas, lengths and distances below this are floating point noise, not geometry. +_MEASURE_EPS = 1e-6 +SCHEMA_VERSION = "1.0" +# Operational bounds on one request: a realistic scan needs a few thousand positions and well +# under a second. Past these the input is refused rather than tying the server up. The budget is +# wall-clock on the hosted server, which ran about 4x slower than the development Mac (ETH3D scene: +# 1.6 s there against 0.4 s locally at 739fb6f), and sits far under Vercel's 300 s function limit. +MAX_STARTS = 50_000 +SOLVE_BUDGET_S = 8.0 +# Positions evaluated before the total time is projected; enough for a stable per-position cost. +PROJECT_AFTER = 256 + + +class SceneTooComplex(ValueError): + """The scene needs more positions checked, or more time, than one request may take.""" + + +@dataclass(frozen=True) +class View: + """A view that would settle part of a check: `band` over s from a to b, seen at least `out` + far (ground and facing: out from the wall; overhead: up from the ground). None for the wall + band, which has no depth.""" + + band: str + a: float + b: float + out: float | None = None + + +@dataclass +class Check: + id: str + label: str + outcome: str + reason: str + rule_key: str + rule: Value | None + rule_source: str = "" + rule_placeholder: bool = False + measured: float | None = None + plus_minus: float | None = None + threshold: float | None = None + review_threshold: float | None = None + comparison: str | None = None + subject: str | None = None + unsure_cause: str | None = None + missing: list[View] = field(default_factory=list) + # Computing the exact unseen stretch is slow, so it is deferred until a result reports it. + missing_later: Callable[[], list[View]] | None = None + # Other rule keys whose values or citation the check uses; if the private file set any of + # them, the citation is withheld. + cites: tuple[str, ...] = () + + def all_missing(self) -> list[View]: + return self.missing + (self.missing_later() if self.missing_later else []) + + def rule_keys(self) -> tuple[str, ...]: + return (self.rule_key, *self.cites) + + def to_json(self, private_keys: frozenset[str] = frozenset()) -> dict[str, Any]: + source = self.rule.source if self.rule else self.rule_source + if _is_private(self.rule_keys(), private_keys): + # The private file's citations stay on the server; answers say only where the value + # came from. + source = "Private rules" + out: dict[str, Any] = { + "id": self.id, + "label": self.label, + "outcome": self.outcome, + "reason": self.reason, + "measured_ft": _round(self.measured), + "plus_minus_ft": _round(self.plus_minus), + "threshold_ft": _round(self.threshold), + "comparison": self.comparison, + "subject": self.subject, + "rule": { + "key": self.rule_key, + "source": source, + "placeholder": self.rule.placeholder if self.rule else self.rule_placeholder, + }, + } + if self.review_threshold is not None: + out["review_threshold_ft"] = _round(self.review_threshold) + if self.outcome == UNSURE: + out["unsure_cause"] = self.unsure_cause or "margin" + return out + + +def _is_private(keys: tuple[str, ...], private_keys: frozenset[str]) -> bool: + """Whether the private file set any of these rules or anything under them.""" + return any( + k == p or p.startswith(f"{k}.") or k.startswith(f"{p}.") for k in keys for p in private_keys + ) + + +# A buffer's round corners are polygons inscribed in the true circle (16 segments a quarter +# turn), so they fall short of the radius by up to 0.12%. Scaling by 1/cos of half a segment's +# angle makes them circumscribe it: the region reaches at least as far as the exact distance +# test in Solver._covered. +_BUFFER_SEGMENTS = 16 +_CIRCUMSCRIBE = 1 / math.cos(math.pi / (4 * _BUFFER_SEGMENTS)) + + +# GEOS 3.13 can return an empty intersection for large polygons that share boundaries (an +# unseen area inside the coverable one gave 0 instead of 172 sq ft); snapping the overlay to a +# grid this fine keeps it exact to far below any measurement. +_OVERLAY_GRID = 1e-9 + + +def _meet(a: Geometry, b: Geometry) -> Geometry: + return shapely.intersection(a, b, grid_size=_OVERLAY_GRID) + + +def _overlap_depth(a: Geometry, b: Geometry) -> float: + """How deep two shapes overlap, as a lower bound on how far one must move to clear the other: + twice the radius of the largest circle inside their intersection (the circle and its + translate overlap until the move is at least its diameter). 0 when they only touch.""" + overlap = _meet(polygonal(a), polygonal(b)) + if overlap.area <= _MEASURE_EPS: + return 0.0 + return 2 * shapely.maximum_inscribed_circle(overlap, tolerance=1e-6).length + + +def _within(fp: Polygon, radius: float) -> Geometry: + """Every point within `radius` of the footprint, and slightly more at the corners.""" + return fp.buffer(radius * _CIRCUMSCRIBE, quad_segs=_BUFFER_SEGMENTS) if radius > 0 else fp + + +def _round(v: float | None) -> float | None: + return None if v is None else round(v, 6) + + +def _up(v: float) -> float: + """v rounded up to 6 decimals: a depth to show, never less than what is needed.""" + return math.ceil(v * 1e6) / 1e6 + + +def _above(v: float) -> float: + """The smallest 6-decimal value strictly above v: a view must reach past the rule's line, + since PASS needs the margin to exceed the error (here 0).""" + return math.floor(v * 1e6) / 1e6 + 1e-6 + + +def _outward(a: float, b: float) -> list[float]: + """A requested view's span, rounded to 6 decimals away from its middle: rounding an end + inward would leave a sliver that capturing exactly the listed span never covers.""" + return [math.floor(a * 1e6) / 1e6, math.ceil(b * 1e6) / 1e6] + + +def at_least(value: float, error: float, threshold: float) -> str: + """A clearance: PASS when value - error > threshold, FAIL when value + error < threshold.""" + if value - error - threshold > EPS: + return PASS + if threshold - (value + error) > EPS: + return FAIL + return UNSURE + + +def reach_outcome(length: float, error: float, confident: float, maximum: float) -> str: + """A route length: PASS when clearly under the confident reach, FAIL when clearly over the + maximum, UNSURE in between or on either line.""" + if (length - error) - maximum > EPS: + return FAIL + # Clear of both lines: the rules keep confident <= maximum, and this holds regardless. + if min(confident, maximum) - (length + error) > EPS: + return PASS + return UNSURE + + +def worst(outcomes: list[str]) -> str: + return max(outcomes, key=_SEVERITY.__getitem__, default=PASS) + + +def ft(v: float) -> str: + return format_ft_in(abs(v)) if v >= 0 else "-" + format_ft_in(abs(v)) + + +def _point(s: float) -> str: + """where(s) as the end of a stretch: "from 3 ft left of the meter to the meter".""" + return "the meter" if abs(s) < 1 / 24 else where(s) + + +def where(s: float) -> str: + if abs(s) < 1 / 24: + return "at the meter" + return f"{format_ft_in(abs(s))} {'left' if s < 0 else 'right'} of the meter" + + +@dataclass +class Route: + outcome: str + length: float + plus_minus: float + polyline: list[tuple[float, float]] + detours: list[dict[str, Any]] + crossings: list[dict[str, Any]] + + +@dataclass +class Candidate: + piece: Piece + s0: float + s1: float + footprint: Polygon + checks: list[Check] + route: Route + outcome: str + + def failing(self) -> list[str]: + return [c.id for c in self.checks if c.outcome == FAIL] + + def unsure(self) -> list[str]: + return [c.id for c in self.checks if c.outcome == UNSURE] + + +class Solver: + def __init__(self, scene: Scene, loaded: LoadedRules) -> None: + self.scene = scene + r: Rules = loaded.rules + self.r = r + self.W = r.battery.width_ft.value + self.D = r.battery.depth_ft.value + self.H = r.battery.height_ft.value + # The meter may stand off its wall's line (up to sweep.meter_to_wall_max_ft); the cable + # starts at the meter, so that distance is part of every route. + mx, mz = scene.meter_xz + wx, wz = scene.point_at(0.0) + self.meter_offset = math.hypot(mx - wx, mz - wz) + objs = scene.objects + self.gas = [o for o in objs if o.type == "gas_meter"] + self.ac = [o for o in objs if o.type == "ac"] + self.batteries = [o for o in objs if o.type == "battery"] + # A battery stands on the ground against the wall, so its check reads both bands. Without + # an existing battery it would only ask for what the gas check already needs (the same + # two bands, to the same height, with a radius no larger), so it is left out then. + self.battery_check = bool(self.batteries) or ( + r.clearances.battery_ft.value > r.clearances.gas_ft.value + ) + self.pool = [o for o in objs if o.type == "pool"] + self.openings = [o for o in objs if o.type in r.openings.types] + self.equipment = [o for o in objs if o.type in r.wall_equipment.types] + self.route_objects = [ + o + for o in objs + if r.route.crossing.get(o.type, "allow") != "allow" # type: ignore[call-overload] + ] + self.good = [g for g in scene.ground if g.type in r.ground.allowed] + self.bad = [g for g in scene.ground if g.type not in r.ground.allowed] + self.drives = [g for g in scene.ground if g.type in r.ground.drivable] + self.unobserved_ground = scene.unobserved_ground() + # How high up the wall each check needs the face seen, from rules.yaml: the battery's + # back to its top, the cable at its run height, and the band's top (headroom height) for + # anything that can hang above the battery: boxes, vents, gas and openings (up to the + # height above which a window no longer counts, when the rules set one). + above = r.headroom.min_ft.value + exempt = r.openings.exempt_bottom_above_ft + self.wall_height = { + "backing": r.battery.height_ft.value, + "route": r.route.height_ft.value, + "above": above, + "openings": above if exempt is None else min(above, exempt), + } + ws = r.meter_working_space + self.ws_span = (-ws.width_ft.value / 2, ws.width_ft.value / 2) + self.ws_poly = scene.band_polygon(self.ws_span[0], self.ws_span[1], ws.depth_ft.value) + self._good_union = unary_union([g.polygon for g in self.good]).buffer(1e-7) + recorded = unary_union([g.polygon for g in scene.ground]) + outdoor = scene.outdoor_band(scene.reach_ft) + # Outdoor ground no patch describes; opened to drop floating point slivers. + self._unclassified = outdoor.difference(recorded).buffer(-1e-6).buffer(1e-6) + self._buffers: dict[tuple[int, float], Geometry] = {} + self._ground_error = max((g.plus_minus for g in scene.ground), default=0.0) + + # --- geometry helpers ---------------------------------------------------------------------- + + def footprint(self, piece: Piece, s0: float) -> Polygon: + return piece.rect(s0, s0 + self.W, 0.0, self.D) + + def _buffered(self, geom: Geometry, distance: float) -> Geometry: + """geom.buffer(distance), computed once per solve: ground patches and the unclassified + area only depend on the wall error, not on the candidate.""" + key = (id(geom), distance) + if key not in self._buffers: + self._buffers[key] = geom.buffer(distance) + return self._buffers[key] + + def _covered(self, fp: Polygon, unobserved: Geometry, radius: float) -> bool: + """True when nothing unobserved lies strictly within `radius` of the footprint: an unseen + area that only touches that distance (an exact shared edge) does not count. An exact + distance, rather than intersecting a buffered footprint, is both cheaper (this is most + of the solve time) and free of the buffer's polygonal approximation of a circle.""" + if unobserved.is_empty: + return True + if radius > _MEASURE_EPS: + return fp.distance(unobserved) >= radius - _MEASURE_EPS + # At radius 0 (a wall with no error) a distance of 0 is either overlap or touching; only + # overlap, by area or by length along a line, leaves part of the footprint unseen. + overlap = unobserved.intersection(fp) + for part in getattr(overlap, "geoms", [overlap]): + if part.geom_type.endswith("Polygon") and part.area > _MEASURE_EPS: + return False + if part.geom_type.endswith("LineString") and part.length > _MEASURE_EPS: + return False + return True + + # --- checks -------------------------------------------------------------------------------- + + def check_backing(self, piece: Piece, s0: float, s1: float) -> Check: + c = Check( + "wall_backing", + "Flush against one straight wall", + PASS, + "", + "battery.width_ft", + self.r.battery.width_ft, + ) + if piece.kind != "wall" or s0 < piece.s0 - EPS or s1 > piece.s1 + EPS: + c.outcome = FAIL + c.reason = ( + "The footprint runs past the end of its straight wall segment (a corner, the end " + "of the wall or a stretch with no wall), so the battery can't sit flush." + ) + return c + # The battery sits within the wall's error of [s0, s1]; if that could put an edge past + # the segment's end, it may not be flush. + e = piece.plus_minus + if s0 - e < piece.s0 - EPS or s1 + e > piece.s1 + EPS: + c.outcome, c.unsure_cause = UNSURE, "margin" + c.reason = ( + "The footprint ends within the wall's measurement error of its straight segment's " + "end, so the battery may not sit flush." + ) + return c + up_to = self.wall_height["backing"] + # A declared wall height, taken as given (as out_ft heights are), against the battery's. + # The battery sits within the wall's error of [s0, s1], so a lower wall surely behind it + # fails, and one that only might be (within the error) leaves it UNSURE. + e = piece.plus_minus + sure = self.scene.lowest_wall(s0 + e, s1 - e) if s1 - s0 > 2 * e else None + maybe = self.scene.lowest_wall(s0 - e, s1 + e) + for height, surely in ((sure, True), (maybe, False)): + if height is None or at_least(height, 0.0, self.H) == PASS: + continue + fails = surely and at_least(height, 0.0, self.H) == FAIL + c.outcome = FAIL if fails else UNSURE + c.measured, c.plus_minus, c.threshold = height, 0.0, self.H + c.comparison = "at_least" + if not fails: + c.unsure_cause = "margin" + c.reason = ( + f"The wall {'behind' if surely else 'within error of'} the battery is " + f"{ft(height)} tall, not taller than the battery's {ft(self.H)}." + ) + return c + # ...and the wall behind every such position must have been seen. + missing = self.scene.missing("wall", s0 - e, s1 + e, up_to) + if missing: + c.outcome, c.unsure_cause = UNSURE, "unobserved" + c.missing = [View("wall", a, b, _above(up_to)) for a, b in missing] + c.reason = "Part of the wall behind the battery was not seen." + return c + c.reason = "The whole footprint backs onto one straight, observed wall segment." + return c + + def check_ground(self, piece: Piece, fp: Polygon) -> Check: + c = Check( + "ground_surface", + "Ground under the battery", + PASS, + "", + "ground.allowed", + None, + rule_source=self.r.ground.source, + rule_placeholder=self.r.ground.placeholder, + # The citation is ground.source, shared by the whole ground group. + cites=("ground",), + ) + ew = piece.plus_minus + # "Clearly on a disallowed surface" erodes the patch by the error, rounded up to 0.1 ft + # so the eroded patches are reused across candidates (at most 1.2 in more conservative). + erosion = math.ceil(round(ew, 9) * 10) / 10 + for g in self.bad: + core = self._buffered(g.polygon, -(g.plus_minus + erosion)) + if not core.is_empty and fp.intersection(core).area > _MEASURE_EPS: + c.outcome, c.subject = FAIL, f"ground[{g.index}] {g.type}" + c.reason = f"The footprint stands on {g.type}, which is not an allowed surface." + return c + if not self._covered(fp, self.unobserved_ground, ew): + c.outcome, c.unsure_cause = UNSURE, "unobserved" + c.missing_later = partial(self._missing, "ground", fp, ew) + c.reason = "The ground under the footprint was not seen." + return c + # A patch edge's error matters only where an allowed surface meets a disallowed or + # unrecorded one; an edge that runs along the house wall is not a boundary at all. + # Containment is inclusive: touching a disallowed patch along an exact edge is not + # standing on it (golden test 01). + on_good = self._good_union.covers(fp) + near_bad = any(fp.distance(g.polygon) < g.plus_minus + ew - _MEASURE_EPS for g in self.bad) + near_unknown = ( + not self._unclassified.is_empty + and fp.distance(self._unclassified) < self._ground_error + ew - _MEASURE_EPS + ) + if on_good and not near_bad and not near_unknown: + c.reason = "The whole footprint stands on an allowed surface." + return c + c.outcome = UNSURE + if not on_good and not near_bad: + c.unsure_cause = "unknown_attribute" + c.reason = "The ground under the footprint was seen but its surface was not recorded." + else: + c.unsure_cause = "margin" + c.reason = "The footprint sits within measurement error of a surface boundary." + return c + + def _unobserved(self, band: str, up_to: float | None) -> Geometry: + return self.unobserved_ground if band == "ground" else self.scene.unobserved_wall(up_to) + + def _missing( + self, band: str, fp: Polygon, radius: float, up_to: float | None = None + ) -> list[View]: + """The views that would show what nobody saw within `radius` of the footprint. Only what + a view can settle is requested; the rest lies past an unexplored end, which the + past_end request covers. For the wall band, the area past an unexplored end that lies in + front of the scanned walls is settled by a view of the ground there, so it is asked for + as ground.""" + within = _within(fp, radius) + if band == "ground": + parts = [("ground", self.unobserved_ground)] + else: + parts = [ + ("wall", self.scene.unobserved_wall_lines(up_to)), + ("ground", self.scene.unexplored_area()), + ] + out = [] + for view, unseen in parts: + region = _meet(unseen, within) + if view == "ground": + # Clipping can leave a line where edges touch (no ground to show), which the next + # overlay refuses beside an area (the real sim scene after the snapped overlays). + region = polygonal(region) + region = _meet(region, self.scene.coverable(view)) + if view == "ground": + # Behind a gap only a view pointed into the passage shows the ground (a span + # within the gap, Scene.view_polygon), so that part is asked for on its own. + for gap, passage in zip( + self.scene.gaps, self.scene.passages(self.scene.reach_ft), strict=True + ): + behind = polygonal(_meet(region, passage)) + if behind.area > 1e-12: + a, b, depth = self.scene.view_to_cover(behind, gap.s0, gap.s1) + out.append(View("ground", a, b, _up(depth))) + region = shapely.difference(region, passage, grid_size=_OVERLAY_GRID) + # Where that meets an unexplored end exactly, a sliver with no length along the wall + # is left; a request for it could never be settled. + extents = [] + for part in getattr(region, "geoms", [region]): + e = self.scene.s_extent(part) + if not e: + continue + if e[1] - e[0] >= COVERAGE_TOLERANCE_FT: + extents.append(e) + elif part.area > COVERAGE_TOLERANCE_FT**2: + # A real area whose points all map to one s: the wedge in front of a convex + # corner. A view spanning the corner covers it. + mid = (e[0] + e[1]) / 2 + extents.append((mid - COVERAGE_TOLERANCE_FT, mid + COVERAGE_TOLERANCE_FT)) + if extents: + a, b = min(a for a, _ in extents), max(b for _, b in extents) + if view == "ground": + a, b, depth = self.scene.view_to_cover(region, a, b) + depth = _up(depth) + else: + depth = None if up_to is None else _above(up_to) + out.append(View(view, a, b, depth)) + return out + + def _missing_bands( + self, bands: list[str], fp: Polygon, radius: float, up_to: float | None + ) -> list[View]: + """Every unseen band, so one recapture settles the check (a clearance can need both the + ground and the wall).""" + return [m for band in bands for m in self._missing(band, fp, radius, up_to)] + + def check_clearance( + self, + check_id: str, + label: str, + rule_key: str, + rule: Value, + piece: Piece, + fp: Polygon, + items: list[tuple[str, Geometry, float, bool | None, bool]], + band: str, + noun: str, + wall_height: float | None = None, + along_err: float | None = None, + unknown_reason: str | None = None, + ) -> Check: + """Minimum plan distance from the footprint to each item. `items` holds (label, geometry, + error, counts, in plan) where counts None means an unknown attribute decides whether it + applies, and in plan says whether the item was placed in plan (the battery moves against + it by its full position error) or along the walls (by its error along them). + `wall_height` is how high up the wall the face must have been seen, for a check that + reads the wall band.""" + t = rule.value + c = Check(check_id, label, PASS, "", rule_key, rule, threshold=t, comparison="at_least") + if along_err is None: + along_err = piece.plus_minus + worst_key: tuple[int, float] | None = None + for name, geom, err, counts, in_plan in items: + # Nothing to measure to (no ground left unrecorded): its distance would be NaN. + if counts is False or geom.is_empty: + continue + d = fp.distance(geom) + e = err + (piece.plus_minus if in_plan else along_err + self._chain_to(piece, geom)) + outcome = at_least(d, e, t) + cause = "margin" + if counts is None and outcome != PASS: + outcome, cause = UNSURE, "unknown_attribute" + key = (_SEVERITY[outcome], -(d - e)) + if worst_key is None or key > worst_key: + worst_key = key + c.outcome, c.measured, c.plus_minus, c.subject = outcome, d, e, name + c.unsure_cause = cause if outcome == UNSURE else None + if c.outcome == FAIL: + c.reason = ( + f"{c.subject} is {ft(c.measured or 0)} (± {ft(c.plus_minus or 0)}) from the " + f"battery; the rule needs more than {ft(t)}." + ) + return c + # The footprint itself is only placed to within the wall's error, so the area that must + # have been seen reaches that much further. + radius = t + piece.plus_minus + bands = ["ground", "wall"] if band == "ground+wall" else [band] + unseen = [ + b for b in bands if not self._covered(fp, self._unobserved(b, wall_height), radius) + ] + missing_later = ( + partial(self._missing_bands, unseen, fp, radius, wall_height) if unseen else None + ) + if c.outcome == UNSURE: + if c.unsure_cause == "unknown_attribute": + c.reason = unknown_reason or ( + f"{c.subject} is within {ft(t)} of the battery, and whether the rule applies " + "to it (for example whether a window opens) was not recorded." + ) + else: + c.reason = ( + f"{c.subject} is {ft(c.measured or 0)} (± {ft(c.plus_minus or 0)}) from the " + f"battery against a {ft(t)} rule: too close to call." + ) + c.missing_later = missing_later + return c + if missing_later: + c.outcome, c.unsure_cause = UNSURE, "unobserved" + c.missing_later = missing_later + c.reason = ( + f"Not everything within {ft(t)} of the battery was seen, so a {noun} could " + "hide there." + ) + return c + if c.measured is None: + c.reason = f"No {noun} within {ft(t)} of the battery, and that whole area was seen." + else: + c.reason = ( + f"Nearest {noun} is {ft(c.measured)} (± {ft(c.plus_minus or 0)}) away, clear of " + f"the {ft(t)} rule, and the area around the battery was seen." + ) + return c + + def _opening_counts(self, o: SceneObject) -> bool | None: + cfg = self.r.openings + if o.type != "window" or o.well is True: + return True + exempt: bool | None = False + if cfg.exempt_fixed_windows: + exempt = True if o.operable is False else (None if o.operable is None else False) + if cfg.exempt_bottom_above_ft is not None and exempt is not True: + if o.bottom is None: + exempt = None + elif o.bottom > cfg.exempt_bottom_above_ft: + exempt = True + if exempt is True and o.well is None: + return None + return None if exempt is None else not exempt + + def check_along_wall(self, along_err: float, s0: float, s1: float) -> Check: + """Wall-mounted boxes and vents directly above the battery, measured along the wall, so + against the battery's error along the walls (_errors).""" + rule = self.r.clearances.wall_equipment_ft + t = rule.value + c = Check( + "wall_equipment_above", + "No box or vent above the battery", + PASS, + "", + "clearances.wall_equipment_ft", + rule, + threshold=t, + comparison="at_least", + ) + worst_key: tuple[int, float] | None = None + for o in self.equipment: + gap = max(o.span[0] - s1, s0 - o.span[1]) + # Both ends of the gap carry error: the box's own, and the battery's position, which + # is known only to the wall's error. + e = o.plus_minus + along_err + outcome = at_least(gap, e, t) + key = (_SEVERITY[outcome], -(gap - e)) + if worst_key is None or key > worst_key: + worst_key = key + c.outcome, c.measured, c.plus_minus, c.subject = outcome, gap, e, o.label + if c.outcome == FAIL: + c.reason = f"{c.subject} is on the wall directly above the battery." + return c + up_to = self.wall_height["above"] + # Wherever the battery may stand along the wall (the strict property's case: a meter + # move slid it 0.011 ft onto unseen wall). + missing = self.scene.missing("wall", s0 - t - along_err, s1 + t + along_err, up_to) + if c.outcome == UNSURE: + c.unsure_cause = "margin" + c.reason = f"{c.subject} ends within measurement error of the battery's edge." + elif missing: + c.outcome, c.unsure_cause = UNSURE, "unobserved" + c.reason = "The wall above the battery was not fully seen." + else: + c.reason = "Nothing is mounted on the wall above the battery." + if missing and c.outcome == UNSURE: + c.missing = [View("wall", a, b, _above(up_to)) for a, b in missing] + return c + + def check_meter_space(self, piece: Piece, fp: Polygon, s0: float, s1: float) -> Check: + ws = self.r.meter_working_space + c = Check( + "meter_working_space", + "Clear of the meter's working space", + PASS, + "", + "meter_working_space.width_ft", + ws.width_ft, + threshold=0.0, + comparison="at_least", + subject="meter", + ) + d = fp.distance(self.ws_poly) + if d <= EPS and piece.s0 - EPS <= 0.0 <= piece.s1 + EPS: + # On the meter's own segment the battery can only slide along the wall, so the + # overlap along s is how far it must move: the exact measure there. + d = min(0.0, max(self.ws_span[0] - s1, s0 - self.ws_span[1])) + elif d <= EPS: + # From another wall s says nothing about the overlap (it was measured as 0, a tie that + # never failed); its depth does. + d = -_overlap_depth(fp, self.ws_poly) + # The working space is drawn in front of the wall under it, so that wall's error counts + # as well as the meter's and the battery's: from another wall the battery doesn't move + # with it. + lo, hi = self.ws_span + e = self.scene.meter_plus_minus + piece.plus_minus + self._walls_between(piece, lo, hi) + c.measured, c.plus_minus = d, e + c.outcome = at_least(d, e, 0.0) + box = f"{ft(ws.width_ft.value)} wide by {ft(ws.depth_ft.value)} deep" + if c.outcome == FAIL: + c.reason = f"The battery would stand in the {box} working space in front of the meter." + elif c.outcome == UNSURE: + c.unsure_cause = "margin" + c.reason = ( + f"The battery is within measurement error of the meter's {box} working space." + ) + else: + c.reason = f"The battery is {ft(d)} clear of the meter's {box} working space." + return c + + def check_measured( + self, + check_id: str, + label: str, + band: str, + entries: list[Measured], + rule_key: str, + rule: Value, + s0: float, + s1: float, + subtract: float, + noun: str, + wall_error: float, + ) -> Check: + """Facing gap or headroom: the smallest measurement over the battery's stretch of wall.""" + t = rule.value + c = Check(check_id, label, PASS, "", rule_key, rule, threshold=t, comparison="at_least") + # The battery may sit up to the wall's error either side of [s0, s1], so the space in + # front of or above every such position must have been seen. + lo, hi = s0 - wall_error, s1 + wall_error + missing = self.scene.missing(band, lo, hi) + worst_key: tuple[int, float] | None = None + # The lowest value surely under the battery, and whether the deciding entry only might be. + surely: tuple[float, float] | None = None + only_maybe = False + for m in entries: + # plus_minus is the error of the measured height or depth; where the stretch sits + # along the wall is known to the wall's error. A measurement that lies under the + # battery wherever that error puts it counts in full; one that only might (it + # reaches the battery's edge within the error) can't give a clean pass, but isn't a + # clear failure either. With exact geometry, touching end to end is not overlap. + a, b = m.span + possible = a - wall_error < s1 - EPS and b + wall_error > s0 + EPS + if not possible: + continue + definite = a + wall_error < s1 - EPS and b - wall_error > s0 + EPS + value = m.value - subtract + if definite and (surely is None or value < surely[0]): + surely = (value, m.plus_minus) + outcome = at_least(value, m.plus_minus, t) + maybe = not definite and outcome == FAIL + if maybe: + outcome = UNSURE + key = (_SEVERITY[outcome], -(value - m.plus_minus)) + if worst_key is None or key > worst_key: + worst_key, only_maybe = key, maybe + c.outcome, c.measured, c.plus_minus = outcome, value, m.plus_minus + c.subject = f"{'overheads' if band == 'overhead' else 'facing'}[{m.index}]" + val = f"{ft(c.measured or 0)} (± {ft(c.plus_minus or 0)})" + if c.outcome == FAIL: + c.reason = f"The {noun} is {val}; the rule needs more than {ft(t)}." + return c + if c.outcome == UNSURE and only_maybe: + self._report_range(c, surely, wall_error, noun) + elif c.outcome == UNSURE: + c.unsure_cause = "margin" + c.reason = f"The {noun} is {val} against a {ft(t)} rule: too close to call." + elif missing: + c.outcome, c.unsure_cause = UNSURE, "unobserved" + c.reason = f"The {noun} over the battery's stretch of wall was not measured everywhere." + # The request must settle the check in one round, so it also names the stretches + # that were seen, but not far enough where no measurement covers them. + missing = merge_intervals( + missing + self._too_shallow(band, entries, lo, hi, t + subtract) + ) + elif (seen := self._seen_clear(band, entries, lo, hi)) <= t + subtract: + # A view (a walked path, a tilt-up frame) proves the space clear only as far as it + # reached; where nothing was measured, that is all that is known. + c.outcome, c.unsure_cause = UNSURE, "unobserved" + c.reason = ( + f"The {noun} was seen clear only to {ft(seen - subtract)} over part of the " + f"battery's stretch of wall; the rule needs more than {ft(t)}." + ) + missing = [(lo, hi)] + elif c.measured is None: + c.reason = f"Nothing limits the {noun} over the battery's stretch of wall." + else: + c.reason = f"The {noun} is {val}, clear of the {ft(t)} rule." + if missing and c.outcome == UNSURE: + # Seen clear further than the rule needs, or with nothing in the way, settles it. + c.missing = [View(band, a, b, _above(t + subtract)) for a, b in missing] + return c + + def _too_shallow( + self, band: str, entries: list[Measured], s0: float, s1: float, need: float + ) -> list[tuple[float, float]]: + """Stretches of [s0, s1] no measurement covers that were seen, but clear only to `need` + or less.""" + measured = merge_intervals([m.span for m in entries]) + free = subtract_intervals((s0, s1), measured) + seen = [ + part + for a, b in free + for part in subtract_intervals((a, b), self.scene.missing(band, a, b)) + ] + return [(a, b) for a, b in seen if self.scene.seen_to(band, a, b) <= need] + + def _seen_clear(self, band: str, entries: list[Measured], s0: float, s1: float) -> float: + """How far clear the band was seen over the parts of [s0, s1] no measurement covers: + infinite where a view saw it all (no `out_ft`), and nothing needed where measurements + cover the whole stretch.""" + measured = merge_intervals([m.span for m in entries]) + free = subtract_intervals((s0, s1), measured) + return min((self.scene.seen_to(band, a, b) for a, b in free), default=math.inf) + + @staticmethod + def _report_range( + c: Check, surely: tuple[float, float] | None, wall_error: float, noun: str + ) -> None: + """The deciding entry fails the rule but only might lie over the battery: its end is + within the wall's position error of the battery's edge. The value is then somewhere + between that entry's and the lowest one surely over the battery, and is reported as + that range (middle ± half its width plus the larger measurement error), so the numbers + give UNSURE under the C5 rule as the check did.""" + low, low_err = c.measured or 0.0, c.plus_minus or 0.0 + c.unsure_cause = "margin" + maybe = ( + f"{c.subject} ({ft(low)}) may or may not be over the battery: its end is within the " + f"wall's ± {ft(wall_error)} of the battery's edge" + ) + if surely is None: + # Nothing else surely lies over the battery, so nothing bounds the value from above. + c.measured = c.plus_minus = None + c.reason = f"{maybe}, and nothing else limits the {noun} there." + return + high, high_err = surely + c.measured = (low + high) / 2 + c.plus_minus = (high - low) / 2 + max(low_err, high_err) + c.reason = ( + f"{maybe}. Elsewhere over the battery the {noun} is {ft(high)}, so it is between " + f"{ft(low)} and {ft(high)} against a {ft(c.threshold or 0)} rule: too close to call." + ) + + def route_for(self, piece: Piece, s0: float, s1: float) -> tuple[Route, Check, Check]: + r = self.r.route + near = s0 if s0 > 0 else (s1 if s1 < 0 else 0.0) + lo, hi = min(0.0, near), max(0.0, near) + crossings: list[dict[str, Any]] = [] + detours: list[dict[str, Any]] = [] + effects: list[str] = [] + unknown: list[str] = [] + small_gaps: list[str] = [] + for gap in self.scene.gaps: + if min(hi, gap.s1) - max(lo, gap.s0) > EPS: + # A gap no longer than the two walls' errors may not be a gap at all. + gap_err = gap.error_at(max(abs(gap.s0), abs(gap.s1))) + clear = (gap.s1 - gap.s0) - gap_err > EPS + crossings.append( + { + "subject": "stretch with no wall", + "span_ft": [gap.s0, gap.s1], + "effect": "fail" if clear else "review", + } + ) + if clear: + effects.append("fail") + else: + small_gaps.append(f"a {ft(gap.s1 - gap.s0)} gap between walls") + h = r.height_ft.value + path_line = self.scene.wall_line(lo, hi) if hi - lo > EPS else None + # The run's length is known only to the battery's wall's error, that of every other wall + # segment the cable runs along (each one's ends, its corners, may lie anywhere within + # its error; summed, which may overstate but never understates), and the meter's, added + # one-sidedly below (_meter_on_route). + e = piece.plus_minus + self._walls_between(piece, lo, hi) + # The battery's end of the route slides along the walls when the meter or a corner moves + # (_slide); spans in s near it are judged with that too. + slide = self._slide(piece, s0) + maybe_blockers: list[str] = [] + for o in self.route_objects: + if path_line is None: + continue + # The object's ends carry its error and the route's ends the meter's, the wall's and + # the battery's slide, so a crossing is definite only when the overlap survives all + # of them, and possible while any overlap is within them. + tol = o.plus_minus + self.scene.meter_plus_minus + piece.plus_minus + slide + overlap = min(hi, o.span[1]) - max(lo, o.span[0]) + if overlap + tol <= EPS: + continue + definite = overlap - tol > EPS + effect = r.crossing[o.type] # type: ignore[index] + # Something the cable can go round or behind is only in its way when it touches the + # wall the cable runs along; a door or garage blocks it wherever it is drawn. + standoff = o.geom.distance(path_line) - o.plus_minus - piece.plus_minus + if effect in ("detour", "allow") and standoff > _MEASURE_EPS: + continue + if effect == "fail" and not definite: + # A door that may or may not reach the route: neither clear nor blocking. + maybe_blockers.append(o.label) + crossings.append({"subject": o.label, "span_ft": list(o.span), "effect": "review"}) + continue + crossings.append({"subject": o.label, "span_ft": list(o.span), "effect": effect}) + effects.append(effect) + if effect == "detour": + bottom = o.bottom if o.bottom is not None else 0.0 + if o.top is None: + unknown.append(o.label) + continue + # Heights carry the object's error; a detour that may or may not be needed, or + # whose size is uncertain, widens the route's error by the round trip. + if bottom - o.plus_minus <= h + EPS and o.top + o.plus_minus >= h - EPS: + e += 2 * o.plus_minus + if bottom <= h + EPS and o.top >= h - EPS: + options = [2 * (o.top - h)] + if bottom > EPS: + options.append(2 * (h - bottom)) + extra = min(options) + if extra > EPS: + detours.append({"subject": o.label, "extra_ft": extra}) + if not definite: + # A detour that may not be needed: counted, and its size added to + # the error so the run can still come out without it. + e += extra + corners = sum( + 1 + for a, b in zip(self.scene.pieces, self.scene.pieces[1:], strict=False) + if a.kind == "wall" and b.kind == "wall" and lo + EPS < a.s1 < hi - EPS + ) + length = ( + self.meter_offset + + (hi - lo) + + corners * r.corner_allowance_ft.value + + sum(d["extra_ft"] for d in detours) + ) + route_height = self.wall_height["route"] + # Each end of the route may lie within its own error: the meter's at the meter, the + # wall's at the battery. Unseen wall there could hold a blocker, so it must be seen. + meter_end = self.scene.meter_plus_minus + near_end = piece.plus_minus + slide + lo_err, hi_err = (meter_end, near_end) if lo >= -EPS else (near_end, meter_end) + missing = self.scene.missing( + "wall", + max(lo - lo_err, self.scene.s_min), + min(hi + hi_err, self.scene.s_max), + route_height, + ) + + path = Check( + "route_path", + "Cable route along the wall", + PASS, + "", + "route.crossing", + None, + rule_source="Demo rule: the cable can't cross a door, a garage or a stretch with " + "no wall", + ) + if "fail" in effects: + path.outcome = FAIL + blockers = [x["subject"] for x in crossings if x["effect"] == "fail"] + path.subject = blockers[0] + path.reason = f"The cable would have to cross {', '.join(blockers)}." + elif maybe_blockers: + path.outcome, path.unsure_cause = UNSURE, "margin" + path.subject = maybe_blockers[0] + path.reason = ( + f"{path.subject} ends within measurement error of the cable's route, so it may be " + "in the way." + ) + elif "review" in effects: + path.outcome, path.unsure_cause = UNSURE, "rule_requires_review" + path.subject = next(x["subject"] for x in crossings if x["effect"] == "review") + path.reason = ( + f"The cable would route past {path.subject}, which the policy sends to a person." + ) + elif small_gaps: + path.outcome, path.unsure_cause = UNSURE, "margin" + path.subject = "stretch with no wall" + path.reason = ( + f"The cable would cross {small_gaps[0]}, no longer than the walls' own " + "measurement error: it may be one continuous wall." + ) + elif unknown: + path.outcome, path.unsure_cause = UNSURE, "unknown_attribute" + path.subject = unknown[0] + path.reason = ( + f"The height of {unknown[0]} was not recorded, so the detour around it is unknown." + ) + elif missing: + path.outcome, path.unsure_cause = UNSURE, "unobserved" + path.missing = [View("wall", a, b, _above(route_height)) for a, b in missing] + path.reason = "Part of the wall the cable would run along was not seen." + else: + path.reason = "The cable runs along continuous, observed wall with nothing blocking it." + + # The meter's error changes the run one-sidedly (_meter_on_route): the check judges the + # range the run can truly lie in, reported as its middle +/- half its width. + up, down = self._meter_on_route(piece) + shortest, longest = length - e - down, length + e + up + e += up + measured, spread = (shortest + longest) / 2, (longest - shortest) / 2 + outcome = reach_outcome(measured, spread, r.confident_reach_ft.value, r.max_ft.value) + # threshold_ft is the maximum, the line past which the run fails (at_most). A run past + # the confident reach but under the maximum is UNSURE, so the cited rule names both lines + # and is a placeholder if either is. + cr = r.confident_reach_ft + rule = Value( + value=r.max_ft.value, + source=f"{r.max_ft.source}. Review past {ft(cr.value)}: {cr.source}", + placeholder=r.max_ft.placeholder or cr.placeholder, + ) + reach = Check( + "route_length", + "Cable run length", + outcome, + "", + "route.max_ft", + rule, + measured=measured, + plus_minus=spread, + threshold=r.max_ft.value, + review_threshold=cr.value, + comparison="at_most", + cites=("route.confident_reach_ft",), + ) + run = ( + f"{ft(length)} (± {ft(e)})" + if abs(measured - length) <= EPS + else f"between {ft(shortest)} and {ft(longest)}" + ) + confident = cr.value + if reach.outcome == FAIL: + reach.reason = f"The cable run is {run}, over the {ft(r.max_ft.value)} maximum." + elif reach.outcome == UNSURE: + near_a_line = any( + abs(measured - line) <= spread + EPS for line in (confident, r.max_ft.value) + ) + if near_a_line: + reach.unsure_cause = "margin" + reach.reason = ( + f"The cable run is {run}, within its error of the {ft(confident)} confident " + f"reach or the {ft(r.max_ft.value)} maximum: too close to call." + ) + else: + reach.unsure_cause = "rule_requires_review" + reach.reason = ( + f"The cable run is {run}: past the {ft(confident)} confident reach, so the " + f"policy sends it to a person, though under the {ft(r.max_ft.value)} maximum." + ) + else: + reach.reason = f"The cable run is {run}, within the {ft(confident)} confident reach." + route = Route( + outcome=worst([path.outcome, reach.outcome]), + length=length, + plus_minus=e, + # From the meter itself, which may stand off its wall's line. + polyline=([self.scene.meter_xz] if self.meter_offset > EPS else []) + + self.scene.polyline(0.0, near), + detours=detours, + crossings=crossings, + ) + return route, path, reach + + # --- candidates ---------------------------------------------------------------------------- + + def evaluate(self, wall_piece: Piece, s0: float) -> Candidate: + r = self.r + s1 = s0 + self.W + fp = self.footprint(wall_piece, s0) + # Checks see the battery's position error (_error): its wall's at its far edge from the + # meter, where AR drift is largest, and the meter's. The route and the meter's working + # space count the meter's error themselves, so they take the wall's alone. + plan_err, along_err = self._errors(wall_piece, s0) + piece = replace(wall_piece, plus_minus=max(plan_err, along_err), drift=0.0) + on_wall = replace(piece, plus_minus=wall_piece.error_at(max(abs(s0), abs(s1)))) + c = r.clearances + checks = [ + self.check_backing(piece, s0, s1), + self.check_ground(piece, fp), + self.check_meter_space(on_wall, fp, s0, s1), + self.check_clearance( + "gas_clearance", + "Distance from gas equipment", + "clearances.gas_ft", + c.gas_ft, + piece, + fp, + [(o.label, o.geom, o.plus_minus, True, o.in_plan) for o in self.gas], + # Gas meters hang on the wall as well as standing on the ground. + "ground+wall", + "gas meter or pipe", + self.wall_height["above"], + along_err=along_err, + ), + self.check_clearance( + "ac_clearance", + "Distance from AC units", + "clearances.ac_ft", + c.ac_ft, + piece, + fp, + [(o.label, o.geom, o.plus_minus, True, o.in_plan) for o in self.ac], + "ground", + "AC unit", + along_err=along_err, + ), + *( + [ + self.check_clearance( + "battery_clearance", + "Distance from an existing battery", + "clearances.battery_ft", + c.battery_ft, + piece, + fp, + [(o.label, o.geom, o.plus_minus, True, o.in_plan) for o in self.batteries], + "ground+wall", + "battery", + self.wall_height["above"], + along_err=along_err, + ) + ] + if self.battery_check + else [] + ), + self.check_clearance( + "drive_clearance", + "Distance from the driveway", + "clearances.drive_ft", + c.drive_ft, + piece, + fp, + [ + *( + (f"ground[{g.index}] {g.type}", g.polygon, g.plus_minus, True, True) + for g in self.drives + ), + # Seen ground with no recorded surface may be a driveway: unknown, as + # ground_surface treats it (final review: it counted as no driveway). + ( + "ground with no recorded surface", + self._unclassified, + self._ground_error, + None, + True, + ), + ], + "ground", + "drivable surface", + unknown_reason=( + f"Ground within {ft(c.drive_ft.value)} of the battery was seen but its surface " + "was not recorded, so it may be a driveway. Recording what that ground is (a " + "`ground` patch with its type) settles it." + ), + along_err=along_err, + ), + self.check_clearance( + "pool_clearance", + "Distance from a pool", + "clearances.pool_ft", + c.pool_ft, + piece, + fp, + [(o.label, o.geom, o.plus_minus, True, o.in_plan) for o in self.pool], + "ground", + "pool", + along_err=along_err, + ), + self.check_clearance( + "opening_clearance", + "Distance from doors and windows", + "clearances.opening_ft", + c.opening_ft, + piece, + fp, + [ + (o.label, o.geom, o.plus_minus, self._opening_counts(o), o.in_plan) + for o in self.openings + ], + "wall", + "door or window", + self.wall_height["openings"], + along_err=along_err, + ), + self.check_along_wall(along_err, s0, s1), + self.check_measured( + "facing_gap", + "Open space in front", + "facing", + self.scene.facing, + "facing.min_ft", + r.facing.min_ft, + s0, + s1, + self.D if r.facing.measured_from == "battery_front" else 0.0, + "gap in front of the battery" + if r.facing.measured_from == "battery_front" + else "gap from the wall to whatever faces it", + along_err, + ), + self.check_measured( + "headroom", + "Headroom above", + "overhead", + self.scene.overheads, + "headroom.min_ft", + r.headroom.min_ft, + s0, + s1, + 0.0, + "headroom", + along_err, + ), + ] + route, path, reach = self.route_for(on_wall, s0, s1) + checks += [path, reach] + return Candidate(wall_piece, s0, s1, fp, checks, route, worst([x.outcome for x in checks])) + + def _chain_to(self, piece: Piece, geom: Geometry) -> float: + """How far a hazard placed along the walls (no outline: it lies on its wall's line at its + s) can move against a battery on `piece`, from the walls between them other than the + battery's own: each can move its line by its error, and its corners slide what lies past + them along the chain by the error times the turn against the battery's wall. A gas meter + on another wall's line moved 0.17 ft when that wall's ends moved within 0.3 ft, which its + check left out (test_within_errors).""" + extent = self.scene.s_extent(geom) + if extent is None: + return 0.0 + lo, hi = min(piece.s0, extent[0]), max(piece.s1, extent[1]) + total = 0.0 + for p in self.scene.walls: + a, b = max(p.s0, lo), min(p.s1, hi) + if b - a <= EPS or (abs(p.s0 - piece.s0) <= EPS and abs(p.s1 - piece.s1) <= EPS): + continue + turn = math.hypot(piece.along[0] - p.along[0], piece.along[1] - p.along[1]) + total += p.error_at(max(abs(a), abs(b))) * (1 + turn) + return total + + def _walls_between(self, piece: Piece, lo: float, hi: float) -> float: + """The summed errors of the wall segments other than `piece` over [lo, hi], each at its + end furthest from the meter within the stretch.""" + total = 0.0 + for p in self.scene.walls: + a, b = max(p.s0, lo), min(p.s1, hi) + if b - a <= EPS or (abs(p.s0 - piece.s0) <= EPS and abs(p.s1 - piece.s1) <= EPS): + continue + total += p.error_at(max(abs(a), abs(b))) + return total + + def _meter_on_route(self, piece: Piece) -> tuple[float, float]: + """How much the meter's error e can lengthen and shorten a route to a battery on `piece`. + The battery is placed by its offset from the meter, so moving the meter by d (|d| <= e) + moves both. The route's start slides d's component along the meter's wall and the + battery d's along its own, so the run along the walls changes by sigma * d . v, with v the + difference of the two walls' directions and sigma the battery's side of the meter. The + meter's standoff o from its wall becomes |o + d . u| (u: from the wall to the meter). The + change sigma * d . v + |o + d . u| - o is the larger of d . (sigma v + u) and + d . (sigma v - u) - 2o, two linear functions, so over the disc it is at most + max(e |sigma v + u|, e |sigma v - u| - 2o): derived, and reached. On the meter's own wall + (v = 0) that is e; round a right-angle corner with the meter on its wall's line, sqrt(5) + e, the caretaker's witness in test_within_errors, where e * sqrt(1 + k^2) had treated the + two components as independent. Shorter: the standoff can't go below zero, so by at most + min(o, e) + |v| e, a bound, not the minimum.""" + e = self.scene.meter_plus_minus + m = self.scene.meter_piece + sigma = -1.0 if piece.s1 <= EPS else 1.0 + v = (sigma * (piece.along[0] - m.along[0]), sigma * (piece.along[1] - m.along[1])) + o = self.meter_offset + if o > EPS: + mx, mz = self.scene.meter_xz + wx, wz = self.scene.point_at(0.0) + u = ((mx - wx) / o, (mz - wz) / o) + else: + u = m.outward + longer = max( + e * math.hypot(v[0] + u[0], v[1] + u[1]), + e * math.hypot(v[0] - u[0], v[1] - u[1]) - 2 * o, + ) + return longer, min(longer, min(o, e) + math.hypot(*v) * e) + + def reach_limit(self, piece: Piece) -> float: + """Past this |s| of its near edge a battery's route fails the maximum length whatever + else is true: the route is never shorter than |s|, and its error is at most the meter's, + the wall's and every possible detour's.""" + detour_err = sum(2 * o.plus_minus for o in self.route_objects) + # Every other wall's error may add to a route (at most, its error at its far end). + others = self._walls_between(piece, -math.inf, math.inf) + e_fixed = self._meter_on_route(piece)[1] + piece.plus_minus + detour_err + others + if piece.drift >= 1: + return math.inf + # The route's error grows by the wall's drift at the battery's far edge, |s| + W from the + # meter (as evaluate takes it): solve |s| - e(|s| + W) = max. + e_fixed += piece.drift * self.W + # The 1e-6 ft margin keeps the cutoff clear of the 6-decimal rounding of reported + # starts, so a start reported past reach really fails when evaluated. + return (self.r.route.max_ft.value - self.meter_offset + e_fixed) / (1 - piece.drift) + 1e-6 + + def starts(self, piece: Piece) -> list[float]: + """Start positions (left edge, in s) to evaluate on one straight segment.""" + W, D = self.W, self.D + limit = self.reach_limit(piece) + lo, hi = max(piece.s0, -limit - W), min(piece.s1 - W, limit) + if hi < lo - EPS: + return [] + hi = max(hi, lo) + step = self.r.sweep.step_ft.value + # The grid puts the battery's centre at k * step from the meter, so the start positions + # of a scene and of its mirror image map onto each other and left and right get the same + # treatment. + k_lo, k_hi = math.floor(lo / step) - 1, math.ceil((hi + W) / step) + 1 + points = [lo, hi] + [k * step - W / 2 for k in range(k_lo, k_hi + 1)] + # Where a start is first clear of the segment's ends by the wall's error. + # The battery's error beyond its wall's, as _error adds it: one value over a segment, as + # the same walls lie between it and the meter wherever it stands on it. + slide = self._slide(piece, lo) + extra = max(self.scene.meter_plus_minus, slide) + # Checks against spans take the error along the walls (_errors), this much smaller. + along = extra - slide + for e in {piece.plus_minus + extra, piece.error_at(max(abs(lo), abs(hi))) + extra}: + points += [piece.s0 + e, piece.s1 - W - e] + # Along the wall: every place an interval can start or stop mattering, each with only + # its own error offsets (combining every boundary with every error would grow as their + # product). + ew = piece.error_at(max(abs(lo), abs(hi))) + extra + # When the error drifts, offsets that include the battery's own error are placed with the + # error at each start instead (below), since that is the error a start is judged with. + drifts = piece.drift > 0 + around = (0.0,) if drifts else (0.0, ew, -ew, ew - along, along - ew) + boundaries: list[tuple[float, tuple[float, ...]]] = [(0.0, around)] + boundaries += [(b, around) for b in self.ws_span] + # Boundaries whose offset includes the battery's own error, with the fixed part of the + # offset: re-placed below with the error at each start when that error drifts. + drifting: list[tuple[float, float]] = [(0.0, 0.0), *((b, 0.0) for b in self.ws_span)] + for o in self.scene.objects: + e = o.plus_minus + near = e + ew - along + own = (0.0, e, -e) if drifts else (0.0, e, -e, e + ew, -(e + ew), near, -near) + boundaries += [(b, own) for b in o.span] + drifting += [(b, e) for b in o.span] + if along > EPS: + drifting += [(b, f - along) for b, f in drifting] + for m in self.scene.overheads + self.scene.facing: + boundaries += [(b, around) for b in m.span] + for band in self.scene.observed.values(): + for a, b, _ in band: + boundaries += [(a, around), (b, around)] + for g in self.scene.gaps: + boundaries += [(g.s0, around), (g.s1, around)] + for b, offsets in boundaries: + for off in offsets: + points += [b + off, b - W - off] + if drifts: + for b, fixed in drifting: + for k in (1.0, -1.0): + points.append( + self._settle( + lambda s, b=b, f=fixed, k=k: b + k * (f + self._error(piece, s)), + b + k * (fixed + ew), + ) + ) + points.append( + self._settle( + lambda s, b=b, f=fixed, k=k: b - W - k * (f + self._error(piece, s)), + b - W - k * (fixed + ew), + ) + ) + rt = self.r.route + # Where the run's range [|s| - (wall + down), |s| + wall + up] reaches each line. + up, down = self._meter_on_route(piece) + for line in (rt.confident_reach_ft.value, rt.max_ft.value): + for x in (line - (ew - extra) - up, line + (ew - extra) + down, line): + points += [x, -x - W] + # Plan clearances: where a footprint corner's track along the wall crosses the line at + # the rule's distance (and within error of it) from any part of an object, including + # the middle of a slanted edge, and where it crosses a ground patch's edge. + tracks = [LineString([piece.point(lo, v), piece.point(hi + W, v)]) for v in (0.0, D)] + strip = piece.rect(lo, hi + W, 0.0, D) + if drifts: + # The battery's error over this segment's starts: V-shaped in s, least where the + # battery straddles the meter. + errs = [self._error(piece, s) for s in (lo, hi, min(max(-W / 2, lo), hi))] + e_least, e_most = min(errs), max(errs) + for geom, base, fixed, k in self._clearance_edges(piece, along): + if drifts and k != 0: + points += self._drifting_outline( + piece, geom, base, fixed, k, tracks, strip, e_least, e_most + ) + continue + dist = base + k * (fixed + ew) + # A rule's outline needs a positive distance; a ground patch's may shrink it (base 0). + if base > 0 and dist <= 0: + continue + region = geom.buffer(dist) if dist != 0 else geom + boundary = region.boundary + for track in tracks: + if track.distance(boundary) > EPS: + continue + for x, z in _coords_of(track.intersection(boundary)): + u = piece.local((x, z))[0] + points += [u, u - W] + # Where the region lies inside the footprint's strip, the footprint's side edges + # meet it at the region's extent along the wall (an object standing a little off the + # wall is reached by a side edge, not a corner). + inside = region.intersection(strip) + if not inside.is_empty: + us = [piece.local((x, z))[0] for x, z in get_coordinates(inside)] + points += [min(us), max(us), min(us) - W, max(us) - W] + pts = sorted({round(p, 9) for p in points if lo - EPS <= p <= hi + EPS}) + pts = [min(max(p, lo), hi) for p in pts] + mids = [(a + b) / 2 for a, b in itertools.pairwise(pts) if b - a > 1e-6] + return sorted(set(pts) | set(mids)) + + def _clearance_edges( + self, piece: Piece, along: float + ) -> list[tuple[Geometry, float, float, float]]: + """(geometry, base, fixed error, k): outlines at distance base + k * (fixed error + the + battery's error) from the geometry bound some check's outcome. An object placed along + the walls is judged with the battery's error along them, `along` less (_errors).""" + c = self.r.clearances + + def fixed(o: SceneObject) -> float: + return ( + o.plus_minus if o.in_plan else o.plus_minus - along + self._chain_to(piece, o.geom) + ) + + items = [ + *((c.gas_ft.value, o.geom, fixed(o)) for o in self.gas), + *((c.ac_ft.value, o.geom, fixed(o)) for o in self.ac), + *((c.battery_ft.value, o.geom, fixed(o)) for o in self.batteries), + *((c.pool_ft.value, o.geom, fixed(o)) for o in self.pool), + *((c.opening_ft.value, o.geom, fixed(o)) for o in self.openings), + *((c.drive_ft.value, g.polygon, g.plus_minus) for g in self.drives), + ] + out = [(geom, t, err, k) for t, geom, err in items for k in (0.0, 1.0, -1.0) if t > 0] + for g in self.scene.ground: + out += [(g.polygon, 0.0, g.plus_minus, k) for k in (0.0, 1.0, -1.0)] + return out + + def _error(self, piece: Piece, s0: float) -> float: + """The battery's position error for a start at s0 where a check compares it with both + kinds of position (a wall's end and the wall's spans of heights; seen areas): the larger + of _errors. The battery is placed by its offset from the meter (the AR view anchors it + there), so wherever the meter truly is, the battery goes with it: against anything placed + in plan it moves by up to the meter's error, and against anything placed along the walls + in s it slides (_slide). test_within_errors has a case of each.""" + return max(self._errors(piece, s0)) + + def _errors(self, piece: Piece, s0: float) -> tuple[float, float]: + """The battery's position error for a start at s0 against what was placed in plan (a + wall's end, a ground patch, an object's outline), and against what was placed along the + walls in s (a span): its wall's error at its far edge from the meter plus the meter's in + plan, plus the slide (_slide) along the walls. On the meter's own wall the slide is 0: a + span there moves with the meter as the battery does.""" + wall = piece.error_at(max(abs(s0), abs(s0 + self.W))) + return wall + self.scene.meter_plus_minus, wall + self._slide(piece, s0) + + def _slide(self, piece: Piece, s0: float) -> float: + """How far the battery's s can move against spans measured along the walls when it + stands on `piece` and the meter or a corner between them moves within its error. s = 0 + is the meter's projection onto its own wall, so moving the meter by d moves s = 0 by d's + component along that wall and the battery by d's along its own: d . (along - along_m). + Moving the far corner of a wall p between them by d lengthens p by d . along_p and moves + the battery's wall with it: d . (along_p - along). Each is at most the error times the + difference of the two directions, 0 on the meter's own wall.""" + + def turn(p: Piece) -> float: + return math.hypot(piece.along[0] - p.along[0], piece.along[1] - p.along[1]) + + near = s0 if s0 > 0 else min(s0 + self.W, 0.0) + lo, hi = min(0.0, near), max(0.0, near) + slide = self.scene.meter_plus_minus * turn(self.scene.meter_piece) + for p in self.scene.walls: + a, b = max(p.s0, lo), min(p.s1, hi) + if b - a <= EPS or (abs(p.s0 - piece.s0) <= EPS and abs(p.s1 - piece.s1) <= EPS): + continue + slide += p.error_at(max(abs(a), abs(b))) * turn(p) + return slide + + @staticmethod + def _settle(place: Callable[[float], float], start: float) -> float: + """A boundary whose position depends on the error at that position: iterate from the + largest-error estimate. Each round shrinks the difference by the drift rate.""" + for _ in range(8): + start = place(start) + return start + + def _drifting_outline( + self, + piece: Piece, + geom: Geometry, + base: float, + fixed: float, + k: float, + tracks: list[LineString], + strip: Polygon, + e_least: float, + e_most: float, + ) -> list[float]: + """Starts where the footprint is exactly base + k * (fixed + the error at that start) + from `geom`. A start's error lies between the segment's least and most, so each such + start lies between where the footprint meets the outline drawn with the least error and + where it meets the one drawn with the most: by a corner (along the corner tracks) or by + a side edge (the outline's extent within the footprint's strip). Those positions bracket + it, and bisection finds it however far apart they are.""" + W = self.W + ends: set[float] = set() + for e in (e_least, e_most): + dist = base + k * (fixed + e) + if base > 0 and dist <= 0: + continue + region = geom.buffer(dist) if dist != 0 else geom + for track in tracks: + for p in _coords_of(track.intersection(region.boundary)): + u = piece.local(p)[0] + ends |= {u, u - W} + inside = region.intersection(strip) + if not inside.is_empty: + us = [piece.local((x, z))[0] for x, z in get_coordinates(inside)] + ends |= {min(us), max(us), min(us) - W, max(us) - W} + + ordered = sorted(round(s, 9) for s in ends) + if base == 0: + # A ground patch's edge: the ground check rounds its erosion up to 0.1 ft, so there + # is no exact root to find; both bracketing positions are tested. + return ordered + + def gap(s: float) -> float: + # A rule's distance, from the whole footprint: corners and side edges alike. + return self.footprint(piece, s).distance(geom) - ( + base + k * (fixed + self._error(piece, s)) + ) + + found: list[float] = [] + values = [gap(s) for s in ordered] + for (a, ga), (b, gb) in itertools.pairwise(zip(ordered, values, strict=True)): + if ga * gb > 0: + continue + for _ in range(40): + mid = (a + b) / 2 + gm = gap(mid) + if gm * ga <= 0: + b = mid + else: + a, ga = mid, gm + found.append((a + b) / 2) + return found + + def candidates(self, budget_s: float) -> list[Candidate]: + started = time.perf_counter() + starts = [(piece, s0) for piece in self.scene.walls for s0 in self.starts(piece)] + if len(starts) > MAX_STARTS: + raise SceneTooComplex( + f"the scene needs {len(starts)} battery positions checked, more than the " + f"{MAX_STARTS} this server evaluates" + ) + out = [] + for i, (piece, s0) in enumerate(starts): + elapsed = time.perf_counter() - started + if i % 64 == 0 and elapsed > budget_s: + raise SceneTooComplex( + f"checking the scene took longer than {budget_s:g} seconds " + f"({i} of {len(starts)} positions done)" + ) + # Refuse as soon as the pace shows the budget can't be met, rather than after + # spending it. + if i == PROJECT_AFTER and elapsed / i * len(starts) > budget_s: + raise SceneTooComplex( + f"checking its {len(starts)} battery positions would take about " + f"{elapsed / i * len(starts):.0f} seconds, more than the {budget_s:g} this " + "server allows" + ) + out.append(self.evaluate(piece, s0)) + return out + + def out_of_reach(self) -> list[dict[str, Any]]: + """Sweep runs for wall that is not evaluated start by start: segments too short for the + battery, and stretches too far along for any route to pass.""" + runs = [] + + def fail_run(piece: Piece, a: float, b: float, check_id: str) -> dict[str, Any]: + return { + **_locator(self.scene, a, a + self.W), + "start_ft": [_round(a), _round(b)], + "outcome": FAIL, + "failing": [check_id], + "unsure": [], + } + + for piece in self.scene.walls: + limit = self.reach_limit(piece) + lo, hi = piece.s0, piece.s1 - self.W + if hi < lo - EPS: + # Too short for the battery: any start runs off the end of the segment. + runs.append(fail_run(piece, lo, lo, "wall_backing")) + continue + for a, b in ((lo, min(hi, -limit - self.W)), (max(lo, limit), hi)): + if b - a > EPS: + runs.append(fail_run(piece, a, b, "route_length")) + return runs + + +def _coords_of(geom: Geometry) -> list[tuple[float, float]]: + out: list[tuple[float, float]] = [] + for part in getattr(geom, "geoms", [geom]): + if not part.is_empty and hasattr(part, "coords"): + out += [(x, z) for x, z in part.coords] + return out + + +def evaluate_start( + scene: Scene, loaded: LoadedRules, s0: float, wall_id: str | None = None +) -> Candidate: + """Evaluate one battery start position (its left edge at s0), even one that crosses a corner. + The footprint follows the straight segment that contains s0.""" + solver = Solver(scene, loaded) + # A wall joined into its collinear neighbour's piece has no piece of its own id. + walls = [p for p in scene.walls if wall_id is None or p.wall_id == wall_id] or scene.walls + piece = next((p for p in walls if p.s0 - EPS <= s0 < p.s1 - EPS), walls[-1]) + return solver.evaluate(piece, s0) + + +# --- decision and result ------------------------------------------------------------------------- + + +def _unseen_near(solver: Solver, c: Candidate, end: list[float]) -> bool: + """Whether unseen ground past an unexplored end at `end` lies within reach of a check at c: + in that end's area (within reach of it), and within reach of c's footprint.""" + scene = solver.scene + area = _meet(polygonal(Point(end).buffer(scene.reach_ft)), polygonal(solver.unobserved_ground)) + return not area.is_empty and c.footprint.distance(area) < scene.reach_ft + + +def _rank_pass(c: Candidate) -> tuple: + return (c.route.length, abs((c.s0 + c.s1) / 2), c.s0) + + +def estimate_fails(c: Candidate) -> bool: + """Whether a check's best estimate is past its rule although the error leaves it UNSURE: + the measured value on the fail side (an overlap with the meter's working space, a clearance + under its minimum, a run over its maximum). Only a check unsure by its margin counts: one + unsure for an unknown attribute (a window that may not open) may not be subject to the rule.""" + for check in c.checks: + if check.outcome != UNSURE or check.unsure_cause != "margin": + continue + if check.measured is None or check.threshold is None: + continue + if check.comparison == "at_least" and check.measured < check.threshold - EPS: + return True + if check.comparison == "at_most" and check.measured > check.threshold + EPS: + return True + return False + + +def _rank_unsure(c: Candidate) -> tuple: + """A candidate whose best estimate is past a rule (issue #44: a spot overlapping the meter's + working space, UNSURE only because the meter's error is large) ranks below every candidate + whose best estimates all clear, whatever its route. Within each group: fewest UNSURE checks, + then the shortest route.""" + return (estimate_fails(c), len(c.unsure()), c.route.length, c.s0) + + +def _spot_json(solver: Solver, c: Candidate) -> dict[str, Any]: + p = c.piece + corners = [p.point(c.s0), p.point(c.s1), p.point(c.s1, solver.D), p.point(c.s0, solver.D)] + center = p.point((c.s0 + c.s1) / 2, solver.D / 2) + mx, mz = solver.scene.meter_xz + return { + "outcome": c.outcome, + **_locator(solver.scene, c.s0, c.s1), + "span_ft": [_round(c.s0), _round(c.s1)], + "width_ft": _round(solver.W), + "depth_ft": _round(solver.D), + "height_ft": _round(solver.H), + "footprint": [[_round(x), _round(z)] for x, z in corners], + "center": [_round(center[0]), _round(center[1])], + "along": [_round(p.along[0]), _round(p.along[1])], + "outward": [_round(p.outward[0]), _round(p.outward[1])], + "meter_offset_ft": [_round(center[0] - mx), _round(center[1] - mz)], + "route_length_ft": _round(c.route.length), + } + + +def _route_json(solver: Solver, c: Candidate) -> dict[str, Any]: + rt = c.route + return { + "outcome": rt.outcome, + "length_ft": _round(rt.length), + "plus_minus_ft": _round(rt.plus_minus), + "height_ft": _round(solver.r.route.height_ft.value), + "polyline": [[_round(x), _round(z)] for x, z in rt.polyline], + "detours": [ + {"subject": d["subject"], "extra_ft": _round(d["extra_ft"])} for d in rt.detours + ], + "crossings": [ + { + "subject": x["subject"], + "span_ft": [_round(v) for v in x["span_ft"]], + "effect": x["effect"], + } + for x in rt.crossings + ], + } + + +_DEPTH_TEXT = { + "wall": ", seen at least {} up the wall", + "ground": ", at least {} out from the wall", + "facing": ", clear at least {} out from the wall", + "overhead": ", clear at least {} up", +} +_BAND_TEXT = { + "wall": "wall", + "ground": "ground in front of the wall", + "overhead": "space overhead", + "facing": "gap in front of the wall", +} + + +def _missing_json(c: Candidate, scene: Scene) -> list[dict[str, Any]]: + by_band: dict[str, list[tuple[View, str]]] = {} + for chk in c.checks: + for view in chk.all_missing(): + by_band.setdefault(view.band, []).append((view, chk.id)) + out = [] + for band, items in by_band.items(): + for a, b in merge_intervals([(v.a, v.b) for v, _ in items]): + within = [(v, i) for v, i in items if v.a < b + EPS and v.b > a - EPS] + depths = [v.out for v, _ in within if v.out is not None] + request: dict[str, Any] = { + "kind": "band", + "band": band, + "span_ft": _outward(a, b), + "checks": sorted({i for _, i in within}), + } + text = f"Show the {_BAND_TEXT[band]} from {_point(a)} to {_point(b)}" + if depths: + request["out_ft"] = _up(max(depths)) + text += _DEPTH_TEXT[band].format(ft(request["out_ft"])) + request["message"] = text + "." + _past_end_hint(scene, a, b) + out.append(request) + return out + + +def _past_end_hint(scene: Scene, a: float, b: float) -> str: + """Only a limit end has requests past it (see Scene.coverable); they are met from where the + walk stopped.""" + sides = [ + s for s, past in (("left", a < scene.s_min - EPS), ("right", b > scene.s_max + EPS)) if past + ] + if not sides: + return "" + return ( + f" Part of it is past the {' and '.join(sides)} end: point the camera there from the end." + ) + + +def _locator(scene: Scene, s0: float, s1: float) -> dict[str, Any]: + """The uploaded wall and segment under a battery's middle, from the same original segment.""" + wall_id, segment = scene.segment_at((s0 + s1) / 2) + return {"wall_id": wall_id, "segment": segment} + + +def _sweep_json(cands: list[Candidate], scene: Scene, step: float) -> list[dict[str, Any]]: + """Merge evaluated starts into runs. A run only grows by a start on the same straight piece + within one sweep step of the previous one, so it never claims starts nobody evaluated.""" + runs: list[dict[str, Any]] = [] + prev: Candidate | None = None + for c in sorted(cands, key=lambda c: c.s0): + where = _locator(scene, c.s0, c.s1) + key = ( + where["wall_id"], + where["segment"], + c.piece, + c.outcome, + sorted(c.failing()), + sorted(c.unsure()), + ) + adjacent = prev is not None and prev.piece == c.piece and c.s0 - prev.s0 <= step + EPS + prev = c + if runs and adjacent and runs[-1]["_key"] == key: + runs[-1]["start_ft"][1] = _round(c.s0) + else: + runs.append( + { + "_key": key, + **where, + "start_ft": [_round(c.s0), _round(c.s0)], + "outcome": c.outcome, + "failing": key[4], + "unsure": key[5], + } + ) + for run in runs: + del run["_key"] + return runs + + +def solve(scene: Scene, loaded: LoadedRules, budget_s: float = SOLVE_BUDGET_S) -> dict[str, Any]: + """Decide where the battery goes. Pure apart from the elapsed time it reports. + + Raises SceneTooComplex when the scene needs more positions checked, or more time, than a + request may take.""" + started = time.perf_counter() + solver = Solver(scene, loaded) + r = loaded.rules + cands = solver.candidates(budget_s) + far = solver.out_of_reach() + passes = [c for c in cands if c.outcome == PASS] + unsures = [c for c in cands if c.outcome == UNSURE] + fails = [c for c in cands if c.outcome == FAIL] + auto = r.policy.auto_approve and r.policy.id is not None + + ends: dict[str, dict[str, Any]] = {} + for side, s_end, pt, piece in ( + ("left", scene.s_min, scene.walls[0].a, scene.walls[0]), + ("right", scene.s_max, scene.walls[-1].b, scene.walls[-1]), + ): + # Any spot past this end has its near edge at least |s_end| out. + beyond = abs(s_end) > solver.reach_limit(piece) + ends[side] = { + "kind": scene.end_kinds[side], + "s_ft": _round(s_end), + "point": [_round(pt[0]), _round(pt[1])], + "beyond_reach": beyond, + } + open_ends = [s for s, e in ends.items() if e["kind"] == "unexplored" and not e["beyond_reach"]] + + def past_end_requests() -> list[dict[str, Any]]: + return [ + { + "kind": "past_end", + "side": side, + "span_ft": [ends[side]["s_ft"], ends[side]["s_ft"]], + "message": ( + f"Keep walking past the {side} end of the scan ({where(ends[side]['s_ft'])}): " + "a spot within reach may be there." + ), + } + for side in open_ends + ] + + reasons: list[dict[str, Any]] = [] + missing: list[dict[str, Any]] = [] + spot = best = nearest = None + unexplored_reason = { + "code": "unexplored_end", + "message": "The wall continues past an end of the scan within cable reach.", + } + policy_reason = { + "code": "policy_not_approved", + "message": ( + "The rules in use are not approved for automatic decisions (no policy selected, or " + "placeholder values), so a person must confirm." + ), + } + if passes: + best = spot = min(passes, key=_rank_pass) + reasons.append( + {"code": "all_checks_pass", "message": "A fully observed spot passes every check."} + ) + decision = "pass" if auto else "manual_review" + if not auto: + reasons.append(policy_reason) + summary = ( + f"The battery fits {where((best.s0 + best.s1) / 2)} with a " + f"{format_ft_in(best.route.length)} cable run" + + (": every check passes." if auto else "; a person must confirm under these rules.") + ) + elif unsures: + best = spot = min(unsures, key=_rank_unsure) + decision = "manual_review" + ids = best.unsure() + labels = [c.label.lower() for c in best.checks if c.outcome == UNSURE] + reasons.append( + { + "code": "unsure_checks", + "checks": ids, + "message": "The best spot has checks nobody can settle from this scan: " + + "; ".join(labels) + + ".", + } + ) + missing = _missing_json(best, scene) + # An unseen check no view can settle waits on ground past an unexplored end: walking past + # it settles that, even an end too far for a spot past it to reach (the final review's + # ETH3D case: pool_clearance UNSURE with no request and no reason). + requested = {i for m in missing for i in m["checks"]} + leftover = [ + c.id + for c in best.checks + if c.outcome == UNSURE and c.unsure_cause == "unobserved" and c.id not in requested + ] + walk_to = [ + side + for side, e in ends.items() + if leftover + and e["kind"] == "unexplored" + and side not in open_ends + and _unseen_near(solver, best, e["point"]) + ] + if missing or leftover: + reasons.append( + { + "code": "unobserved_area", + "checks": sorted(requested | set(leftover)), + "message": "Part of the area the checks need was not seen.", + } + ) + if open_ends: + reasons.append(unexplored_reason) + missing += past_end_requests() + missing += [ + { + "kind": "past_end", + "side": side, + "span_ft": [ends[side]["s_ft"], ends[side]["s_ft"]], + "message": ( + f"Walk past the {side} end of the scan ({where(ends[side]['s_ft'])}): ground " + "a check at the best spot needs lies past it." + ), + } + for side in walk_to + ] + spot_at = where((best.s0 + best.s1) / 2) + unseen = [c for c in best.checks if c.outcome == UNSURE and c.unsure_cause == "unobserved"] + if unseen: + # Only unseen checks are settled by more views; the rest are named for a person, as in + # "A person needs to check the best spot" (issue #45, #50's wording). + rest = [ + c.label.lower() + for c in best.checks + if c.outcome == UNSURE and c.unsure_cause != "unobserved" + ] + summary = ( + f"More views are needed around the best spot, {spot_at}: " + + ("1 check depends" if len(unseen) == 1 else f"{len(unseen)} checks depend") + + " on areas the scan did not see" + + ("; a person also needs to check " + "; ".join(rest) if rest else "") + + "." + ) + else: + summary = ( + f"A person needs to check the best spot, {spot_at}: " + "; ".join(labels) + "." + ) + else: + best = nearest = min( + fails, + key=lambda c: (len(c.failing()), len(c.unsure()), c.route.length, c.s0), + default=None, + ) + fail_counts: dict[str, int] = {} + for c in fails: + for i in c.failing(): + fail_counts[i] = fail_counts.get(i, 0) + 1 + if far: + fail_counts["route_length"] = fail_counts.get("route_length", 0) + len(far) + top = sorted(fail_counts, key=lambda i: -fail_counts[i]) + if cands or far: + reasons.append( + { + "code": "all_spots_fail", + "checks": top, + "message": "Every spot on the walls scanned fails at least one check by a " + "clear margin.", + } + ) + else: + reasons.append( + { + "code": "no_wall_segment_fits", + "message": "No straight stretch of scanned wall is as wide as the battery.", + } + ) + can_reject = auto and r.policy.allow_reject and not open_ends + if open_ends: + reasons.append(unexplored_reason) + missing = past_end_requests() + if not auto: + reasons.append(policy_reason) + decision = "reject" if can_reject else "manual_review" + if decision == "reject": + summary = ( + "No spot within reach works: every spot fails " + + (", ".join(t.replace("_", " ") for t in top[:3]) or "the checks") + + "." + ) + elif open_ends: + summary = ( + "No spot on the scanned walls works; walk further to look for one within reach." + ) + else: + summary = "No spot on the scanned walls works; a person must confirm before rejecting." + + elapsed_ms = (time.perf_counter() - started) * 1000 + return { + "schema_version": SCHEMA_VERSION, + "decision": decision, + # Every answer names whose rules decided it when the policy asks (the public demo). + "summary": f"{summary} {r.policy.notice}" if r.policy.notice else summary, + "reasons": reasons, + "policy": { + "id": r.policy.id, + "version": r.policy.version, + "auto_approve": auto, + "sources": list(loaded.sources), + "rules_sha256": loaded.sha256, + "notice": r.policy.notice, + }, + "spot": _spot_json(solver, spot) if spot else None, + "route": _route_json(solver, spot) if spot else None, + "checks": [c.to_json(loaded.private_keys) for c in best.checks] if best else [], + "nearest_considered": _spot_json(solver, nearest) if nearest else None, + "missing_evidence": missing, + "objects_not_used": [ + { + "object": f"objects[{index}] {kind}", + "side": side, + "message": ( + f"The {kind.replace('_', ' ')} marked {where(s)} is past the {side} end of " + "the scan, where the wall may turn, so it was not used." + ), + } + for index, kind, side, s in scene.set_aside + ], + "ends": ends, + "sweep": sorted( + _sweep_json(cands, scene, r.sweep.step_ft.value) + far, + key=lambda run: (run["start_ft"][0], run["segment"]), + ), + "stats": { + "candidates": len(cands), + "pass": len(passes), + "unsure": len(unsures), + "fail": len(fails), + "elapsed_ms": round(elapsed_ms, 3), + "input_sha256": scene.input_sha256, + }, + } diff --git a/server/tests/conftest.py b/server/tests/conftest.py new file mode 100644 index 000000000..9a2878642 --- /dev/null +++ b/server/tests/conftest.py @@ -0,0 +1,22 @@ +import os + +import pytest +from hypothesis import settings + +import api +from rules import public_rules_dict, rules_from_dict + +# CI runs every property test on a fixed set of examples, so a check can't pass on one run and +# fail on the next; a counterexample found there reproduces from the log alone. Local runs keep +# the random search (and its example database) for finding new cases. GitHub Actions sets CI. +# Loaded here, before the test modules import, so each @settings inherits it. +settings.register_profile("ci", derandomize=True, database=None, print_blob=True) +settings.load_profile("ci" if os.environ.get("CI") else "default") + + +@pytest.fixture(autouse=True) +def public_api(monkeypatch: pytest.MonkeyPatch) -> None: + """API tests run against the public rules, whatever private file this checkout holds, and + without an API key; tests of the private mode set both themselves.""" + monkeypatch.setattr(api, "LOADED", rules_from_dict(public_rules_dict())) + monkeypatch.setattr(api, "API_KEY", None) diff --git a/server/tests/fixtures/example-scene.json b/server/tests/fixtures/example-scene.json new file mode 100644 index 000000000..873a92674 --- /dev/null +++ b/server/tests/fixtures/example-scene.json @@ -0,0 +1,44 @@ +{ + "schema_version": "1.0", + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "side", "plus_minus_ft": 0.3}, + "walls": [ + {"id": "back", "baseline": [[-14.0, -18.0], [-14.0, 0.0]], "height_ft": 9, "plus_minus_ft": 0.3}, + {"id": "side", "baseline": [[-14.0, 0.0], [22.0, 0.0]], "height_ft": 9, "plus_minus_ft": 0.3} + ], + "objects": [ + {"type": "gas_meter", "wall_id": "side", "span_ft": [-5.0, -4.0], "bottom_ft": 0.5, "top_ft": 2.5, + "source": "tap", "plus_minus_ft": 0.3, + "footprint": [[-5.0, 0.0], [-4.0, 0.0], [-4.0, 1.0], [-5.0, 1.0]]}, + {"type": "window", "wall_id": "side", "span_ft": [3.0, 6.0], "bottom_ft": 3.0, "top_ft": 7.0, + "attrs": {"operable": true, "well": false}, "source": "vlm", "conf": 0.86}, + {"type": "door", "wall_id": "side", "span_ft": [17.0, 20.0], "bottom_ft": 0.0, "top_ft": 6.8, + "source": "tap"}, + {"type": "ac", "wall_id": "back", "span_ft": [-20.0, -17.0], "source": "tap", + "footprint": [[-17.0, -6.0], [-14.0, -6.0], [-14.0, -3.0], [-17.0, -3.0]]} + ], + "ground": [ + {"type": "lawn", "polygon": [[-28.0, -18.0], [-14.0, -18.0], [-14.0, 14.0], [-28.0, 14.0]]}, + {"type": "mulch", "polygon": [[-14.0, 0.0], [22.0, 0.0], [22.0, 4.0], [-14.0, 4.0]]}, + {"type": "lawn", "polygon": [[-14.0, 4.0], [22.0, 4.0], [22.0, 14.0], [-14.0, 14.0]]}, + {"type": "drive", "polygon": [[22.5, 0.0], [34.0, 0.0], [34.0, 30.0], [22.5, 30.0]]} + ], + "overheads": [], + "facing": [{"wall_id": "side", "span_ft": [-14.0, 22.0], "depth_ft": 14.0, "plus_minus_ft": 0.5}], + "coverage": { + "ends": {"left": {"kind": "unexplored", "note": "back wall continues past the patio"}, + "right": {"kind": "limit", "note": "front corner, driveway"}}, + "observed": [ + {"band": "wall", "span_ft": [-26.0, 22.0]}, + {"band": "ground", "span_ft": [-26.0, 26.0], "out_ft": 14.0}, + {"band": "overhead", "span_ft": [-26.0, 22.0]}, + {"band": "facing", "span_ft": [-26.0, 22.0]} + ] + }, + "keyframes": [ + {"id": "k1", "pose": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 2.0, 4.5, 9.0, 1], + "intrinsics": [1450.0, 1450.0, 960.0, 720.0], "w": 1920, "h": 1440, "img": "k1.jpg"} + ], + "stills": {"meter_close": "meter_close.jpg"}, + "gps": {"lat": 30.2672, "lon": -97.7431}, + "heading": {"deg": 181.0, "accuracy_deg": 12.0} +} diff --git a/server/tests/helpers.py b/server/tests/helpers.py new file mode 100644 index 000000000..088a4b3ba --- /dev/null +++ b/server/tests/helpers.py @@ -0,0 +1,116 @@ +"""Builders for synthetic test scenes and rules. Everything here is synthetic; no real home data. + +`shared_fixture()` is the public golden tests' shared fixture (docs/research/t3-lane-c-review.md on +t3/research): wall w1 is straight with s = x, the meter at 0 and outward +z; the only usable pad is +level ground at s = [6, 9]; both ends observed; facing gap and headroom 9; exact geometry. +""" + +import copy +from typing import Any + +from rules import LoadedRules, deep_merge, public_rules_dict, rules_from_dict +from scene import Scene, parse_scene +from solver import Check, evaluate_start, solve + +W = 31 / 12 # battery width (31 in) +D = 11 / 6 # battery depth (22 in) + +# Test settings, not Base policy. +GOLDEN_RULES: dict[str, Any] = { + "policy": {"id": "golden-test", "version": "1", "auto_approve": True, "allow_reject": True}, + "errors": { + k: {"value": 0.0, "source": "exact test geometry"} + for k in ("tap_ft", "vlm_ft", "mesh_ft", "tape_ft", "wall_ft", "meter_ft", "drift_per_ft") + }, + "facing": {"min_ft": {"value": 5.0, "source": "test"}, "measured_from": "wall"}, + "route": { + "confident_reach_ft": {"value": 15.0, "source": "test"}, + "max_ft": {"value": 20.0, "source": "test"}, + "corner_allowance_ft": {"value": 0.0, "source": "test"}, + }, +} + + +def golden_rules(**overrides: Any) -> LoadedRules: + return rules_from_dict(deep_merge(deep_merge(public_rules_dict(), GOLDEN_RULES), overrides)) + + +def rect(x0: float, x1: float, z0: float, z1: float) -> list[list[float]]: + return [[x0, z0], [x1, z0], [x1, z1], [x0, z1]] + + +def everything_observed(lo: float = -60, hi: float = 60, out: float = 30) -> dict[str, Any]: + return { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": "wall", "span_ft": [lo, hi]}, + {"band": "ground", "span_ft": [lo, hi], "out_ft": out}, + {"band": "overhead", "span_ft": [lo, hi]}, + {"band": "facing", "span_ft": [lo, hi]}, + ], + } + + +def pads_ground( + pads: list[tuple[float, float]], lo: float = -40, hi: float = 40, depth: float = 30 +) -> list[dict[str, Any]]: + """Lawn over each pad [x0, x1] on a straight wall along z = 0 (outward +z); deck elsewhere.""" + ground = [] + edges = [lo] + for x0, x1 in sorted(pads): + ground.append({"type": "lawn", "polygon": rect(x0, x1, 0, depth), "plus_minus_ft": 0}) + edges += [x0, x1] + edges.append(hi) + for a, b in zip(edges[::2], edges[1::2], strict=True): + if b > a: + ground.append({"type": "deck", "polygon": rect(a, b, 0, depth), "plus_minus_ft": 0}) + return ground + + +def shared_fixture() -> dict[str, Any]: + return { + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "w1", "plus_minus_ft": 0}, + "walls": [ + {"id": "w1", "baseline": [[-40, 0], [40, 0]], "height_ft": 9, "plus_minus_ft": 0} + ], + "objects": [], + "ground": pads_ground([(6, 9)]), + "overheads": [ + {"wall_id": "w1", "span_ft": [-40, 40], "clearance_ft": 9, "plus_minus_ft": 0} + ], + "facing": [{"wall_id": "w1", "span_ft": [-40, 40], "depth_ft": 9, "plus_minus_ft": 0}], + "coverage": everything_observed(-40, 40), + } + + +def observed_band( + raw: dict[str, Any], band: str, spans: list[tuple[float, float]], out: float = 30 +) -> None: + """Replace one coverage band with the given observed spans.""" + extra = {"out_ft": out} if band == "ground" else {} + raw["coverage"]["observed"] = [o for o in raw["coverage"]["observed"] if o["band"] != band] + [ + {"band": band, "span_ft": list(s), **extra} for s in spans + ] + + +def parsed(raw: dict[str, Any], rules: LoadedRules | None = None) -> Scene: + loaded = rules or golden_rules() + return parse_scene(copy.deepcopy(raw), loaded.rules) + + +def run(raw: dict[str, Any], rules: LoadedRules | None = None) -> dict[str, Any]: + loaded = rules or golden_rules() + return solve(parsed(raw, loaded), loaded) + + +def check(result: dict[str, Any], check_id: str) -> dict[str, Any]: + return next(c for c in result["checks"] if c["id"] == check_id) + + +def at_start( + raw: dict[str, Any], s0: float, check_id: str, rules: LoadedRules | None = None +) -> Check: + """One check of the candidate starting at s0, evaluated directly.""" + loaded = rules or golden_rules() + candidate = evaluate_start(parsed(raw, loaded), loaded, s0) + return next(c for c in candidate.checks if c.id == check_id) diff --git a/server/tests/test_api.py b/server/tests/test_api.py new file mode 100644 index 000000000..f072b19a0 --- /dev/null +++ b/server/tests/test_api.py @@ -0,0 +1,377 @@ +import copy +import html +import io +import json +import time +import zipfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from jsonschema import Draft202012Validator + +import api +from scene import parse_scene +from solver import solve + +SERVER = Path(__file__).resolve().parents[1] +RESULT_VALIDATOR = Draft202012Validator( + json.loads((SERVER / "schemas" / "result.schema.json").read_text()) +) +EXAMPLE_BYTES = (SERVER / "tests" / "fixtures" / "example-scene.json").read_bytes() +EXAMPLE = json.loads(EXAMPLE_BYTES) +# A short wall solves in milliseconds; tests about transport, not placement, upload this one. It +# names one keyframe image and one still, like a real bundle. +SMALL = { + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "w1"}, + "walls": [{"id": "w1", "baseline": [[-8.0, 0.0], [8.0, 0.0]], "height_ft": 9}], + "ground": [{"type": "lawn", "polygon": [[-8, 0], [8, 0], [8, 20], [-8, 20]]}], + "coverage": { + "observed": [ + {"band": "wall", "span_ft": [-8, 8]}, + {"band": "ground", "span_ft": [-8, 8], "out_ft": 20}, + ] + }, + "keyframes": [ + { + "id": "k1", + "pose": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 2.0, 4.5, 9.0, 1], + "intrinsics": [1450.0, 1450.0, 960.0, 720.0], + "w": 1920, + "h": 1440, + "img": "k1.jpg", + } + ], + "stills": {"meter_close": "meter_close.jpg"}, +} +SMALL_BYTES = json.dumps(SMALL).encode() +JPEG = b"\xff\xd8\xff\xe0 synthetic test image \xff\xd9" +PLACEMENTS = "/v1/placements" +SITE_PLAN = "/v1/placements/site-plan.svg" +_JSON = {"content-type": "application/json"} + + +@pytest.fixture(scope="module") +def client() -> TestClient: + return TestClient(api.app) + + +def bundle(files: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for name, data in files.items(): + zf.writestr(name, data) + return buf.getvalue() + + +def example_bundle( + prefix: str = "", drop: str | None = None, scene: bytes = EXAMPLE_BYTES +) -> bytes: + files = {"scene.json": scene, "k1.jpg": JPEG, "meter_close.jpg": JPEG} + return bundle({prefix + k: v for k, v in files.items() if k != drop}) + + +def post_zip(client: TestClient, data: bytes, media: str = "application/zip"): + return client.post(PLACEMENTS, content=data, headers={"content-type": media}) + + +def error(resp) -> dict: + body = resp.json() + assert set(body) == {"error"}, body + assert set(body["error"]) == {"code", "message", "path"}, body + return body["error"] + + +def same_decision(a: dict, b: dict) -> None: + assert a["decision"] == b["decision"] + assert a["spot"] == b["spot"] + assert [(c["id"], c["outcome"]) for c in a["checks"]] == [ + (c["id"], c["outcome"]) for c in b["checks"] + ] + + +# --- health --------------------------------------------------------------------------------------- + + +def test_health_reports_the_loaded_policy(client: TestClient) -> None: + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["schema_version"] == "1.0" + policy = api.LOADED.rules.policy + assert body["policy"] == { + "id": policy.id, + "version": policy.version, + "auto_approve": policy.auto_approve and policy.id is not None, + "sources": list(api.LOADED.sources), + "rules_sha256": api.LOADED.sha256, + "notice": policy.notice, + } + + +def test_health_policy_matches_what_a_placement_reports(client: TestClient) -> None: + placed = client.post(PLACEMENTS, content=SMALL_BYTES, headers=_JSON).json() + assert client.get("/health").json()["policy"] == placed["policy"] + + +# --- accepted uploads ----------------------------------------------------------------------------- + + +def test_bare_json_returns_a_schema_valid_result(client: TestClient) -> None: + resp = client.post(PLACEMENTS, content=EXAMPLE_BYTES, headers=_JSON) + assert resp.status_code == 200, resp.text + result = resp.json() + RESULT_VALIDATOR.validate(result) + direct = solve(parse_scene(copy.deepcopy(EXAMPLE), api.LOADED.rules), api.LOADED) + same_decision(result, direct) + + +def test_zip_bundle_gives_the_same_decision_as_bare_json(client: TestClient) -> None: + bare = client.post(PLACEMENTS, content=EXAMPLE_BYTES, headers=_JSON).json() + resp = post_zip(client, example_bundle()) + assert resp.status_code == 200, resp.text + zipped = resp.json() + RESULT_VALIDATOR.validate(zipped) + same_decision(zipped, bare) + # The input hash is over scene.json's bytes, so both routes name the same input. + assert zipped["stats"]["input_sha256"] == bare["stats"]["input_sha256"] + + +@pytest.mark.parametrize("media", ["application/octet-stream", "application/json"]) +def test_zip_is_recognised_by_its_magic_bytes(client: TestClient, media: str) -> None: + resp = post_zip(client, example_bundle(scene=SMALL_BYTES), media) + assert resp.status_code == 200, resp.text + + +def test_zip_with_scene_inside_one_top_level_folder(client: TestClient) -> None: + data = example_bundle("capture-2026-09-26/", scene=SMALL_BYTES) + # A macOS Finder zip also carries __MACOSX/, which must not count as a second folder. + with zipfile.ZipFile(io.BytesIO(data), "a") as zf: + zf.writestr("__MACOSX/capture-2026-09-26/._scene.json", b"resource fork") + assert post_zip(client, data).status_code == 200 + + +def test_multipart_bundle_field_accepts_zip_and_json(client: TestClient) -> None: + as_zip = client.post( + PLACEMENTS, + files={"bundle": ("capture.zip", example_bundle(scene=SMALL_BYTES), "application/zip")}, + ) + as_json = client.post( + PLACEMENTS, files={"bundle": ("scene.json", SMALL_BYTES, "application/json")} + ) + assert as_zip.status_code == 200, as_zip.text + assert as_json.status_code == 200, as_json.text + same_decision(as_zip.json(), as_json.json()) + + +# --- refusals ------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("missing", "path"), + [("k1.jpg", "/keyframes/0/img"), ("meter_close.jpg", "/stills/meter_close")], +) +def test_zip_missing_a_referenced_image_is_422_naming_it( + client: TestClient, missing: str, path: str +) -> None: + resp = post_zip(client, example_bundle(drop=missing)) + assert resp.status_code == 422 + err = error(resp) + assert err["code"] == "missing_bundle_file" + assert missing in err["message"] + assert err["path"] == path + + +def test_bare_json_skips_the_image_check(client: TestClient) -> None: + # SMALL names k1.jpg and meter_close.jpg; a bare upload carries no files to check. + assert client.post(PLACEMENTS, content=SMALL_BYTES, headers=_JSON).status_code == 200 + + +@pytest.mark.parametrize("name", ["../x", "a/../../x", "/etc/x", "C:/x", "..\\x"]) +def test_zip_entry_escaping_the_bundle_is_400(client: TestClient, name: str) -> None: + data = bundle({"scene.json": EXAMPLE_BYTES, "k1.jpg": JPEG, "meter_close.jpg": JPEG, name: b""}) + resp = post_zip(client, data) + assert resp.status_code == 400 + err = error(resp) + assert err["code"] == "unsafe_zip_entry" + assert repr(name) in err["message"] + + +def test_zip_without_scene_json_is_400(client: TestClient) -> None: + resp = post_zip(client, bundle({"a/scene.json": b"{}", "b/k1.jpg": JPEG})) + assert resp.status_code == 400 + assert error(resp)["code"] == "missing_scene_json" + + +def test_corrupt_zip_is_400(client: TestClient) -> None: + resp = post_zip(client, b"PK\x03\x04 not really a zip") + assert resp.status_code == 400 + assert error(resp)["code"] == "unreadable_zip" + + +def test_malformed_json_is_422(client: TestClient) -> None: + resp = client.post(PLACEMENTS, content=b'{"meter": ', headers=_JSON) + assert resp.status_code == 422 + err = error(resp) + assert err["code"] == "invalid_json" + assert err["path"] is None + + +def test_nan_is_not_json(client: TestClient) -> None: + text = EXAMPLE_BYTES.decode().replace('"plus_minus_ft": 0.3}', '"plus_minus_ft": NaN}', 1) + resp = client.post(PLACEMENTS, content=text.encode(), headers=_JSON) + assert resp.status_code == 422 + assert error(resp)["code"] == "invalid_json" + + +def test_unknown_scene_field_is_422_with_its_path(client: TestClient) -> None: + scene = copy.deepcopy(EXAMPLE) + scene["objects"][1]["plusminus_ft"] = 0.3 + resp = client.post(PLACEMENTS, json=scene) + assert resp.status_code == 422 + err = error(resp) + assert err["code"] == "invalid_scene" + assert err["path"] == "/objects/1" + assert "plusminus_ft" in err["message"] + + +def test_scene_error_after_the_schema_carries_its_path(client: TestClient) -> None: + scene = copy.deepcopy(EXAMPLE) + scene["meter"]["wall_id"] = "nowhere" + resp = client.post(PLACEMENTS, json=scene) + assert resp.status_code == 422 + assert error(resp)["path"] == "/meter/wall_id" + + +@pytest.mark.parametrize("media", ["text/plain", "application/xml", "image/jpeg"]) +def test_unsupported_content_type_is_415(client: TestClient, media: str) -> None: + resp = client.post(PLACEMENTS, content=EXAMPLE_BYTES, headers={"content-type": media}) + assert resp.status_code == 415 + assert error(resp)["code"] == "unsupported_media_type" + + +def test_oversized_body_is_413(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(api, "MAX_UPLOAD_BYTES", len(EXAMPLE_BYTES) - 1) + resp = client.post(PLACEMENTS, content=EXAMPLE_BYTES, headers=_JSON) + assert resp.status_code == 413 + assert error(resp)["code"] == "body_too_large" + + +def test_oversized_body_without_content_length_is_413( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(api, "MAX_UPLOAD_BYTES", 1000) + + def chunks(): + for _ in range(10): + yield b" " * 200 + + resp = client.post(PLACEMENTS, content=chunks(), headers=_JSON) + assert resp.status_code == 413 + + +def test_bundle_that_unpacks_too_large_is_413( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(api, "MAX_UNZIPPED_BYTES", len(EXAMPLE_BYTES) + 10) + resp = post_zip(client, example_bundle()) + assert resp.status_code == 413 + assert error(resp)["code"] == "bundle_too_large" + + +def test_empty_body_is_400(client: TestClient) -> None: + resp = client.post(PLACEMENTS, content=b"", headers=_JSON) + assert resp.status_code == 400 + assert error(resp)["code"] == "empty_body" + + +def test_multipart_with_a_field_other_than_bundle_is_400(client: TestClient) -> None: + resp = client.post(PLACEMENTS, files={"scene": ("scene.json", EXAMPLE_BYTES)}) + assert resp.status_code == 400 + assert error(resp)["code"] == "unexpected_form_field" + + +def test_multipart_with_two_files_is_400(client: TestClient) -> None: + resp = client.post( + PLACEMENTS, + files=[("bundle", ("a.json", EXAMPLE_BYTES)), ("bundle", ("b.json", EXAMPLE_BYTES))], + ) + assert resp.status_code == 400 + assert error(resp)["code"] == "unreadable_multipart" + + +def test_unknown_route_uses_the_error_envelope(client: TestClient) -> None: + resp = client.get("/v1/nothing-here") + assert resp.status_code == 404 + assert error(resp)["code"] == "not_found" + + +@pytest.mark.parametrize("value", ["abc", "0", "-5", "inf", "nan"]) +def test_bad_size_limit_env_fails_loudly(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setenv("HOUSESCAN_MAX_UPLOAD_MB", value) + with pytest.raises(ValueError, match="HOUSESCAN_MAX_UPLOAD_MB"): + api._limit_bytes("HOUSESCAN_MAX_UPLOAD_MB", 256) + + +def test_size_limit_env_is_megabytes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOUSESCAN_MAX_UPLOAD_MB", "1.5") + assert api._limit_bytes("HOUSESCAN_MAX_UPLOAD_MB", 256) == 3 * 512 * 1024 + + +# --- CORS ----------------------------------------------------------------------------------------- + + +def test_cors_preflight_allows_any_origin(client: TestClient) -> None: + resp = client.options( + PLACEMENTS, + headers={ + "origin": "https://example.org", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type", + }, + ) + assert resp.status_code == 200 + assert resp.headers["access-control-allow-origin"] == "*" + assert "POST" in resp.headers["access-control-allow-methods"] + + +# --- site plan ------------------------------------------------------------------------------------ + + +def test_site_plan_is_svg_for_the_same_upload(client: TestClient) -> None: + data = example_bundle(scene=SMALL_BYTES) + placed = post_zip(client, data).json() + svg = client.post(SITE_PLAN, content=data, headers={"content-type": "application/zip"}) + assert svg.status_code == 200 + assert svg.headers["content-type"].startswith("image/svg+xml") + assert svg.text.startswith(" None: + resp = client.post(SITE_PLAN, content=b"{", headers=_JSON) + assert resp.status_code == 422 + assert error(resp)["code"] == "invalid_json" + + +# --- speed ---------------------------------------------------------------------------------------- + + +def test_solving_the_example_takes_under_a_second() -> None: + scene = parse_scene(copy.deepcopy(EXAMPLE), api.LOADED.rules) + started = time.perf_counter() + solve(scene, api.LOADED) + assert time.perf_counter() - started < 1.0 + + +# --- OpenAPI -------------------------------------------------------------------------------------- + + +def test_openapi_publishes_the_scene_request_body(client: TestClient) -> None: + # Clients (the verification harness, a future SDK) discover the upload formats here. + paths = client.get("/openapi.json").json()["paths"] + for path in ("/v1/placements", "/v1/placements/site-plan.svg"): + content = paths[path]["post"]["requestBody"]["content"] + assert set(content) == {"application/json", "application/zip", "multipart/form-data"} + assert content["multipart/form-data"]["schema"]["required"] == ["bundle"] diff --git a/server/tests/test_capture_contract.py b/server/tests/test_capture_contract.py new file mode 100644 index 000000000..e7ca3453b --- /dev/null +++ b/server/tests/test_capture_contract.py @@ -0,0 +1,236 @@ +"""The capture contract (README, "What settles each check"): supplying exactly what a request +names settles it, and the depth a walked path or a tilt-up view proves is credited.""" + +import copy +import json +from pathlib import Path + +import jsonschema +from helpers import D, at_start, observed_band, parsed, shared_fixture +from test_s4_round import PUBLIC, answer + +from scene import COVERAGE_TOLERANCE_FT +from solver import PASS, UNSURE, evaluate_start + +RESULT_SCHEMA = json.loads( + (Path(__file__).resolve().parents[1] / "schemas" / "result.schema.json").read_text() +) +FACING_NEED = D + 3.0 # battery depth plus facing.min_ft +HEADROOM_NEED = 6.5 # headroom.min_ft + + +def supplied(raw: dict, result: dict) -> dict: + """The scene after the app supplies exactly the coverage each band request names.""" + out = copy.deepcopy(raw) + for item in result["missing_evidence"]: + if item["kind"] == "band": + depth = {"out_ft": item["out_ft"]} if "out_ft" in item else {} + out["coverage"]["observed"].append( + {"band": item["band"], "span_ft": item["span_ft"], **depth} + ) + return out + + +def seen_clear(raw: dict, band: str, out_ft: float) -> None: + observed_band(raw, band, []) + raw["coverage"]["observed"].append({"band": band, "span_ft": [-40, 40], "out_ft": out_ft}) + + +def walked(out_ft: float) -> dict: + """No facing measurements; the facing band is known clear only as far as the homeowner + walked from the wall.""" + raw = shared_fixture() + raw["facing"] = [] + seen_clear(raw, "facing", out_ft) + return raw + + +def test_a_walked_path_far_enough_out_settles_the_facing_gap() -> None: + facing = at_start(walked(FACING_NEED + 0.5), 6.0, "facing_gap", PUBLIC) + assert facing.outcome == PASS + + +def test_a_walked_path_too_close_is_unsure_and_asks_how_far() -> None: + raw = walked(FACING_NEED - 1) + facing = at_start(raw, 6.0, "facing_gap", PUBLIC) + assert (facing.outcome, facing.unsure_cause) == (UNSURE, "unobserved") + request = next(m for m in answer(raw)["missing_evidence"] if m.get("band") == "facing") + assert request["out_ft"] > FACING_NEED + + +def test_supplying_the_facing_request_settles_it() -> None: + raw = walked(FACING_NEED - 1) + after = answer(supplied(raw, answer(raw))) + assert next(c for c in after["checks"] if c["id"] == "facing_gap")["outcome"] == PASS + + +def test_a_measured_gap_is_not_capped_by_a_shorter_walk() -> None: + # A mesh measurement over the whole stretch says where the obstruction is; how far the + # homeowner walked adds nothing there. + raw = walked(2.0) + raw["facing"] = [{"wall_id": "w1", "span_ft": [-40, 40], "depth_ft": 9, "plus_minus_ft": 0}] + assert at_start(raw, 6.0, "facing_gap", PUBLIC).outcome == PASS + + +def tilted_up(out_ft: float) -> dict: + raw = shared_fixture() + raw["overheads"] = [] + seen_clear(raw, "overhead", out_ft) + return raw + + +def test_a_tilt_up_view_high_enough_settles_headroom() -> None: + assert at_start(tilted_up(HEADROOM_NEED + 0.5), 6.0, "headroom", PUBLIC).outcome == PASS + + +def test_supplying_the_overhead_request_settles_it() -> None: + raw = tilted_up(HEADROOM_NEED - 2) + result = answer(raw) + request = next(m for m in result["missing_evidence"] if m.get("band") == "overhead") + assert request["out_ft"] > HEADROOM_NEED + after = answer(supplied(raw, result)) + assert next(c for c in after["checks"] if c["id"] == "headroom")["outcome"] == PASS + + +def shallow_ground() -> dict: + """The app's current export: ground seen 1.2 m (3.94 ft) out everywhere.""" + raw = shared_fixture() + observed_band(raw, "ground", [(-40, 40)], out=3.94) + return raw + + +def test_a_ground_request_names_how_far_out() -> None: + result = answer(shallow_ground()) + request = next(m for m in result["missing_evidence"] if m.get("band") == "ground") + # The pool clearance reaches its 10 ft radius past the battery's depth. + assert request["out_ft"] >= D + 10.0 + jsonschema.validate(result, RESULT_SCHEMA) + + +def test_supplying_the_ground_request_settles_it() -> None: + raw = shallow_ground() + after = answer(supplied(raw, answer(raw))) + unseen = [c["id"] for c in after["checks"] if c.get("unsure_cause") == "unobserved"] + assert unseen == [] + + +# --- a corner the homeowner marks ----------------------------------------------------------------- + + +def around_a_corner(next_wall_ft: float, right_end: str) -> dict: + """The meter wall turns a convex corner 10 ft right of the meter, and the app sends the next + wall as the next piece of the chain, as far as the homeowner followed it.""" + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-20, 0], [10, 0]], "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[10, 0], [10, -next_wall_ft]], "plus_minus_ft": 0}, + ] + top = 10 + next_wall_ft + raw["overheads"] = [{"wall_id": "w1", "span_ft": [-20, 10], "clearance_ft": 9}] + raw["facing"] = [{"wall_id": "w1", "span_ft": [-20, 10], "depth_ft": 9}] + raw["ground"] = [ + {"type": "lawn", "polygon": [[-40, 0], [40, 0], [40, 30], [-40, 30]], "plus_minus_ft": 0}, + {"type": "lawn", "polygon": [[10, 0], [40, 0], [40, -40], [10, -40]], "plus_minus_ft": 0}, + ] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": right_end}}, + "observed": [ + {"band": "wall", "span_ft": [-20, top]}, + {"band": "ground", "span_ft": [-20 - 20, top + 20], "out_ft": 30}, + {"band": "overhead", "span_ft": [-20, top]}, + {"band": "facing", "span_ft": [-20, top]}, + ], + } + return raw + + +def test_a_corner_sent_as_the_next_piece_asks_for_no_walk() -> None: + # Followed round the corner past cable reach, the chain has no open end within reach. + result = answer(around_a_corner(20.0, "unexplored")) + assert result["ends"]["right"]["beyond_reach"] is True + assert not [m for m in result["missing_evidence"] if m["kind"] == "past_end"] + + +def test_a_corner_left_unexplored_within_reach_is_a_walk_request_when_no_spot_passes() -> None: + # Stopping at the corner: the wall continues, so a spot round it may exist within reach. + raw = around_a_corner(20.0, "unexplored") + raw["walls"] = raw["walls"][:1] + raw["ground"] = [{"type": "deck", "polygon": [[-40, 0], [40, 0], [40, 30], [-40, 30]]}] + result = answer(raw) + assert result["ends"]["right"]["beyond_reach"] is False + assert any(m["kind"] == "past_end" for m in result["missing_evidence"]) + + +# --- the README's table, exactly ------------------------------------------------------------------ + +S0 = 6.0 +S1 = S0 + 31 / 12 +# The README's coverage for a battery at [S0, S1] on a wall with no position error (e = 0). +EXACT = { + "wall": (0.0, S1 + 3), # route from the meter, and gas and openings 3 ft either side + "ground": (S0 - 10, S1 + 10, D + 10), # the pool clearance reaches furthest + "facing": (S0, S1), + "overhead": (S0, S1), +} + + +def readme_coverage(**trim: float) -> dict: + raw = shared_fixture() + observed = [] + for band, span in EXACT.items(): + cut = trim.get(band, 0.0) + if band == "ground": + a, b, out = span + observed.append({"band": band, "span_ft": [a, b], "out_ft": out - cut}) + else: + observed.append({"band": band, "span_ft": [span[0], span[1] - cut]}) + raw["coverage"]["observed"] = observed + return raw + + +def unobserved_at_spot(raw: dict) -> list[str]: + candidate = evaluate_start(parsed(raw, PUBLIC), PUBLIC, S0) + return [c.id for c in candidate.checks if c.unsure_cause == "unobserved"] + + +def test_the_readme_coverage_settles_every_check() -> None: + assert unobserved_at_spot(readme_coverage()) == [] + + +def test_less_than_the_readme_coverage_leaves_a_check_unseen() -> None: + for band in EXACT: + # Twice the coverage tolerance: narrower gaps are float noise and count as seen. + assert unobserved_at_spot(readme_coverage(**{band: 2 * COVERAGE_TOLERANCE_FT})) != [], band + + +def test_a_request_ending_at_the_meter_reads_naturally() -> None: + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 0), (3, 40)]) # the cable's first 3 ft unseen + messages = [m["message"] for m in answer(raw)["missing_evidence"] if m.get("band") == "wall"] + # The cable runs 1 ft up (route.height_ft), so that is how high the view must reach. + assert messages == [ + "Show the wall from the meter to 3 ft 0 in right of the meter, seen at least 1 ft 0 in " + "up the wall." + ] + + +# --- float noise at the end of the wall ----------------------------------------------------------- + + +def wall_seen_to_a_hair_short() -> dict: + """S4's replay upload: the unrolled wall ends at 11.00006 ft and the app reports it seen to + 11 ft. The gap is floating point noise, far under any error, not an unseen stretch.""" + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [11.00006, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-40, 11.00006] + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} # as the app sent it + observed_band(raw, "wall", [(-40, 11)]) + return raw + + +def test_a_hairline_gap_at_the_wall_end_asks_for_nothing() -> None: + # Before: "Show the wall from 11 ft 0 in right of the meter to 11 ft 0 in right of the meter". + result = answer(wall_seen_to_a_hair_short()) + assert [m for m in result["missing_evidence"] if m.get("band") == "wall"] == [] + opening = at_start(wall_seen_to_a_hair_short(), 6.0, "opening_clearance", PUBLIC) + assert [v for v in opening.all_missing() if v.band == "wall"] == [] diff --git a/server/tests/test_coderabbit_review.py b/server/tests/test_coderabbit_review.py new file mode 100644 index 000000000..3592d10e3 --- /dev/null +++ b/server/tests/test_coderabbit_review.py @@ -0,0 +1,148 @@ +"""Regression tests for CodeRabbit's review of #11; each failed before its fix.""" + +import copy +from pathlib import Path + +import pytest +from helpers import at_start, golden_rules, observed_band, parsed, run, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st + +from rules import load_rules +from scene import SceneError +from solver import SOLVE_BUDGET_S, UNSURE, Solver, evaluate_start + +# --- missing_evidence names every unseen band ------------------------------------------------ + + +def both_bands_unseen() -> dict: + """The lawn pad at s 6 to 9 is the only spot; wall and ground are unseen from 9.5 to 11, + inside the gas clearance's reach but not behind or under the battery.""" + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 9.5), (11, 40)]) + observed_band(raw, "ground", [(-40, 9.5), (11, 40)]) + return raw + + +def test_a_check_reports_every_band_it_did_not_see() -> None: + # Before: gas_clearance (ground and wall) reported only the ground, so a recapture of the + # ground left it UNSURE on the wall. + gas = at_start(both_bands_unseen(), 6.0, "gas_clearance") + assert (gas.outcome, gas.unsure_cause) == (UNSURE, "unobserved") + assert {view.band for view in gas.all_missing()} == {"ground", "wall"} + + +def test_missing_evidence_lists_the_check_under_each_band() -> None: + result = run(both_bands_unseen()) + for band in ("ground", "wall"): + item = next(m for m in result["missing_evidence"] if m.get("band") == band) + assert "gas_clearance" in item["checks"], item + + +BANDS = ("wall", "ground", "overhead", "facing") + + +@st.composite +def partly_seen_scenes(draw: st.DrawFn) -> dict: + """The shared wall with random gaps in each band's coverage and a gas meter somewhere.""" + raw = shared_fixture() + gas_at = draw(st.floats(min_value=-12, max_value=20)) + raw["objects"] = [ + { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [gas_at, gas_at + 1], + "bottom_ft": 0.5, + "top_ft": 2.5, + "source": "tape", + "plus_minus_ft": 0.05, + "footprint": [[gas_at, 0], [gas_at + 1, 0], [gas_at + 1, 1], [gas_at, 1]], + } + ] + for band in BANDS: + # Pairs of cuts: each pair is a gap in the band's coverage. + cuts = draw(st.lists(st.floats(min_value=-15, max_value=25), max_size=4)) + edges = [-40.0, *sorted(cuts[: len(cuts) // 2 * 2]), 40.0] + spans = [(a, b) for a, b in zip(edges[::2], edges[1::2], strict=True) if b > a] + out = draw(st.floats(min_value=1, max_value=30)) + observed_band(raw, band, spans or [(-40.0, -39.0)], out) + if band in ("facing", "overhead") and draw(st.booleans()): + for o in raw["coverage"]["observed"]: + if o["band"] == band: + o["out_ft"] = draw(st.floats(1, 12)) + return raw + + +@settings(max_examples=60, deadline=None) +@given(raw=partly_seen_scenes()) +def test_capturing_what_missing_evidence_asks_for_settles_coverage(raw: dict) -> None: + # With equal gas and opening clearances, the opening check asks for the same stretch of wall + # and hides a gas check that forgets it; rules may set them apart. + rules = golden_rules(clearances={"opening_ft": {"value": 1.0}}) + result = run(raw, rules) + # missing_evidence is the list of views that would settle a manual review; a pass or a + # reject lists none. + spot = result["spot"] + if result["decision"] != "manual_review" or spot is None: + return + # The capture shows exactly what each request names, depth included. + captured = copy.deepcopy(raw) + for item in result["missing_evidence"]: + if item["kind"] == "band": + depth = {"out_ft": item["out_ft"]} if "out_ft" in item else {} + captured["coverage"]["observed"].append( + {"band": item["band"], "span_ft": item["span_ft"], **depth} + ) + # The answer rounds the spot to 6 decimals; evaluate the exact start the solver chose, since + # a start rounded down can reach a sliver of a stretch that was never missing. + exact = min( + (c.s0 for c in Solver(parsed(raw, rules), rules).candidates(SOLVE_BUDGET_S)), + key=lambda s0: abs(s0 - spot["span_ft"][0]), + ) + after = evaluate_start(parsed(captured, rules), rules, exact) + unseen = [c.id for c in after.checks if c.outcome == UNSURE and c.unsure_cause == "unobserved"] + assert unseen == [], (unseen, result["missing_evidence"]) + + +# --- duplicate keys in rules files -------------------------------------------------------------- + + +def test_a_duplicate_key_in_the_private_rules_is_refused(tmp_path: Path) -> None: + private = tmp_path / "rules.yaml" + private.write_text( + "clearances:\n gas_ft: {value: 4.0, source: 'first'}\n" + "clearances:\n ac_ft: {value: 4.0, source: 'second'}\n" + ) + with pytest.raises(ValueError, match=r"duplicate key 'clearances'"): + load_rules(private) + + +def test_a_duplicate_nested_key_is_refused(tmp_path: Path) -> None: + private = tmp_path / "rules.yaml" + private.write_text( + "clearances:\n gas_ft: {value: 4.0, source: 'a'}\n gas_ft: {value: 5.0, source: 'b'}\n" + ) + with pytest.raises(ValueError, match=r"duplicate key 'gas_ft'"): + load_rules(private) + + +# --- attrs rejects unknown keys ------------------------------------------------------------------- + + +def test_a_misspelled_attribute_is_refused() -> None: + # Before: {"operabel": false} passed and read as unknown, so a typo sent the spot to review. + raw = shared_fixture() + raw["objects"] = [ + { + "type": "window", + "wall_id": "w1", + "span_ft": [-14, -11], + "bottom_ft": 3, + "top_ft": 7, + "attrs": {"operabel": False}, + "source": "tap", + } + ] + with pytest.raises(SceneError) as caught: + parsed(raw) + assert caught.value.path == "/objects/0/attrs" diff --git a/server/tests/test_codex_review.py b/server/tests/test_codex_review.py new file mode 100644 index 000000000..9f846dbf4 --- /dev/null +++ b/server/tests/test_codex_review.py @@ -0,0 +1,250 @@ +"""Regression tests for the Codex review of #11 at 6c7ca23; each failed before its fix.""" + +from pathlib import Path + +import pytest +from helpers import at_start, observed_band, parsed, rect, shared_fixture +from test_s4_round import PUBLIC, answer + +from rules import deep_merge, load_rules, public_rules_dict, rules_from_dict +from solver import FAIL, PASS, UNSURE, reach_outcome + +W = 31 / 12 + + +# --- 1. a declared wall height --------------------------------------------------------------- + + +def test_a_wall_lower_than_the_battery_fails_the_backing() -> None: + # Before: height_ft was dropped, so a 2 ft wall passed a 3.29 ft battery. + raw = shared_fixture() + raw["walls"][0]["height_ft"] = 2 + raw["meter"]["pos"][1] = 1 + assert at_start(raw, 6.0, "wall_backing", PUBLIC).outcome == FAIL + + +def test_a_tall_enough_or_undeclared_wall_is_unchanged() -> None: + assert at_start(shared_fixture(), 6.0, "wall_backing", PUBLIC).outcome == PASS # 9 ft + raw = shared_fixture() + del raw["walls"][0]["height_ft"] + assert at_start(raw, 6.0, "wall_backing", PUBLIC).outcome == PASS + + +# --- 2. facing and headroom coverage over every position the battery may have ------------------ + + +def seen_only_over_the_nominal_spot() -> dict: + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.5 + raw["ground"] = [{"type": "lawn", "polygon": rect(-40, 40, 0, 30), "plus_minus_ft": 0}] + raw["facing"], raw["overheads"] = [], [] + for band in ("facing", "overhead"): + observed_band(raw, band, []) + raw["coverage"]["observed"].append({"band": band, "span_ft": [6, 6 + W], "out_ft": 9}) + return raw + + +def test_views_of_only_the_nominal_stretch_do_not_settle_facing_or_headroom() -> None: + # Before: coverage of exactly [6, 6 + W] passed, though the battery may sit 0.5 ft either way. + raw = seen_only_over_the_nominal_spot() + for check_id in ("facing_gap", "headroom"): + check = at_start(raw, 6.0, check_id, PUBLIC) + assert (check.outcome, check.unsure_cause) == (UNSURE, "unobserved"), check_id + + +def test_a_low_overhang_within_the_error_is_unsure() -> None: + raw = seen_only_over_the_nominal_spot() + raw["overheads"] = [ + {"wall_id": "w1", "span_ft": [5.6, 5.9], "clearance_ft": 2, "plus_minus_ft": 0} + ] + observed_band(raw, "overhead", [(-40, 40)]) + assert at_start(raw, 6.0, "headroom", PUBLIC).outcome == UNSURE + + +# --- 3. a real gap between walls ------------------------------------------------------------------ + + +def two_walls(error: float) -> dict: + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-40, 0], [3, 0]], "height_ft": 9, "plus_minus_ft": error}, + {"id": "w2", "baseline": [[3.5, 0], [40, 0]], "height_ft": 9, "plus_minus_ft": error}, + ] + return raw + + +def test_a_gap_larger_than_the_walls_errors_is_a_gap() -> None: + # Before: exact walls 0.5 ft apart were joined (the tolerance is 0.6), the route passed and + # s skipped the gap. + scene = parsed(two_walls(0.0), PUBLIC) + assert [(p.s0, p.s1) for p in scene.gaps] == [(3.0, 3.5)] + assert scene.s_max == pytest.approx(40.0) # s counts the gap + assert at_start(two_walls(0.0), 6.0, "route_path", PUBLIC).outcome == FAIL + + +def test_a_gap_within_the_walls_errors_is_a_join() -> None: + scene = parsed(two_walls(0.3), PUBLIC) # 0.5 <= 0.3 + 0.3, under the 0.6 cap + assert scene.gaps == [] + assert scene.s_max == pytest.approx(39.5) # the walls meet; s continues without the gap + + +# --- 4. the spot's wall and segment belong together --------------------------------------------- + + +def test_a_spot_on_the_second_of_two_joined_walls_names_its_own_segment() -> None: + # Before: wall_id w2 with segment 1, the first wall's index; w2 has only segment 0. + raw = shared_fixture() + raw["walls"] = [ + { + "id": "w1", + "baseline": [[-40, -10], [-40, 0], [4, 0]], + "height_ft": 9, + "plus_minus_ft": 0, + }, + {"id": "w2", "baseline": [[4, 0], [40, 0]], "height_ft": 9, "plus_minus_ft": 0}, + ] + spot = answer(raw)["spot"] + assert (spot["wall_id"], spot["segment"]) == ("w2", 0) + runs = {(r["wall_id"], r["segment"]) for r in answer(raw)["sweep"]} + assert ("w2", 1) not in runs + + +# --- 5. a zero sweep step ------------------------------------------------------------------------- + + +def test_a_zero_sweep_step_is_refused_when_the_rules_load() -> None: + # Before: it validated, and every solve then divided by zero. + data = deep_merge(public_rules_dict(), {"sweep": {"step_ft": {"value": 0.0}}}) + with pytest.raises(ValueError, match=r"sweep\.step_ft"): + rules_from_dict(data) + + +# --- 6. placeholders under partial private rules -------------------------------------------------- + + +def test_a_partial_private_policy_names_the_checks_still_on_placeholders(tmp_path: Path) -> None: + # Before: any private file cleared the notice, though the pool and driveway checks still ran + # on public placeholder values. + private = tmp_path / "rules.yaml" + private.write_text( + "policy: {id: p, version: '1', auto_approve: true, allow_reject: true}\n" + "clearances:\n gas_ft: {value: 3.0, source: 'test'}\n" + ) + notice = load_rules(private).rules.policy.notice + assert notice is not None + for check_id in ("pool_clearance", "drive_clearance", "route_length", "headroom"): + assert check_id in notice, notice + assert "gas_clearance" not in notice + + +def test_the_public_demo_notice_is_unchanged() -> None: + assert "not Base's" in (PUBLIC.rules.policy.notice or "") + + +# --- follow-ups from the caretaker's review of 903d86f ---------------------------------------- + + +def two_heights() -> dict: + """Collinear walls: w1 up to the meter, 2 ft tall; w2 from it, 9 ft. Joined into one piece.""" + raw = shared_fixture() + raw["meter"]["pos"][1] = 1 + raw["walls"] = [ + {"id": "w1", "baseline": [[-40, 0], [0, 0]], "height_ft": 2, "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[0, 0], [40, 0]], "height_ft": 9, "plus_minus_ft": 0}, + ] + return raw + + +def test_a_battery_on_the_taller_wall_uses_that_walls_height() -> None: + # Before: the joined piece kept the lower height everywhere, so a spot wholly on the 9 ft + # wall failed on 2 ft and the scene was rejected. + assert at_start(two_heights(), 6.0, "wall_backing", PUBLIC).outcome == PASS + assert answer(two_heights())["decision"] != "reject" + + +def test_a_battery_across_the_join_uses_the_lower_height() -> None: + assert at_start(two_heights(), -1.0, "wall_backing", PUBLIC).outcome == FAIL + + +def test_segments_are_numbered_as_uploaded() -> None: + # Before: collinear points were merged first, so the second uploaded segment reported 0. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [0, 0], [40, 0]] + scene = parsed(raw, PUBLIC) + assert scene.segment_at(7.3) == ("w1", 1) + assert scene.segment_at(-7.3) == ("w1", 0) + assert scene.segment_at(0.0) in {("w1", 0), ("w1", 1)} # on the boundary: either + spot = answer(raw)["spot"] + assert (spot["wall_id"], spot["segment"]) == ("w1", 1) + + +@pytest.mark.parametrize("gap", [0.005, 0.010001]) +def test_a_known_gap_between_exact_walls_stays_a_gap(gap: float) -> None: + # Before: exact walls 0.005 ft apart were joined by the 0.01 ft coverage floor. + raw = two_walls(0.0) + raw["walls"][0]["baseline"] = [[-40, 0], [3, 0]] + raw["walls"][1]["baseline"] = [[3 + gap, 0], [40, 0]] + scene = parsed(raw, PUBLIC) + assert len(scene.gaps) == 1 + assert at_start(raw, 6.0, "route_path", PUBLIC).outcome == FAIL + + +def test_walls_sharing_an_endpoint_still_meet() -> None: + raw = two_walls(0.0) + raw["walls"][1]["baseline"] = [[3, 0], [40, 0]] + assert parsed(raw, PUBLIC).gaps == [] + + +# --- Codex review of 7133283 ---------------------------------------------------------------------- + + +def test_a_confident_reach_past_the_maximum_is_refused() -> None: + data = deep_merge(public_rules_dict(), {"route": {"confident_reach_ft": {"value": 30.0}}}) + with pytest.raises(ValueError, match=r"route\.confident_reach_ft"): + rules_from_dict(data) + + +def test_a_run_that_may_exceed_the_maximum_never_passes() -> None: + # Before: with confident reach 30 and maximum 20, a 21 ± 2 ft run passed. + assert reach_outcome(21.0, 2.0, 30.0, 20.0) != PASS + assert reach_outcome(10.0, 2.0, 30.0, 20.0) == PASS + + +def test_a_wall_view_without_out_ft_reaches_headroom_height_only() -> None: + # Before: an omitted out_ft counted as infinite, so an 8 ft battery's back passed from it, + # while the same view declared at 6.5 ft (headroom height) went to review. + tall = rules_from_dict( + deep_merge(public_rules_dict(), {"battery": {"height_ft": {"value": 8.0}}}) + ) + raw = shared_fixture() + del raw["walls"][0]["height_ft"] + observed_band(raw, "wall", [(-40, 40)]) # no out_ft + omitted = at_start(raw, 6.0, "wall_backing", tall) + raw["coverage"]["observed"][-1]["out_ft"] = 6.5 + declared = at_start(raw, 6.0, "wall_backing", tall) + assert (omitted.outcome, omitted.unsure_cause) == (UNSURE, "unobserved") + assert (declared.outcome, declared.unsure_cause) == (UNSURE, "unobserved") + + +def test_an_omitted_wall_view_still_settles_every_public_check() -> None: + # Headroom height itself is enough for boxes and openings above the battery. + assert at_start(shared_fixture(), 6.0, "wall_equipment_above", PUBLIC).outcome == PASS + + +def test_a_partial_ground_override_keeps_ground_surface_in_the_notice(tmp_path: Path) -> None: + # Before: setting only ground.drivable and ground.source hid the public ground.allowed. + private = tmp_path / "rules.yaml" + private.write_text("ground:\n drivable: [drive]\n source: 'test'\n") + assert "ground_surface" in (load_rules(private).rules.policy.notice or "") + + +def test_at_exactly_the_needed_height_omitted_settles_and_explicit_does_not() -> None: + # wall_equipment_above needs the wall seen to headroom height (6.5). An omitted out_ft counts + # inclusively at that height; an explicit out_ft must be strictly higher. + needed = PUBLIC.rules.headroom.min_ft.value + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 40)]) # no out_ft + assert at_start(raw, 6.0, "wall_equipment_above", PUBLIC).outcome == PASS + raw["coverage"]["observed"][-1]["out_ft"] = needed + explicit = at_start(raw, 6.0, "wall_equipment_above", PUBLIC) + assert (explicit.outcome, explicit.unsure_cause) == (UNSURE, "unobserved") diff --git a/server/tests/test_drift.py b/server/tests/test_drift.py new file mode 100644 index 000000000..8b6c6644d --- /dev/null +++ b/server/tests/test_drift.py @@ -0,0 +1,48 @@ +"""AR-placed positions without their own plus_minus_ft get an error that grows with the distance +walked from the meter (rules.yaml errors.drift_per_ft, from S1's real-data drift evals).""" + +import pytest +from helpers import W, at_start, golden_rules, parsed, shared_fixture + +RULES = golden_rules( + errors={ + "tap_ft": {"value": 0.3, "source": "t"}, + "vlm_ft": {"value": 1.5, "source": "t"}, + "tape_ft": {"value": 0.05, "source": "t"}, + "wall_ft": {"value": 0.3, "source": "t"}, + "drift_per_ft": {"value": 0.16, "source": "t"}, + } +) + + +def obj(source, span, **extra): + return {"type": "window", "wall_id": "w1", "span_ft": span, "source": source, **extra} + + +def test_object_default_error_grows_with_distance_walked() -> None: + raw = shared_fixture() + raw["objects"] = [ + obj("tap", [2, 3]), + obj("tap", [-10, -9]), + obj("vlm", [2, 3]), + obj("tape", [9, 10]), + obj("tap", [9, 10], plus_minus_ft=0.1), + ] + errors = [o.plus_minus for o in parsed(raw, RULES).objects] + assert errors == pytest.approx([0.3 + 0.16 * 3, 0.3 + 0.16 * 10, 1.5 + 0.16 * 3, 0.05, 0.1]) + + +def test_wall_default_error_is_taken_at_the_batterys_far_edge() -> None: + raw = shared_fixture() + del raw["walls"][0]["plus_minus_ft"] + for s0 in (6.0, 12.0, -12.0): + reach = at_start(raw, s0, "route_length", RULES) + far = max(abs(s0), abs(s0 + W)) + # The meter is exact in the fixture, so the route's error is the wall's at the far edge. + assert reach.plus_minus == pytest.approx(0.3 + 0.16 * far) + + +def test_explicit_wall_error_does_not_drift() -> None: + # The fixture's wall has plus_minus_ft 0. + reach = at_start(shared_fixture(), 12.0, "route_length", RULES) + assert reach.plus_minus == pytest.approx(0.0) diff --git a/server/tests/test_existing_battery.py b/server/tests/test_existing_battery.py new file mode 100644 index 000000000..fc30a972e --- /dev/null +++ b/server/tests/test_existing_battery.py @@ -0,0 +1,92 @@ +"""A battery already on the wall (issue #27): `battery` objects keep a new battery +clearances.battery_ft away, measured in plan like the AC clearance, under the strict rule.""" + +import json +from pathlib import Path + +import pytest +from helpers import W, at_start, rect, shared_fixture +from jsonschema import Draft202012Validator +from test_s4_round import PUBLIC, answer + +from rules import load_rules +from solver import FAIL, PASS, UNSURE + +S0 = 6.0 # the new battery at [S0, S0 + W] on the shared wall's lawn pad +S1 = S0 + W +DEPTH = 22 / 12 + + +def existing_battery(gap: float, plus_minus: float = 0.1, top: float | None = 3.29) -> dict: + """An existing battery standing against the wall `gap` ft right of the new one.""" + raw = shared_fixture() + x0 = S1 + gap + item = { + "type": "battery", + "wall_id": "w1", + "span_ft": [x0, x0 + W], + "bottom_ft": 0, + "source": "tap", + "plus_minus_ft": plus_minus, + "footprint": rect(x0, x0 + W, 0, DEPTH), + } + if top is not None: + item["top_ft"] = top + raw["objects"] = [item] + return raw + + +@pytest.mark.parametrize( + ("gap", "outcome"), + [ + (1.0, FAIL), # misses the 3 ft rule by more than the 0.1 ft error + (4.0, PASS), # clears it by more than the error + (3.05, UNSURE), # within the error either way + ], +) +def test_an_existing_battery_is_held_to_the_rule(gap: float, outcome: str) -> None: + check = at_start(existing_battery(gap), S0, "battery_clearance", PUBLIC) + assert check.outcome == outcome + assert check.threshold == PUBLIC.rules.clearances.battery_ft.value == 3.0 + if outcome == UNSURE: + assert check.unsure_cause == "margin" + + +def test_exactly_at_the_rule_with_no_error_is_unsure() -> None: + # The strict rule: equality neither clears nor misses by more than the (zero) error. + check = at_start(existing_battery(3.0, plus_minus=0.0), S0, "battery_clearance", PUBLIC) + assert check.outcome == UNSURE + + +def test_the_answer_cites_the_rule() -> None: + result = answer(existing_battery(1.0)) + check = next(c for c in result["checks"] if c["id"] == "battery_clearance") + assert check["rule"]["key"] == "clearances.battery_ft" + assert "other batteries" in check["rule"]["source"] + + +def test_a_scene_without_one_has_no_battery_check() -> None: + # It would repeat the AC check's coverage (same band, same 3 ft), so answers stay as before. + assert "battery_clearance" not in {c["id"] for c in answer(shared_fixture())["checks"]} + + +def test_the_cable_detours_over_an_existing_battery() -> None: + # Between the meter (s = 0) and the new battery: the cable goes over it at route height. + raw = existing_battery(0.0) + raw["objects"][0]["span_ft"] = [2.0, 2.0 + W] + raw["objects"][0]["footprint"] = rect(2.0, 2.0 + W, 0, DEPTH) + route = at_start(raw, S0, "route_path", PUBLIC) + assert route.outcome == PASS + detours = at_start(raw, S0, "route_length", PUBLIC) + assert detours.measured == pytest.approx(S0 + 2 * (3.29 - 1.0), abs=1e-6) + + +def test_the_public_value_applies_when_private_rules_do_not_set_it(tmp_path: Path) -> None: + private = tmp_path / "rules.yaml" + private.write_text("clearances:\n gas_ft: {value: 4.0, source: 'test'}\n") + assert load_rules(private).rules.clearances.battery_ft.value == 3.0 + + +def test_the_schema_accepts_the_type() -> None: + schema = Path(__file__).resolve().parents[1] / "schemas" / "scene.schema.json" + Draft202012Validator(json.loads(schema.read_text())).validate(existing_battery(4.0)) diff --git a/server/tests/test_field_run_2.py b/server/tests/test_field_run_2.py new file mode 100644 index 000000000..91d98967c --- /dev/null +++ b/server/tests/test_field_run_2.py @@ -0,0 +1,178 @@ +"""Regression tests for the real-phone run 2 (issues #44, #45, #42); each failed before its fix.""" + +import copy + +from helpers import observed_band, parsed, rect, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from test_s4_round import PUBLIC, answer + +from rules import deep_merge, public_rules_dict, rules_from_dict +from solver import SOLVE_BUDGET_S, UNSURE, Solver, estimate_fails, evaluate_start + +S1_PAD = 6 + 31 / 12 # far edge of the shared fixture's first spot + +# --- #44: a spot whose best estimate overlaps the meter's working space ---------------------- + + +def meter_known_loosely(meter_error: float = 0.792, wall_error: float = 0.713) -> dict: + """Run 2's numbers: the meter and wall errors are large, so a spot against the meter is only + UNSURE for the working space. Headroom is unseen everywhere. Right of the meter the ground is + seen but its surface wasn't recorded, so the spots that clear the working space are UNSURE + for as many checks as the one against the meter, and further from it. (The lawn also runs + past the left limit end, behind the wall's line, where that ground counts too.)""" + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = meter_error + raw["walls"][0]["baseline"] = [[-3, 0], [20, 0]] + raw["walls"][0]["plus_minus_ft"] = wall_error + raw["overheads"] = [] + raw["facing"][0]["span_ft"] = [-3, 20] + raw["ground"] = [{"type": "lawn", "polygon": rect(-40, 1.0, -30, 30), "plus_minus_ft": 0}] + raw["coverage"]["ends"] = {"left": {"kind": "limit"}, "right": {"kind": "limit"}} + observed_band(raw, "overhead", []) + return raw + + +def test_a_spot_over_the_meters_working_space_does_not_win() -> None: + # Before: the spot from -2.58 to 0 won on its 0 ft route although its best estimate + # overlapped the working space by 1.25 ft. + result = answer(meter_known_loosely()) + meter = next(c for c in result["checks"] if c["id"] == "meter_working_space") + assert meter["measured_ft"] >= 0, (result["spot"]["span_ft"], meter) + + +@settings(max_examples=40, deadline=None) +@given( + meter_error=st.floats(min_value=0.1, max_value=2.0), + wall_error=st.floats(min_value=0.0, max_value=1.5), +) +def test_an_estimate_past_a_rule_never_outranks_one_that_clears( + meter_error: float, wall_error: float +) -> None: + raw = meter_known_loosely(meter_error, wall_error) + scene = parsed(raw, PUBLIC) + candidates = Solver(scene, PUBLIC).candidates(SOLVE_BUDGET_S) + unsures = [c for c in candidates if c.outcome == UNSURE] + result = answer(raw) + if result["decision"] != "manual_review" or not any(not estimate_fails(c) for c in unsures): + return + chosen = min(unsures, key=lambda c: abs(c.s0 - result["spot"]["span_ft"][0])) + assert not estimate_fails(chosen) + + +# --- #45: the summary counts only unseen checks ----------------------------------------------- + + +def test_the_summary_counts_unseen_checks_and_names_the_rest() -> None: + # Before: "10 checks depend on areas the scan did not see", one of them too close to call. + raw = shared_fixture() + observed_band(raw, "overhead", []) # headroom unseen + raw["walls"][0]["plus_minus_ft"] = 0.2 + raw["objects"] = [ # an AC unit 3.1 ft from the pad's spot: within the 0.2 ft error + { + "type": "ac", + "wall_id": "w1", + "span_ft": [6 + 31 / 12 + 3.1, 6 + 31 / 12 + 5.1], + "source": "tape", + "plus_minus_ft": 0, + "footprint": rect(6 + 31 / 12 + 3.1, 6 + 31 / 12 + 5.1, 0, 2), + } + ] + result = answer(raw) + others = [ + c["label"].lower() + for c in result["checks"] + if c["outcome"] == UNSURE and c["unsure_cause"] != "unobserved" + ] + # One unseen check is counted; the one unsure by its margin is named, as in "A person needs + # to check the best spot" (#50's wording). + assert len(others) == 1 + assert ( + "1 check depends on areas the scan did not see; a person also needs to check " + f"{others[0]}." in result["summary"] + ), result["summary"] + + +# --- #42: an object past an unexplored end ---------------------------------------------------- + + +def window_past_the_end(end: str, footprint: bool = False) -> dict: + """A window with no footprint 5 ft past the wall's right end at 12 ft.""" + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [12, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-40, 12] + raw["coverage"]["ends"]["right"] = {"kind": end} + window = { + "type": "window", + "wall_id": "w1", + "span_ft": [17, 20], + "bottom_ft": 3, + "top_ft": 7, + "source": "tap", + "plus_minus_ft": 0.1, + } + if footprint: + window["footprint"] = [[17, 0], [20, 0]] + raw["objects"] = [window] + return raw + + +def test_a_mark_past_an_unexplored_end_is_set_aside_and_named() -> None: + # Before: the window was placed on the wall's straight extension, which may not exist. + scene = parsed(window_past_the_end("unexplored"), PUBLIC) + assert scene.objects == [] + result = answer(window_past_the_end("unexplored")) + assert [o["object"] for o in result["objects_not_used"]] == ["objects[0] window"] + assert "right end" in result["objects_not_used"][0]["message"] + + +def test_past_a_limit_end_the_wall_line_still_holds_it() -> None: + assert len(parsed(window_past_the_end("limit"), PUBLIC).objects) == 1 + assert answer(window_past_the_end("limit"))["objects_not_used"] == [] + + +def test_a_mark_with_its_own_footprint_is_measured_where_it_is() -> None: + raw = window_past_the_end("unexplored", footprint=True) + assert len(parsed(raw, PUBLIC).objects) == 1 + opening = next(c for c in answer(raw)["checks"] if c["id"] == "opening_clearance") + assert opening["subject"] == "objects[0] window" # the nearest opening the check measured + + +def test_a_mark_starting_exactly_at_the_end_is_set_aside() -> None: + # Before: [12, 20] against an end at 12 clipped to the single point 12 and stayed measured. + raw = window_past_the_end("unexplored") + raw["objects"][0]["span_ft"] = [12, 20] + assert parsed(raw, PUBLIC).objects == [] + assert [o["object"] for o in answer(raw)["objects_not_used"]] == ["objects[0] window"] + + +def test_a_mark_across_the_end_keeps_the_part_on_the_wall() -> None: + raw = window_past_the_end("unexplored") + raw["objects"][0]["span_ft"] = [10, 14] + (window,) = parsed(copy.deepcopy(raw), PUBLIC).objects + xs = [x for x, _ in window.geom.coords] + assert min(xs) == 10 and max(xs) <= 12 + 1e-6 + + +def test_an_unknown_attribute_is_not_an_estimate_past_the_rule() -> None: + # A window of unknown operability 1 ft away, with fixed windows exempt: the rule may not apply + # at all, so its distance isn't evidence of a violation the way a known overlap is. + raw = shared_fixture() + raw["objects"] = [ + { + "type": "window", + "wall_id": "w1", + "span_ft": [S1_PAD + 1, S1_PAD + 3], + "bottom_ft": 3, + "top_ft": 6, + "source": "tape", + "plus_minus_ft": 0, + } + ] + rules = rules_from_dict( + deep_merge(public_rules_dict(), {"openings": {"exempt_fixed_windows": True}}) + ) + candidate = evaluate_start(parsed(raw, rules), rules, 6.0) + opening = next(c for c in candidate.checks if c.id == "opening_clearance") + assert (opening.outcome, opening.unsure_cause) == (UNSURE, "unknown_attribute") + assert not estimate_fails(candidate) diff --git a/server/tests/test_final_opus_review.py b/server/tests/test_final_opus_review.py new file mode 100644 index 000000000..d90adcaa8 --- /dev/null +++ b/server/tests/test_final_opus_review.py @@ -0,0 +1,178 @@ +"""Findings of the final review of 3baa338, each pinned by the reviewer's probe.""" + +import math +import time + +import pytest +import shapely +from helpers import everything_observed, pads_ground, parsed, rect, shared_fixture +from hypothesis import assume, given, settings +from hypothesis import strategies as st +from shapely import unary_union +from shapely.geometry import Point +from test_s4_round import PUBLIC + +from scene import MAX_WALL_PIECES, SceneError, parse_scene +from solver import PASS, evaluate_start, solve + + +def side_passage(error: float = 0.05) -> dict: + """The house wall, a 4 ft open passage to the side yard, then the garage wall, everything + observed by views along the walls (probe r_gap2).""" + return { + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "house", "plus_minus_ft": error}, + "walls": [ + {"id": "house", "baseline": [[-20, 0], [10, 0]], "plus_minus_ft": error}, + {"id": "garage", "baseline": [[14, 0], [40, 0]], "plus_minus_ft": error}, + ], + "objects": [], + "ground": pads_ground([(6.5, 10)], -40, 60), + "overheads": [], + "facing": [], + "coverage": everything_observed(-60, 60), + } + + +def test_ground_behind_a_gap_between_walls_is_yard_until_seen() -> None: + # Before: the passage behind the gap's line was neither in front of a wall nor the house, + # so it never counted as unseen, and [6.6, 9.18], 1.65 ft from it, passed gas, AC and pool + # "and that whole area was seen" from views that only ran along the walls. + raw = side_passage() + scene = parsed(raw, PUBLIC) + assert scene.unobserved_ground().intersects(Point(10.5, -1.0)) + result = solve(scene, PUBLIC) + assert result["decision"] != PASS + checks = {c["id"]: c for c in result["checks"]} + for cid in ("gas_clearance", "ac_clearance", "pool_clearance"): + assert (checks[cid]["outcome"], checks[cid]["unsure_cause"]) == ("unsure", "unobserved") + + +def test_a_view_into_the_passage_settles_it() -> None: + # The request names ground over exactly the gap's stretch; captured, it shows both sides of + # the gap's line (the camera pointed into the passage), and with the passage's surface + # recorded too (seen ground with none may be a driveway) the spot passes. + raw = side_passage() + result = solve(parsed(raw, PUBLIC), PUBLIC) + passage = [m for m in result["missing_evidence"] if m.get("span_ft") == [10.0, 14.0]] + assert passage and passage[0]["band"] == "ground" + raw["coverage"]["observed"].append( + {"band": "ground", "span_ft": [10.0, 14.0], "out_ft": passage[0]["out_ft"]} + ) + raw["ground"].append({"type": "concrete", "polygon": rect(10, 14, -30, 0), "plus_minus_ft": 0}) + assert solve(parsed(raw, PUBLIC), PUBLIC)["decision"] == PASS + + +def test_seen_ground_with_no_recorded_surface_may_be_a_driveway() -> None: + # Before (probe r_drive): ground seen 30 ft out but only the pad's lawn recorded, and + # drive_clearance passed "that whole area was seen", where ground_surface calls the same + # ground an unknown attribute. + raw = shared_fixture() + raw["meter"].pop("plus_minus_ft") + raw["ground"] = [{"type": "lawn", "polygon": rect(4, 11, 0, 4), "plus_minus_ft": 0.05}] + result = solve(parsed(raw, PUBLIC), PUBLIC) + drive = next(c for c in result["checks"] if c["id"] == "drive_clearance") + assert (drive["outcome"], drive["unsure_cause"]) == ("unsure", "unknown_attribute") + assert "Recording what that ground is" in drive["reason"] + + +def zigzag(walls: int, points: int) -> dict: + """Probe r_time2: walls end to end along x, each tap bowing 0.1 ft, so every tap is a + corner (more than the 0.05 ft a straight wall's taps may stray).""" + out, x = [], 0.0 + for k in range(walls): + pts = [] + for j in range(points): + pts.append([x, 0.1 * (j % 2)]) + x += 1.0 + x -= 1.0 + out.append({"id": f"w{k}", "baseline": pts}) + return { + "schema_version": "1.0", + "meter": {"pos": [5.0, 5, 0.0], "wall_id": "w0"}, + "walls": out, + "coverage": {"ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}}, + } + + +def test_a_scene_with_more_wall_pieces_than_the_bound_is_refused_at_once() -> None: + # Before: an 11 KB body of 4 zigzag walls (798 straight pieces) took 28.6 s to solve. + started = time.perf_counter() + with pytest.raises(SceneError, match="straight wall segments"): + parse_scene(zigzag(4, 200), PUBLIC.rules) + assert time.perf_counter() - started < 1.0 + # A scene at the bound still parses. + parse_scene(zigzag(1, MAX_WALL_PIECES + 1), PUBLIC.rules) + + +RULE_OF = {"gas_clearance": "gas_ft", "ac_clearance": "ac_ft", "pool_clearance": "pool_ft"} + + +@st.composite +def passages(draw: st.DrawFn) -> dict: + """Two walls with a gap wider than the join tolerance between them, the second at any angle, + at any errors, every band seen by views along the walls (none pointed into the passage).""" + c = draw(st.floats(1.0, 15.0)) # w1 reaches the meter at x = 0 + g = c + draw(st.floats(1.0, 8.0)) + angle = math.radians(draw(st.floats(-80.0, 80.0))) + end = [g + 20 * math.cos(angle), 20 * math.sin(angle)] + e1, e2 = draw(st.floats(0.0, 0.3)), draw(st.floats(0.0, 0.3)) + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = draw(st.floats(0.0, 0.3)) + raw["walls"] = [ + {"id": "w1", "baseline": [[-20, 0], [c, 0]], "height_ft": 9, "plus_minus_ft": e1}, + {"id": "w2", "baseline": [[g, 0], end], "height_ft": 9, "plus_minus_ft": e2}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-80, 80, -80, 80), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = everything_observed(-60, 60) + return raw + + +@settings(max_examples=40, deadline=None) +@given(raw=passages(), data=st.data()) +def test_views_along_the_walls_never_show_a_side_passage(raw: dict, data) -> None: + """Ground behind a gap that no wall faces is shown only by a view pointed into the passage, + so views along the walls leave any clearance that reaches it unsettled.""" + scene = parsed(raw, PUBLIC) + assume(scene.gaps) + # What no wall faces: behind the gap's line, not the house, and not in front of a wall + # (a turned second wall faces part of that strip, and a view along it does show that part). + faced = scene.band_polygon(scene.pieces[0].s0, scene.pieces[-1].s1, scene.reach_ft) + behind = unary_union(scene.passages(scene.reach_ft)) + passage = shapely.difference(shapely.difference(behind, scene.house()), faced) + piece = data.draw(st.sampled_from(scene.walls), label="wall") + assume(piece.s1 - piece.s0 >= PUBLIC.rules.battery.width_ft.value) + s0 = data.draw(st.floats(piece.s0, piece.s1 - PUBLIC.rules.battery.width_ft.value)) + candidate = evaluate_start(scene, PUBLIC, s0, piece.wall_id) + for c in candidate.checks: + rule = RULE_OF.get(c.id) + if rule is None: + continue + reach = getattr(PUBLIC.rules.clearances, rule).value + if candidate.footprint.distance(passage) < reach: + assert c.outcome != PASS, (c.id, s0, c.reason) + + +def test_a_subnormal_coordinate_does_not_hide_a_passage() -> None: + # Found by the passage property: w2's far end at z = 7.8e-314 ft made the ground behind the + # gap vanish from the unseen area, and a battery 0.73 ft from the passage passed gas. + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.0 + raw["walls"] = [ + {"id": "w1", "baseline": [[-20, 0], [3.3096, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + { + "id": "w2", + "baseline": [[4.7683, 0], [24.7683, 7.7669729873e-314]], + "height_ft": 9, + "plus_minus_ft": 0.0, + }, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-80, 80, -80, 80), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = everything_observed(-60, 60) + scene = parsed(raw, PUBLIC) + assert scene.unobserved_ground().contains(Point(4.0, -0.5)) + gas = next( + c for c in evaluate_start(scene, PUBLIC, 0.0, "w1").checks if c.id == "gas_clearance" + ) + assert gas.outcome != PASS diff --git a/server/tests/test_final_review.py b/server/tests/test_final_review.py new file mode 100644 index 000000000..5cb92d9e4 --- /dev/null +++ b/server/tests/test_final_review.py @@ -0,0 +1,255 @@ +"""Regression tests for the Codex review of #11 at 9176125, and the scoreboard's S2-3 properties.""" + +import copy +import math + +from helpers import at_start, everything_observed, pads_ground, parsed, rect, shared_fixture +from hypothesis import example, given, settings +from hypothesis import strategies as st +from test_s4_round import PUBLIC, answer + +from solver import FAIL, PASS, SOLVE_BUDGET_S, UNSURE, Solver, at_least, evaluate_start + +W = 31 / 12 + + +# --- 4114314891: a spot within the wall's error of the wall's end ------------------------------ + + +def wall_ending_at_9() -> dict: + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [9, 0]] + raw["walls"][0]["plus_minus_ft"] = 0.5 + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-40, 9] + raw["coverage"]["ends"]["right"] = {"kind": "limit"} + return raw + + +def test_a_spot_within_error_of_the_wall_end_is_not_a_pass() -> None: + # Before: [6.1, 8.683] passed although its edge may be at 9.18, past the wall's end at 9. + backing = at_start(wall_ending_at_9(), 6.1, "wall_backing", PUBLIC) + assert (backing.outcome, backing.unsure_cause) == (UNSURE, "margin") + assert answer(wall_ending_at_9())["decision"] != "pass" + + +def test_a_spot_clear_of_the_end_by_its_error_still_passes_backing() -> None: + assert at_start(wall_ending_at_9(), 9 - W - 0.6, "wall_backing", PUBLIC).outcome == PASS + + +# --- 4114314918: the meter's offset from the wall ------------------------------------------------ + + +def meter_off_the_wall() -> dict: + raw = shared_fixture() + raw["meter"]["pos"] = [0.0, 5.0, 2.0] # 2 ft in front of the wall at z = 0 + raw["ground"] = [ + {"type": "lawn", "polygon": rect(14, 17, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(-40, 14, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(17, 40, 0, 30), "plus_minus_ft": 0}, + ] + return raw + + +def test_the_route_starts_at_the_meter_not_its_projection() -> None: + # Before: 14 ± 0 from [0, 0], a pass; the real run from the meter is 16, past 15 confident. + reach = at_start(meter_off_the_wall(), 14.0, "route_length", PUBLIC) + assert reach.measured == 16.0 + assert reach.outcome == UNSURE + result = answer(meter_off_the_wall()) + assert result["decision"] != "pass" + assert result["route"] is None or result["route"]["polyline"][0] == [0.0, 2.0] + + +# --- scoreboard S2-3 ----------------------------------------------------------------------------- + + +@given(threshold=st.floats(0.1, 20), error=st.floats(0, 5)) +def test_equality_is_unsure(threshold: float, error: float) -> None: + # A margin exactly equal to the error decides nothing, either way. + assert at_least(threshold + error, error, threshold) == UNSURE + assert at_least(threshold - error, error, threshold) == UNSURE + + +RANK = {PASS: 0, UNSURE: 1, FAIL: 2} + + +@settings(max_examples=40, deadline=None) +@given(near=st.floats(0.0, 6.0), extra=st.floats(0.0, 4.0), error=st.floats(0.0, 0.5)) +def test_moving_an_obstacle_away_never_turns_pass_into_fail( + near: float, extra: float, error: float +) -> None: + """Monotonicity over one obstacle distance: an AC unit further from the battery never makes + the AC check, or the spot, worse.""" + + def spot_with_ac(gap: float): + raw = shared_fixture() + x = 6.0 + W + gap + raw["objects"] = [ + { + "type": "ac", + "wall_id": "w1", + "span_ft": [x, x + 2], + "source": "tape", + "plus_minus_ft": error, + "footprint": rect(x, x + 2, 0, 2), + } + ] + return evaluate_start(parsed(copy.deepcopy(raw), PUBLIC), PUBLIC, 6.0) + + closer, further = spot_with_ac(near), spot_with_ac(near + extra) + ac = {c.id: c.outcome for c in closer.checks}["ac_clearance"] + ac_far = {c.id: c.outcome for c in further.checks}["ac_clearance"] + assert RANK[ac_far] <= RANK[ac] + assert RANK[further.outcome] <= RANK[closer.outcome] + + +def test_the_wall_behind_every_possible_position_must_be_seen() -> None: + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.5 + raw["coverage"]["observed"] = [o for o in raw["coverage"]["observed"] if o["band"] != "wall"] + raw["coverage"]["observed"].append({"band": "wall", "span_ft": [-40, 6 + W]}) + backing = at_start(raw, 6.0, "wall_backing", PUBLIC) + assert (backing.outcome, backing.unsure_cause) == (UNSURE, "unobserved") + + +# --- S4 on 4a9fa44: a request is a fixed point --------------------------------------------------- + + +def supplied_exactly(raw: dict, result: dict) -> dict: + out = copy.deepcopy(raw) + for item in result["missing_evidence"]: + if item["kind"] == "band": + depth = {"out_ft": item["out_ft"]} if "out_ft" in item else {} + out["coverage"]["observed"].append( + {"band": item["band"], "span_ft": item["span_ft"], **depth} + ) + return out + + +def unseen_at(raw: dict, s0: float, wall_id: str) -> list[str]: + candidate = evaluate_start(parsed(raw, PUBLIC), PUBLIC, s0, wall_id) + return [c.id for c in candidate.checks if c.unsure_cause == "unobserved"] + + +def exact_start(raw: dict, result: dict) -> float: + scene = parsed(raw, PUBLIC) + target = result["spot"]["span_ft"][0] + return min( + (c.s0 for c in Solver(scene, PUBLIC).candidates(SOLVE_BUDGET_S)), + key=lambda s: abs(s - target), + ) + + +@st.composite +def cornered(draw: st.DrawFn) -> dict: + """A wall that turns at x = c by `angle` degrees (positive: an inside corner, turning + toward the yard), with gaps in every band's coverage and random view depths.""" + c = draw(st.floats(4.0, 14.0)) + angle = math.radians(draw(st.floats(-80.0, 80.0))) + end = [c + 20 * math.cos(angle), 20 * math.sin(angle)] + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [c, 0]], "height_ft": 9, "plus_minus_ft": 0.1}, + {"id": "w2", "baseline": [[c, 0], end], "height_ft": 9, "plus_minus_ft": 0.1}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-60, 60, -60, 60), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [], + } + for band in ("wall", "ground", "overhead", "facing"): + cuts = draw(st.lists(st.floats(-10.0, c + 20.0), max_size=4)) + edges = [-30.0, *sorted(cuts[: len(cuts) // 2 * 2]), c + 20.0] + depth = draw(st.floats(1.0, 12.0)) + for a, b in zip(edges[::2], edges[1::2], strict=True): + if b > a: + item = {"band": band, "span_ft": [a, b]} + if band == "ground" or draw(st.booleans()): + item["out_ft"] = depth + raw["coverage"]["observed"].append(item) + return raw + + +def corner_108() -> dict: + """The example the final review saved (finding 6): a 108 degree corner, walls +/- 0.1 ft, + ground seen 1 ft out, overheads unseen over s [-2, 0].""" + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [6.1875, 0]], "height_ft": 9, "plus_minus_ft": 0.1}, + { + "id": "w2", + "baseline": [[6.1875, 0], [12.367839887498949, 19.02113032590307]], + "height_ft": 9, + "plus_minus_ft": 0.1, + }, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-60, 60, -60, 60), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": "wall", "span_ft": [-30.0, 26.1875]}, + {"band": "ground", "span_ft": [-30.0, 26.1875], "out_ft": 1.0}, + {"band": "overhead", "span_ft": [-30.0, -2.0]}, + {"band": "overhead", "span_ft": [0.0, 26.1875]}, + {"band": "facing", "span_ft": [-30.0, 26.1875]}, + ], + } + return raw + + +# The saved example runs on every run, CI's fixed examples included. +@settings(max_examples=30, deadline=None) +@given(raw=cornered()) +@example(raw=corner_108()) +def test_capturing_exactly_what_is_requested_settles_it_in_one_round(raw: dict) -> None: + # Before: near a corner the ground depth was the distance from the chain line, less than + # the strip in front of the wall needs, so each capture fell short and the next answer + # asked for 0.005 ft more over the same stretch. + result = answer(raw) + if result["decision"] != "manual_review" or result["spot"] is None: + return + assert unseen_without_a_reason(result) == [] + s0 = exact_start(raw, result) + assert unseen_at(supplied_exactly(raw, result), s0, result["spot"]["wall_id"]) == [] + + +def unseen_without_a_reason(result: dict) -> list[str]: + """Unseen checks at the spot that the answer's unobserved_area reason doesn't name: the + result contract has every UNSURE check carry a request or a reason.""" + named = {i for r in result["reasons"] if r["code"] == "unobserved_area" for i in r["checks"]} + return [ + c["id"] + for c in result["checks"] + if c.get("unsure_cause") == "unobserved" and c["id"] not in named + ] + + +def test_a_request_leaves_no_lens_where_a_clearance_circle_meets_a_view() -> None: + # Before: the ground request's depth was accepted once under 1e-9 sq ft stayed unseen, and a + # 9.5e-10 sq ft lens where the pool circle meets the view's edge, 1.7e-5 ft inside the + # radius, left pool_clearance unseen after the exact capture. + raw = corner_108() + result = answer(raw) + s0 = exact_start(raw, result) + assert unseen_at(supplied_exactly(raw, result), s0, result["spot"]["wall_id"]) == [] + + +def test_ground_past_an_end_beyond_reach_is_asked_for() -> None: + # The manager's ETH3D case, rebuilt: the pool circle at the only allowed pad reaches past an + # unexplored end that no spot past it could reach. Before, pool_clearance was UNSURE with no + # request and no reason (only walking past the end shows that ground, and no past_end + # request was made for an end beyond reach). + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-20, 0], [30, 0]], "height_ft": 9, "plus_minus_ft": 0.0} + ] + raw["ground"] = pads_ground([(20.0, 23.0)], -40, 60) + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = everything_observed(-20, 30) + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + result = answer(raw) + assert unseen_without_a_reason(result) == [] + walk = [m for m in result["missing_evidence"] if m["kind"] == "past_end"] + assert [m["side"] for m in walk] == ["right"] diff --git a/server/tests/test_golden_public.py b/server/tests/test_golden_public.py new file mode 100644 index 000000000..ff35381fe --- /dev/null +++ b/server/tests/test_golden_public.py @@ -0,0 +1,514 @@ +"""The 14 public golden tests from docs/research/t3-lane-c-review.md (branch t3/research). + +Thresholds are the test settings in tests/helpers.py (GOLDEN_RULES), not Base policy. Every scene +starts from the shared fixture: wall w1 along z = 0 with s = x, the meter at 0, outward +z, a lawn +pad at s = [6, 9] and deck everywhere else, both ends observed, facing gap and headroom 9, exact +geometry. Where the review writes a scene whose walls run right to left (test 04), the walls are +listed left to right as scene.schema.json requires; the expected plan positions are unchanged. +""" + +import json +import math +from pathlib import Path + +import pytest +from helpers import ( + D, + W, + at_start, + check, + everything_observed, + golden_rules, + observed_band, + pads_ground, + parsed, + rect, + run, + shared_fixture, +) +from jsonschema import Draft202012Validator + +from solver import Solver, evaluate_start + +RESULT = Draft202012Validator( + json.loads((Path(__file__).parents[1] / "schemas" / "result.schema.json").read_text()) +) + + +def solve_valid(raw, rules=None): + result = run(raw, rules) + RESULT.validate(result) + return result + + +def flat(values): + return [v for x in values for v in (flat(x) if isinstance(x, list) else [x])] + + +def approx(values, expected): + assert flat(values) == pytest.approx(flat(expected), abs=1e-6) + + +def pad_runs_fail_only(result, check_id, pad=(6, 9)): + """Every sweep run of starts on the pad fails exactly this one check.""" + runs = [ + r + for r in result["sweep"] + if r["start_ft"][0] >= pad[0] - 1e-5 and r["start_ft"][1] <= pad[1] - W + 1e-5 + ] + return bool(runs) and all(r["failing"] == [check_id] for r in runs) + + +def with_gas(raw, geometry, span=(7, 7.5), plus_minus=0.0): + raw["objects"].append( + { + "type": "gas_meter", + "wall_id": raw["walls"][0]["id"], + "span_ft": list(span), + "source": "tap", + "plus_minus_ft": plus_minus, + "footprint": geometry, + } + ) + return raw + + +def test_01_fully_observed_spot_passes_without_unrelated_views() -> None: + raw = shared_fixture() + raw["objects"].append( + { + "type": "garage_door", + "wall_id": "w1", + "span_ft": [-12, -8], + "bottom_ft": 0, + "top_ft": 7, + "source": "tap", + "plus_minus_ft": 0, + } + ) + # The wall past the garage, on the far side of the meter, was never seen. + raw["coverage"] = everything_observed(-8, 40) + raw["coverage"]["ends"]["left"] = {"kind": "unexplored"} + result = solve_valid(raw) + assert result["decision"] == "pass" + approx(result["spot"]["span_ft"], [6, 103 / 12]) + approx(result["spot"]["footprint"], [[6, 0], [103 / 12, 0], [103 / 12, D], [6, D]]) + approx([result["route"]["length_ft"]], [6]) + assert result["missing_evidence"] == [] + + +def test_02_no_selected_policy_means_no_automatic_approval() -> None: + rules = golden_rules(policy={"id": None, "auto_approve": False}) + result = solve_valid(shared_fixture(), rules) + assert result["decision"] == "manual_review" + assert "policy_not_approved" in [r["code"] for r in result["reasons"]] + assert result["spot"]["outcome"] == "pass" + + +def test_03_negative_s_uses_the_near_edge() -> None: + raw = shared_fixture() + raw["ground"] = pads_ground([(-9, -6), (7, 10)]) + result = solve_valid(raw) + assert result["decision"] == "pass" + approx(result["spot"]["span_ft"], [-103 / 12, -6]) + approx([result["route"]["length_ft"]], [6]) + + +def corner_scene(): + # Review's test 04 with the walls listed left to right seen from outside: w2 comes down x = 4 + # from z = 12 to the corner, then w1 runs along z = 0 to x = -12 (outward -z). + return { + "meter": {"pos": [0, 5, 0], "wall_id": "w1", "plus_minus_ft": 0}, + "walls": [ + {"id": "w2", "baseline": [[4, 12], [4, 0]], "plus_minus_ft": 0}, + {"id": "w1", "baseline": [[4, 0], [-12, 0]], "plus_minus_ft": 0}, + ], + "objects": [], + "ground": [ + {"type": "lawn", "polygon": rect(4, 30, 2, 5), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(4, 30, 5, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(4, 30, -30, 2), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(-40, 4, -30, 0), "plus_minus_ft": 0}, + ], + "overheads": [], + "facing": [], + "coverage": everything_observed(-40, 40), + } + + +def test_04_corner_allows_adjacent_wall_not_a_bent_battery() -> None: + raw = corner_scene() + result = solve_valid(raw) + assert result["decision"] == "pass" + spot = result["spot"] + assert spot["wall_id"] == "w2" + xs = [p[0] for p in spot["footprint"]] + zs = [p[1] for p in spot["footprint"]] + approx([min(xs), max(xs)], [4, 35 / 6]) + approx([min(zs), max(zs)], [2, 55 / 12]) + approx([result["route"]["length_ft"]], [6]) + + # The review's candidate s = [3, 67/12] runs 1 ft along w1 and 19/12 ft round the corner. + # Listed left to right that is s = [-67/12, -3]: it must fail backing, not bend. + corner = evaluate_start(parsed(raw), golden_rules(), -67 / 12) + backing = next(c for c in corner.checks if c.id == "wall_backing") + assert backing.outcome == "fail" + assert corner.outcome == "fail" + + +def test_05_inside_corner_gas_distance_is_euclidean() -> None: + raw = { + "meter": {"pos": [-6, 5, 0], "wall_id": "w1", "plus_minus_ft": 0}, + "walls": [ + {"id": "w1", "baseline": [[-6, 0], [4, 0]], "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[4, 0], [4, 20]], "plus_minus_ft": 0}, + ], + "objects": [], + "ground": [ + {"type": "lawn", "polygon": rect(0, 3, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(-40, 0, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(3, 4, 0, 30), "plus_minus_ft": 0}, + ], + "overheads": [], + "facing": [], + "coverage": everything_observed(-40, 60), + } + with_gas(raw, [[4, 4]], span=(10, 10)) + result = solve_valid(raw) + assert result["decision"] == "reject" + assert result["spot"] is None + assert pad_runs_fail_only(result, "gas_clearance", pad=(6, 9)) + all_fail = next(r for r in result["reasons"] if r["code"] == "all_spots_fail") + assert "gas_clearance" in all_fail["checks"] + gas = at_start(raw, 6, "gas_clearance") + assert gas.outcome == "fail" + # Straight-line distance from the footprint x = [0, 31/12], z = [0, 11/6] to the gas at + # [4, 4], not the 65/12 ft measured along the unrolled wall. + assert gas.measured == pytest.approx(math.hypot(17 / 12, 13 / 6)) + assert gas.measured < 3 < 65 / 12 + + +def test_06_every_part_of_an_object_counts() -> None: + # Gas meter body 10 ft out, its regulator pipe reaching x = 7, z = 23/6. + raw = with_gas(shared_fixture(), [[7.5, 10], [7, 23 / 6]]) + result = solve_valid(raw) + assert result["decision"] == "reject" + gas = at_start(raw, 6, "gas_clearance") + assert gas.outcome == "fail" + assert gas.measured == pytest.approx(2) + assert pad_runs_fail_only(result, "gas_clearance") + + +@pytest.mark.parametrize( + ("underside", "plus_minus", "expected"), + [ + (6.5 - 1, 0.5, "reject"), + (6.5 + 0.7, 0.5, "pass"), + (6.5 + 0.3, 0.5, "manual_review"), + ], +) +def test_07_headroom_covers_the_whole_footprint(underside, plus_minus, expected) -> None: + raw = shared_fixture() + # The ray 1 ft out reads 9 (the base overhead entry); a landing spans the whole pad. + raw["overheads"].append( + {"wall_id": "w1", "span_ft": [6, 9], "clearance_ft": underside, "plus_minus_ft": plus_minus} + ) + assert solve_valid(raw)["decision"] == expected + + +def test_07_unobserved_headroom_is_manual_review() -> None: + raw = shared_fixture() + observed_band(raw, "overhead", [(-40, 5), (10, 40)]) + result = solve_valid(raw) + assert result["decision"] == "manual_review" + head = check(result, "headroom") + assert head["outcome"] == "unsure" + assert head["unsure_cause"] == "unobserved" + assert any(m.get("band") == "overhead" for m in result["missing_evidence"]) + + +def test_08_route_across_a_garage_decides_the_result() -> None: + raw = shared_fixture() + raw["ground"] = pads_ground([(10, 13)]) + raw["objects"].append( + { + "type": "garage_door", + "wall_id": "w1", + "span_ft": [3, 7], + "bottom_ft": 0, + "top_ft": 7, + "source": "tap", + "plus_minus_ft": 0, + } + ) + result = solve_valid(raw) + assert result["decision"] == "reject" + assert pad_runs_fail_only(result, "route_path", pad=(10, 13)) + + +def test_08_no_route_interpolates_across_missing_wall() -> None: + raw = shared_fixture() + raw["ground"] = pads_ground([(10, 13)]) + raw["walls"] = [ + {"id": "w1", "baseline": [[-40, 0], [3, 0]], "plus_minus_ft": 0}, + {"id": "w1b", "baseline": [[4, 0], [40, 0]], "plus_minus_ft": 0}, + ] + raw["facing"][0]["span_ft"] = [-40, 3] + raw["overheads"][0]["span_ft"] = [-40, 3] + raw["facing"].append({"wall_id": "w1b", "span_ft": [4, 40], "depth_ft": 9, "plus_minus_ft": 0}) + raw["overheads"].append( + {"wall_id": "w1b", "span_ft": [4, 40], "clearance_ft": 9, "plus_minus_ft": 0} + ) + scene = parsed(raw) + assert [(g.s0, g.s1) for g in scene.gaps] == [(3, 4)] + result = solve_valid(raw) + assert result["decision"] == "reject" + assert pad_runs_fail_only(result, "route_path", pad=(10, 13)) + + +def reach_scene(start, meter_error=0.0): + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = meter_error + raw["ground"] = pads_ground([(start, start + 3)]) + return raw + + +@pytest.mark.parametrize( + ("start", "meter_error", "expected"), + [ + (15 - 1, 0.0, "pass"), # confident reach - 1 + (15, 0.0, "manual_review"), # on the confident-reach line + (15 + 1, 0.0, "manual_review"), + (20, 0.0, "manual_review"), # on the maximum + (20 + 1, 0.0, "reject"), + (20 + 0.2, 0.3, "manual_review"), # not clearly over the maximum + (20 + 0.4, 0.3, "reject"), + ], +) +def test_09_reach_uses_routed_length(start, meter_error, expected) -> None: + result = solve_valid(reach_scene(start, meter_error)) + assert result["decision"] == expected + + +def test_09_vertical_run_counts_toward_reach() -> None: + # Pad at confident reach - 4; a window reaching the ground forces the cable 2.5 ft up and back. + raw = reach_scene(15 - 4) + raw["objects"].append( + { + "type": "window", + "wall_id": "w1", + "span_ft": [2, 5], + "bottom_ft": 0, + "top_ft": 3.5, + "source": "tap", + "plus_minus_ft": 0, + } + ) + result = solve_valid(raw) + assert result["decision"] == "manual_review" + route = result["route"] + approx([route["length_ft"]], [15 + 1]) + approx([d["extra_ft"] for d in route["detours"]], [5]) + + +def gas_gap_scene(gap, gas_error, wall_error=0.0): + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = wall_error + # A gas pipe parallel to the wall: every start on the pad has the same gap. + return with_gas(raw, [[-40, D + gap], [40, D + gap]], span=(-40, 40), plus_minus=gas_error) + + +@pytest.mark.parametrize("e", [0.3, 0.5, 1.5]) +@pytest.mark.parametrize( + ("offset", "expected"), + [ + (lambda e: e + 0.01, "pass"), + (lambda e: e, "manual_review"), + (lambda e: -e, "manual_review"), + (lambda e: -e - 0.01, "reject"), + ], + ids=["T+e+0.01", "T+e", "T-e", "T-e-0.01"], +) +def test_10_margins_cover_equality(e, offset, expected) -> None: + result = solve_valid(gas_gap_scene(3 + offset(e), e)) + assert result["decision"] == expected + + +def test_10_compound_error_adds() -> None: + # Gap 3.5 derived from two positions each ±0.3 (the gas pipe and the wall): error ±0.6. + result = solve_valid(gas_gap_scene(3.5, 0.3, wall_error=0.3)) + assert result["decision"] == "manual_review" + gas = check(result, "gas_clearance") + assert gas["outcome"] == "unsure" + assert gas["plus_minus_ft"] == pytest.approx(0.6) + assert gas["unsure_cause"] == "margin" + + +def test_10_unseen_count_leaves_out_margin_checks() -> None: + # Issue #45: headroom unseen at the pad and the gas gap too close to call. Only the headroom + # is unobserved, so the summary counts one unseen check and names the gas one apart. + raw = gas_gap_scene(3.3, 0.3) + observed_band(raw, "overhead", [(-40, 5), (10, 40)]) + result = solve_valid(raw) + unsure = {c["id"]: c["unsure_cause"] for c in result["checks"] if c["outcome"] == "unsure"} + assert unsure["gas_clearance"] == "margin" + assert list(unsure.values()).count("unobserved") == 1 + gas = check(result, "gas_clearance")["label"].lower() + assert "1 check depends on areas the scan did not see" in result["summary"] + assert f"; a person also needs to check {gas}." in result["summary"] + + +def l_scene(variant): + """w1 along z = 0 to a corner at s = 12, then w2 down x = 12 (outward +x) to s = 32 (s = 12 - z + on w2). A gas pipe 1 ft in front of the battery's front face fails every spot it runs along.""" + raw = shared_fixture() + raw["facing"], raw["overheads"] = [], [] + walls = [{"id": "w1", "baseline": [[-40, 0], [12, 0]], "plus_minus_ft": 0}] + ground = [ + {"type": "lawn", "polygon": rect(-9, -6, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(-40, -9, 0, 30), "plus_minus_ft": 0}, + {"type": "deck", "polygon": rect(-6, 40, 0, 30), "plus_minus_ft": 0}, + ] + front = D + 1 + # (d) adds a valid pad before the gate: the pipe along w1 stops 4 ft short of it. + with_gas(raw, [[-2 if variant == "d" else -40, front], [12 + front, front]], span=(-40, 12)) + if variant == "a": + raw["coverage"] = everything_observed(-40, 12) + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + elif variant == "b": + walls.append({"id": "w2", "baseline": [[12, 0], [12, -20]], "plus_minus_ft": 0}) + ground.append({"type": "deck", "polygon": rect(12, 40, -20, 0), "plus_minus_ft": 0}) + with_gas(raw, [[12 + front, front], [12 + front, -20]], span=(12, 32)) + raw["coverage"] = everything_observed(-40, 40) + else: + # A closed gate hides the ground along w2 from s = 14 to 20, within cable reach. Past the + # gate the wall is seen again to its real end. + walls.append({"id": "w2", "baseline": [[12, 0], [12, -20]], "plus_minus_ft": 0}) + ground.append({"type": "deck", "polygon": rect(12, 40, -2, 0), "plus_minus_ft": 0}) + ground.append({"type": "deck", "polygon": rect(12, 40, -40, -8), "plus_minus_ft": 0}) + with_gas(raw, [[12 + front, front], [12 + front, -1]], span=(12, 13)) + raw["coverage"] = everything_observed(-40, 40) + observed_band(raw, "ground", [(-40, 14), (20, 40)]) + raw["walls"], raw["ground"] = walls, ground + return raw + + +def test_11a_unseen_wall_past_a_tapped_corner_is_manual_review() -> None: + result = solve_valid(l_scene("a")) + assert result["decision"] == "manual_review" + assert [m.get("side") for m in result["missing_evidence"] if m["kind"] == "past_end"] == [ + "right" + ] + + +def test_11b_reject_records_both_real_wall_ends() -> None: + result = solve_valid(l_scene("b")) + assert result["decision"] == "reject" + assert result["spot"] is None + assert result["missing_evidence"] == [] + assert result["ends"]["left"]["kind"] == "limit" + assert result["ends"]["right"]["kind"] == "limit" + approx([result["ends"]["left"]["s_ft"], result["ends"]["right"]["s_ft"]], [-40, 32]) + + +def test_11c_ground_hidden_behind_a_gate_is_manual_review() -> None: + result = solve_valid(l_scene("c")) + assert result["decision"] == "manual_review" + grounds = [m for m in result["missing_evidence"] if m.get("band") == "ground"] + assert grounds + assert all(m["span_ft"][1] > 14 and m["span_ft"][0] < 20 for m in grounds) + + +def test_11d_valid_pad_before_the_gate_passes_without_photo_request() -> None: + result = solve_valid(l_scene("d")) + assert result["decision"] == "pass" + approx(result["spot"]["span_ft"], [-103 / 12, -6]) + assert result["missing_evidence"] == [] + + +def two_ft_rules(): + return golden_rules(battery={"width_ft": {"value": 2.0, "source": "test battery"}}) + + +def test_12_sub_grid_interval_is_found() -> None: + raw = shared_fixture() + raw["ground"] = pads_ground([(4, 12.1)]) + for span in ([3, 4], [12.1, 13.1]): + raw["objects"].append( + { + "type": "window", + "wall_id": "w1", + "span_ft": span, + "bottom_ft": 3, + "top_ft": 6, + "attrs": {"operable": True}, + "source": "tap", + "plus_minus_ft": 0, + } + ) + result = solve_valid(raw, two_ft_rules()) + assert result["decision"] == "pass" + s0, s1 = result["spot"]["span_ft"] + assert 7 < s0 < 7.1 + assert s1 == pytest.approx(s0 + 2) + + +def test_12_last_start_is_enumerated() -> None: + raw = shared_fixture() + raw["walls"] = [ + {"id": "w0", "baseline": [[-3, 0], [3, 0]], "plus_minus_ft": 0}, + {"id": "w1", "baseline": [[6, 0], [8, 0]], "plus_minus_ft": 0}, + ] + raw["meter"]["wall_id"] = "w0" + raw["facing"], raw["overheads"] = [], [] + loaded = two_ft_rules() + scene = parsed(raw, loaded) + piece = next(p for p in scene.walls if p.wall_id == "w1") + assert Solver(scene, loaded).starts(piece) == [6.0] + + +def f_and_s_scene(with_s=True): + raw = shared_fixture() + raw["ground"] = pads_ground([(6, 9), (10, 13)] if with_s else [(6, 9)]) + # Gas 3.2 ± 0.3 from every footprint on pad F; well clear of pad S. + return with_gas(raw, [[7.5, D + 3.2]], span=(7.5, 7.5), plus_minus=0.3) + + +def test_13_unsure_spot_never_outranks_a_pass() -> None: + result = solve_valid(f_and_s_scene()) + assert result["decision"] == "pass" + assert result["spot"]["span_ft"][0] == pytest.approx(10) + approx([result["route"]["length_ft"]], [10]) + + +def test_13_margin_only_review_asks_for_no_photos() -> None: + result = solve_valid(f_and_s_scene(with_s=False)) + assert result["decision"] == "manual_review" + assert check(result, "gas_clearance")["unsure_cause"] == "margin" + assert result["missing_evidence"] == [] + + +def test_14_result_records_its_measurements() -> None: + result = solve_valid(shared_fixture()) + assert result["decision"] == "pass" + assert result["policy"]["id"] == "golden-test" + assert result["policy"]["version"] == "1" + assert len(result["policy"]["rules_sha256"]) == 64 + spot = result["spot"] + approx(spot["meter_offset_ft"], [6 + W / 2, D / 2]) + approx([result["route"]["length_ft"], result["route"]["plus_minus_ft"]], [6, 0]) + facing = check(result, "facing_gap") + head = check(result, "headroom") + approx([facing["measured_ft"], head["measured_ft"]], [9, 9]) + for c in result["checks"]: + assert c["outcome"] == "pass" + assert c["rule"]["source"] + assert result["missing_evidence"] == [] + + +def test_result_is_stable_for_the_same_input() -> None: + a = run(shared_fixture()) + b = run(shared_fixture()) + a["stats"].pop("elapsed_ms") + b["stats"].pop("elapsed_ms") + assert a == b diff --git a/server/tests/test_hardening.py b/server/tests/test_hardening.py new file mode 100644 index 000000000..4092aa3f5 --- /dev/null +++ b/server/tests/test_hardening.py @@ -0,0 +1,265 @@ +"""Regression tests for the second review of #11 (public rules only); each failed before its fix.""" + +import io +import json +import time +import zipfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from helpers import W, at_start, rect, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st + +import api +from rules import deep_merge, load_rules, public_rules_dict, rules_from_dict +from scene import SceneError, parse_scene +from solver import SceneTooComplex, Solver, evaluate_start, solve + +SERVER = Path(__file__).resolve().parents[1] +# Public values with automatic decisions on, so a wrong pass shows as "pass". +PUBLIC = rules_from_dict( + deep_merge(public_rules_dict(), {"policy": {"id": "test", "auto_approve": True}}) +) + + +def default_error_scene(ground_out: float = 30.0) -> dict: + """A straight wall where every position takes the rules' default error (AR drift included).""" + return { + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "w1"}, + "walls": [{"id": "w1", "baseline": [[-40, 0], [60, 0]]}], + "objects": [], + "ground": [{"type": "lawn", "polygon": rect(-60, 80, 0, 40)}], + "overheads": [], + "facing": [], + "coverage": { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": "wall", "span_ft": [-60, 80]}, + {"band": "ground", "span_ft": [-60, 80], "out_ft": ground_out}, + {"band": "overhead", "span_ft": [-60, 80]}, + {"band": "facing", "span_ft": [-60, 80]}, + ], + }, + } + + +# --- 1. unseen ground beyond the modelled outdoor area ------------------------------------------ + + +def test_ground_seen_short_of_the_pool_radius_plus_error_is_not_clear() -> None: + # Before: the outdoor area stopped at 12.83 ft, so ground 12.9 to 12.98 ft out counted as seen. + raw = default_error_scene(ground_out=12.9) + pool = at_start(raw, 2.71, "pool_clearance", PUBLIC) + assert (pool.outcome, pool.unsure_cause) == ("unsure", "unobserved") + + +@settings(max_examples=25, deadline=None) +@given( + out=st.floats(min_value=0.5, max_value=30.0), + shrink=st.floats(min_value=0.0, max_value=0.9), + s0=st.floats(min_value=-20.0, max_value=20.0), +) +def test_shrinking_observed_ground_never_helps(out: float, shrink: float, s0: float) -> None: + full = evaluate_start(parse_scene(default_error_scene(out), PUBLIC.rules), PUBLIC, s0) + less = evaluate_start( + parse_scene(default_error_scene(out * (1 - shrink)), PUBLIC.rules), PUBLIC, s0 + ) + rank = {"pass": 0, "unsure": 1, "fail": 2} + for before, after in zip(full.checks, less.checks, strict=True): + assert rank[after.outcome] >= rank[before.outcome], (before.id, before, after) + + +# --- 2. start positions grow with boundaries times offsets -------------------------------------- + + +def crowded_scene() -> dict: + raw = shared_fixture() + raw["objects"] = [ + { + "type": "elec_box", + "wall_id": "w1", + "span_ft": [-39 + 0.7 * i, -38.9 + 0.7 * i], + "bottom_ft": 4, + "top_ft": 5, + "source": "tap", + "plus_minus_ft": 0.01 + 0.001 * i, + } + for i in range(100) + ] + raw["coverage"]["observed"] += [ + {"band": "wall", "span_ft": [-39.5 + 0.7 * i, -39.2 + 0.7 * i]} for i in range(100) + ] + return raw + + +def test_start_positions_grow_linearly() -> None: + # Before: every boundary was combined with every object's error offset: 174k starts. + scene = parse_scene(crowded_scene(), PUBLIC.rules) + solver = Solver(scene, PUBLIC) + assert sum(len(solver.starts(p)) for p in scene.walls) < 10_000 + + +def test_crowded_scene_solves_quickly() -> None: + started = time.perf_counter() + solve(parse_scene(crowded_scene(), PUBLIC.rules), PUBLIC) + assert time.perf_counter() - started < 10 + + +def test_a_solve_over_budget_stops_with_a_clear_error() -> None: + scene = parse_scene(shared_fixture(), PUBLIC.rules) + with pytest.raises(SceneTooComplex, match="seconds"): + solve(scene, PUBLIC, budget_s=0.0) + + +# --- 3. a small upload that parses to gigabytes ------------------------------------------------- + + +@pytest.fixture +def client() -> TestClient: + return TestClient(api.app) + + +def test_zipped_scene_json_over_the_cap_is_refused_unread(client: TestClient) -> None: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("scene.json", '{"a": ' + " " * (11 * 1024 * 1024) + "1}") + body = buffer.getvalue() + assert len(body) < 100_000 # compresses to almost nothing + resp = client.post("/v1/placements", content=body, headers={"content-type": "application/zip"}) + assert resp.status_code == 413 + assert resp.json()["error"]["code"] == "scene_too_large" + + +def test_bare_scene_json_over_the_cap_is_refused(client, monkeypatch) -> None: + monkeypatch.setattr(api, "MAX_SCENE_BYTES", 1024) + body = json.dumps({"meter": {}, "pad": "x" * 2000}) + resp = client.post("/v1/placements", content=body, headers={"content-type": "application/json"}) + assert resp.status_code == 413 + assert resp.json()["error"]["code"] == "scene_too_large" + + +# --- 4. the cable-reach cutoff -------------------------------------------------------------------- + + +def test_starts_past_the_reach_cutoff_really_fail_route_length() -> None: + # Before: the cutoff took drift at the near edge, so starts up to drift x W beyond it were + # reported as route_length FAIL without evaluation; s0 = 24.62 ft actually gives UNSURE. + raw = default_error_scene() + scene = parse_scene(raw, PUBLIC.rules) + limit = Solver(scene, PUBLIC).reach_limit(scene.walls[0]) + assert limit > 24.62 + for s0 in (limit + 1e-5, limit + 1, limit + 10, -limit - W - 1e-5, -limit - W - 5): + reach = at_start(raw, s0, "route_length", PUBLIC) + assert reach.outcome == "fail", (s0, reach.reason) + # No run failing only route_length starts short of the cutoff (the old cutoff was 0.5 ft + # short); runs within 1e-3 ft of it are evaluated starts that fail on their own. + result = solve(scene, PUBLIC) + for run in result["sweep"]: + if run["failing"] == ["route_length"] and run["start_ft"][0] > 0: + assert run["start_ft"][0] >= limit - 1e-3, run + + +def test_unexplored_end_within_drifted_reach_blocks_a_reject() -> None: + # An end at 24.6 ft: a spot just past it has its far edge 24.6 + W out, where the drifted + # error still leaves the route length unsure, so the end is not beyond reach. + raw = default_error_scene() + raw["walls"][0]["baseline"] = [[-40, 0], [24.6, 0]] + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + result = solve(parse_scene(raw, PUBLIC.rules), PUBLIC) + assert result["ends"]["right"]["beyond_reach"] is False + + +# --- 5. deep JSON ------------------------------------------------------------------------------- + + +def test_deeply_nested_input_is_a_scene_error() -> None: + raw = shared_fixture() + deep: list = [] + for _ in range(5000): + deep = [deep] + raw["extra"] = deep + with pytest.raises(SceneError): + parse_scene(raw, PUBLIC.rules) + + +def test_deeply_nested_body_is_422(client: TestClient) -> None: + body = '{"meter": ' + "[" * 5000 + "]" * 5000 + "}" + resp = client.post("/v1/placements", content=body, headers={"content-type": "application/json"}) + assert resp.status_code == 422 + + +# --- 6. private overrides and their citations --------------------------------------------------- + + +def test_private_value_without_its_own_source_is_refused(tmp_path: Path) -> None: + private = tmp_path / "rules.yaml" + private.write_text("clearances:\n gas_ft: {value: 4.0}\n") + with pytest.raises(ValueError, match=r"clearances\.gas_ft"): + load_rules(private) + + +def test_private_sources_are_withheld_from_answers(tmp_path: Path) -> None: + private = tmp_path / "rules.yaml" + private.write_text( + "policy: {id: p, version: '1', auto_approve: true, allow_reject: true}\n" + "clearances:\n gas_ft: {value: 4.0, source: 'SECRET CITATION'}\n" + # The ground surface check cites ground.source, set here alongside another ground key. + "ground:\n drivable: [drive]\n source: 'SECRET GROUND CITATION'\n" + ) + loaded = load_rules(private) + result = solve(parse_scene(shared_fixture(), loaded.rules), loaded) + assert "SECRET" not in json.dumps(result) + gas = next(c for c in result["checks"] if c["id"] == "gas_clearance") + ac = next(c for c in result["checks"] if c["id"] == "ac_clearance") + ground = next(c for c in result["checks"] if c["id"] == "ground_surface") + assert gas["rule"]["source"] == "Private rules" + assert ground["rule"]["source"] == "Private rules" + assert ac["rule"]["source"].startswith("Base help page") + + +def test_a_private_ground_list_is_not_shown_with_the_public_citation(tmp_path: Path) -> None: + # ground has one citation for the whole group; a private list under it makes it private. + private = tmp_path / "rules.yaml" + private.write_text("ground:\n allowed: [concrete, gravel]\n") + loaded = load_rules(private) + result = solve(parse_scene(shared_fixture(), loaded.rules), loaded) + ground = next(c for c in result["checks"] if c["id"] == "ground_surface") + assert ground["rule"]["source"] == "Private rules" + + +# --- served schemas ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("name", ["scene", "result"]) +def test_schemas_are_served(client: TestClient, name: str) -> None: + resp = client.get(f"/v1/schemas/{name}.json") + assert resp.status_code == 200 + assert resp.json() == json.loads((SERVER / "schemas" / f"{name}.schema.json").read_text()) + + +def test_battery_width_constant_matches_rules() -> None: + assert pytest.approx(PUBLIC.rules.battery.width_ft.value) == W + + +# --- validation messages don't echo the input --------------------------------------------------- + + +def test_validation_message_is_capped() -> None: + # Before: jsonschema's message quoted the whole offending value, so a 10 MB scene with a + # type error at the top would send 10 MB back. + raw = shared_fixture() + raw["meter"] = "x" * 20_000 + with pytest.raises(SceneError) as caught: + parse_scene(raw, PUBLIC.rules) + assert caught.value.path == "/meter" + assert len(caught.value.message) < 300 + assert caught.value.message.endswith("is not of type 'object'") + + +def test_refusal_body_stays_small(client: TestClient) -> None: + body = json.dumps({**shared_fixture(), "meter": [[[[1] * 3000]]]}) + resp = client.post("/v1/placements", content=body, headers={"content-type": "application/json"}) + assert resp.status_code == 422 + assert len(resp.content) < 600 diff --git a/server/tests/test_house_and_tolerance.py b/server/tests/test_house_and_tolerance.py new file mode 100644 index 000000000..5d36cb137 --- /dev/null +++ b/server/tests/test_house_and_tolerance.py @@ -0,0 +1,113 @@ +"""Regression tests for the caretaker's findings at 5a4b4f4; each failed before its fix.""" + +import math + +import pytest +from helpers import observed_band, rect, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from shapely.geometry import Point +from test_s4_round import PUBLIC, answer, parsed + +from solver import PASS, evaluate_start + +GROUND_CHECKS = { + "ground_surface", + "gas_clearance", + "ac_clearance", + "drive_clearance", + "pool_clearance", + "battery_clearance", +} + + +def without_ground(raw: dict) -> dict: + raw["coverage"]["observed"] = [o for o in raw["coverage"]["observed"] if o["band"] != "ground"] + for o in raw["coverage"]["observed"]: + o["span_ft"] = [-100, 100] + return raw + + +def back_wall(error: float) -> dict: + """The caretaker's witness: w3 runs back parallel 2 ft behind w1.""" + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-20, 0], [20, 0]], "height_ft": 9, "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[20, 0], [20, -2]], "height_ft": 9, "plus_minus_ft": 0}, + {"id": "w3", "baseline": [[20, -2], [-20, -2]], "height_ft": 9, "plus_minus_ft": error}, + ] + return without_ground(raw) + + +@pytest.mark.parametrize("error", [0.0, 4.0]) +def test_yard_in_front_of_a_wall_is_not_house(error: float) -> None: + # Before: w3's strip behind it reached across w1 into its yard, so (7, 3) counted as house. + assert not parsed(back_wall(error), PUBLIC).house().covers(Point(7, 3)) + assert parsed(back_wall(error), PUBLIC).house().covers(Point(7, -1)) # between the facades + + +@pytest.mark.parametrize("error", [0.0, 4.0]) +def test_a_spot_with_no_ground_seen_is_not_a_pass(error: float) -> None: + candidate = evaluate_start(parsed(back_wall(error), PUBLIC), PUBLIC, 6.0) + assert candidate.outcome != PASS + assert answer(back_wall(error))["decision"] != "pass" + + +# --- the seen-ground tolerance ------------------------------------------------------------------- + + +@pytest.mark.parametrize(("gap", "closes"), [(0.009, True), (0.011, False), (0.019, False)]) +def test_only_gaps_within_the_tolerance_close(gap: float, closes: bool) -> None: + # Before: growing seen ground by 0.005 each side and removing seams by another 0.005 closed + # gaps up to 0.02 ft, twice the 0.01 ft tolerance. + raw = shared_fixture() + observed_band(raw, "ground", [(-40, 7), (7 + gap, 40)], out=30) + unseen = parsed(raw, PUBLIC).unobserved_ground() + assert unseen.intersects(Point(7 + gap / 2, 5)) != closes + + +# --- no ground seen, nothing that needs ground passes ------------------------------------------- + + +@st.composite +def facades(draw: st.DrawFn) -> dict: + """A facade with a corner, a wall running back parallel behind the first, or a gap between + two walls (a side passage), at any error; no ground observations at all.""" + raw = shared_fixture() + c = draw(st.floats(10.0, 25.0)) + angle = math.radians(draw(st.floats(-100.0, 100.0))) + e1, e2 = draw(st.floats(0.0, 1.0)), draw(st.floats(0.0, 5.0)) + walls = [{"id": "w1", "baseline": [[-20, 0], [c, 0]], "height_ft": 9, "plus_minus_ft": e1}] + layout = draw(st.sampled_from(["back", "corner", "gap"])) + if layout == "gap": + # Wider than the join tolerance (sweep.wall_join_ft, 0.6), so it stays a gap. + g = c + draw(st.floats(1.0, 8.0)) + end = [g + 15 * math.cos(angle), 15 * math.sin(angle)] + walls.append({"id": "w2", "baseline": [[g, 0], end], "height_ft": 9, "plus_minus_ft": e2}) + elif layout == "back": + depth = draw(st.floats(0.5, 20.0)) + walls += [ + {"id": "w2", "baseline": [[c, 0], [c, -depth]], "height_ft": 9, "plus_minus_ft": e1}, + { + "id": "w3", + "baseline": [[c, -depth], [-20, -depth]], + "height_ft": 9, + "plus_minus_ft": e2, + }, + ] + else: + end = [c + 15 * math.cos(angle), 15 * math.sin(angle)] + walls.append({"id": "w2", "baseline": [[c, 0], end], "height_ft": 9, "plus_minus_ft": e2}) + raw["walls"] = walls + raw["ground"] = [{"type": "lawn", "polygon": rect(-80, 80, -80, 80), "plus_minus_ft": 0}] + return without_ground(raw) + + +@settings(max_examples=40, deadline=None) +@given(raw=facades(), starts=st.lists(st.floats(-15.0, 8.0), min_size=1, max_size=3)) +def test_with_no_ground_seen_no_check_that_needs_it_passes(raw: dict, starts: list[float]) -> None: + scene = parsed(raw, PUBLIC) + for s0 in starts: + candidate = evaluate_start(scene, PUBLIC, s0) + passed = {c.id for c in candidate.checks if c.outcome == PASS} & GROUND_CHECKS + assert not passed, (s0, passed) diff --git a/server/tests/test_last_round.py b/server/tests/test_last_round.py new file mode 100644 index 000000000..603590705 --- /dev/null +++ b/server/tests/test_last_round.py @@ -0,0 +1,88 @@ +"""Regression tests for the caretaker's three findings at 4a9fa44; each failed before its fix.""" + +import pytest +from helpers import at_start, observed_band, pads_ground, shared_fixture +from test_s4_round import PUBLIC, answer + +from rules import deep_merge, public_rules_dict, rules_from_dict +from solver import PASS, UNSURE + +# --- 4114443089: every wall the cable runs along carries its error ------------------------------ + + +def three_walls(corner_x: float) -> dict: + """w1 and w2 known to ±1 ft, the candidate wall w3 exact; their shared corner at x.""" + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0 + raw["walls"] = [ + {"id": "w1", "baseline": [[-4, 0], [corner_x, 0]], "height_ft": 9, "plus_minus_ft": 1}, + {"id": "w2", "baseline": [[corner_x, 0], [4, -4]], "height_ft": 9, "plus_minus_ft": 1}, + {"id": "w3", "baseline": [[4, -4], [20, -4]], "height_ft": 9, "plus_minus_ft": 0}, + ] + ground = pads_ground([(9.5, 12.5)]) + for g in ground: + g["polygon"] = [[x, z - 4 if z == 0 else z] for x, z in g["polygon"]] + raw["ground"] = ground + raw["overheads"], raw["facing"] = [], [] + for band in ("wall", "ground", "overhead", "facing"): + observed_band(raw, band, [(-40, 60)]) + return raw + + +def test_a_route_near_the_reach_line_through_uncertain_walls_is_not_a_pass() -> None: + # Before: 14.5 ± 0 passed; the same battery with the shared corner moved 1 ft (within the + # walls' ±1) is a 15.6 ft run and goes to review. + result = answer(three_walls(4.0)) + reach = next(c for c in result["checks"] if c["id"] == "route_length") + assert reach["outcome"] != PASS, reach + assert reach["plus_minus_ft"] >= 2 + + +def test_moving_the_corner_within_its_error_does_not_flip_the_decision() -> None: + assert answer(three_walls(4.0))["decision"] == answer(three_walls(5.0))["decision"] + + +# --- 4114443091: unseen wall where the route may run --------------------------------------------- + + +def short_wall(door: bool) -> dict: + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-4, 0], [20, 0]] + raw["meter"]["plus_minus_ft"] = 0.3 + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-4, 20] + observed_band(raw, "wall", [(0, 40)]) + if door: + raw["objects"] = [ + { + "type": "door", + "wall_id": "w1", + "span_ft": [-0.3, -0.1], + "bottom_ft": 0, + "top_ft": 7, + "source": "tape", + "plus_minus_ft": 0, + } + ] + return raw + + +def test_unseen_wall_within_the_meters_error_leaves_the_route_unsure() -> None: + # Before: the route passed while the wall at s -0.3 to 0, where the meter may be, was unseen; + # recording a door there made it UNSURE. + route = at_start(short_wall(door=False), 6.0, "route_path", PUBLIC) + assert (route.outcome, route.unsure_cause) == (UNSURE, "unobserved") + assert at_start(short_wall(door=True), 6.0, "route_path", PUBLIC).outcome == UNSURE + + +# --- 4114443100: a window exemption above the height the opening check requires ----------------- + + +def test_an_exemption_above_headroom_is_refused_when_the_rules_load() -> None: + data = deep_merge(public_rules_dict(), {"openings": {"exempt_bottom_above_ft": 10.0}}) + with pytest.raises(ValueError, match=r"openings\.exempt_bottom_above_ft"): + rules_from_dict(data) + + +def test_an_exemption_at_or_below_headroom_loads() -> None: + data = deep_merge(public_rules_dict(), {"openings": {"exempt_bottom_above_ft": 6.5}}) + assert rules_from_dict(data).rules.openings.exempt_bottom_above_ft == 6.5 diff --git a/server/tests/test_main_review.py b/server/tests/test_main_review.py new file mode 100644 index 000000000..20a9ab73e --- /dev/null +++ b/server/tests/test_main_review.py @@ -0,0 +1,217 @@ +"""Regression tests for the main-track review of #11 at 9edcd4b; each failed before its fix.""" + +import json +import math + +from fastapi.testclient import TestClient +from helpers import at_start, observed_band, pads_ground, parsed, rect, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from test_s4_round import PUBLIC, answer + +import api +from rules import deep_merge, public_rules_dict, rules_from_dict +from solver import PASS, UNSURE, evaluate_start, solve + +W = 31 / 12 + + +# --- 1. wall equipment carries the battery's position error ----------------------------------- + + +def test_equipment_within_the_walls_error_is_not_a_pass() -> None: + # Before: a box 0.204 ft from the battery's edge passed, though the wall is known to ±0.5. + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.5 + raw["ground"] = pads_ground([(1, 30)]) + raw["objects"] = [ + { + "type": "elec_box", + "wall_id": "w1", + "span_ft": [4.6, 5.6], + "bottom_ft": 4, + "top_ft": 5, + "source": "tape", + "plus_minus_ft": 0, + } + ] + check = at_start(raw, 1.8125, "wall_equipment_above", PUBLIC) + assert check.outcome == UNSURE + assert check.plus_minus >= 0.5 + + +# --- 2. a door that may or may not cross the route --------------------------------------------- + + +def door(span: list[float], wall_error: float = 0.0) -> dict: + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = wall_error + raw["objects"] = [ + { + "type": "door", + "wall_id": "w1", + "span_ft": span, + "bottom_ft": 0, + "top_ft": 7, + "source": "tape", + "plus_minus_ft": 0.5, + } + ] + return raw + + +def test_a_door_that_may_reach_the_route_is_not_a_pass() -> None: + # Before: its right edge at -0.4 ± 0.5 can reach past the meter at 0, yet the route passed. + assert at_start(door([-3.4, -0.4]), 6.0, "route_path", PUBLIC).outcome == UNSURE + + +def test_a_door_that_may_miss_the_route_is_not_a_reject() -> None: + # Before: its right edge at 0.1 ± 0.5 (wall ±0.5) can end left of the meter, yet every spot + # right of the meter failed and the scene was rejected. + raw = door([-2.9, 0.1], wall_error=0.5) + assert at_start(raw, 6.0, "route_path", PUBLIC).outcome == UNSURE + assert answer(raw)["decision"] != "reject" + + +def test_a_door_surely_across_the_route_still_fails() -> None: + assert at_start(door([1.0, 4.0]), 6.0, "route_path", PUBLIC).outcome == "fail" + + +# --- 3. the battery clearance reads the wall too ----------------------------------------------- + + +def six_ft_battery_rules(): + return rules_from_dict( + deep_merge( + public_rules_dict(), + {"clearances": {"battery_ft": {"value": 6.0, "source": "test"}}}, + ) + ) + + +def test_a_larger_battery_clearance_needs_the_wall_seen() -> None: + # Before: only ground was required, so a wall stretch unseen within 6 ft passed. + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 9.5), (13, 40)]) + check = at_start(raw, 6.0, "battery_clearance", six_ft_battery_rules()) + assert (check.outcome, check.unsure_cause) == (UNSURE, "unobserved") + assert any(v.band == "wall" for v in check.all_missing()) + + +def test_the_battery_check_is_left_out_only_when_the_gas_check_covers_it() -> None: + ids = {c.id for c in evaluate_start(parsed(shared_fixture(), PUBLIC), PUBLIC, 6.0).checks} + assert "battery_clearance" not in ids # battery_ft 3 <= gas_ft 3: gas reads both bands + loaded = six_ft_battery_rules() + ids = {c.id for c in evaluate_start(parsed(shared_fixture(), loaded), loaded, 6.0).checks} + assert "battery_clearance" in ids + + +# --- 4. start positions where the error grows with drift --------------------------------------- + + +def drift_window(lo: float = 8.005, hi: float = 8.030, z: float = 0.9) -> tuple[dict, float, float]: + """Two exact gas-meter points placed so a start passes only inside (lo, hi): each is 3 ft + plus the battery's error at that end away from the battery's nearest part. The error grows + 0.16 ft per foot from the meter (default wall error). `z` is how far out from the wall the + points stand; past the battery's depth the nearest part of it is a front corner.""" + + def e(s: float) -> float: + return 0.3 + 0.16 * (s + W) + + def along(s: float) -> float: + out = max(0.0, z - 22 / 12) + return math.sqrt((3 + e(s)) ** 2 - out**2) + + raw = shared_fixture() + del raw["walls"][0]["plus_minus_ft"] # default error with drift + raw["ground"] = pads_ground([(lo - e(lo) - 0.01, hi + W + e(hi) + 0.01)]) + raw["objects"] = [ + { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [x, x], + "bottom_ft": 0, + "top_ft": 0.1, + "source": "tape", + "plus_minus_ft": 0, + "footprint": [[x, z]], + } + for x in (lo - along(lo), hi + W + along(hi)) + ] + return raw, lo, hi + + +def test_the_narrow_interval_is_found_with_the_meters_in_front() -> None: + # The caretaker's follow-up: the same interval with the gas points 1 ft in front of the + # battery, so the nearest part is a front corner and the distance runs diagonally. + raw, lo, hi = drift_window(z=22 / 12 + 1) + assert evaluate_start(parsed(raw, PUBLIC), PUBLIC, (lo + hi) / 2).outcome == PASS + assert solve(parsed(raw, PUBLIC), PUBLIC)["stats"]["pass"] > 0 + + +@settings(max_examples=25, deadline=None) +@given( + lo=st.floats(min_value=3.0, max_value=12.0), + width=st.floats(min_value=0.004, max_value=0.2), + z=st.floats(min_value=0.1, max_value=4.0), +) +def test_a_start_that_passes_is_found_by_the_solve(lo: float, width: float, z: float) -> None: + raw, lo, hi = drift_window(lo, lo + width, z) + scene = parsed(raw, PUBLIC) + if evaluate_start(scene, PUBLIC, (lo + hi) / 2).outcome == PASS: + assert solve(scene, PUBLIC)["stats"]["pass"] > 0 + + +def test_a_passing_interval_narrower_than_the_grid_is_found() -> None: + # Before: the start positions used the segment's largest error, so the passing interval + # (8.005, 8.030) had no start in it and the scene went to manual review. + raw, lo, hi = drift_window() + assert evaluate_start(parsed(raw, PUBLIC), PUBLIC, (lo + hi) / 2).outcome == PASS + result = solve(parsed(raw, PUBLIC), PUBLIC) + assert result["stats"]["pass"] > 0 + + +# --- 5. the site plan draws an existing battery ------------------------------------------------ + + +def test_the_site_plan_draws_an_existing_battery() -> None: + raw = shared_fixture() + raw["objects"] = [ + { + "wall_id": "w1", + "type": "battery", + "span_ft": [12, 14.6], + "source": "tape", + "plus_minus_ft": 0.05, + "footprint": rect(12, 14.6, 0, 1.83), + } + ] + resp = TestClient(api.app).post( + "/v1/placements/site-plan.svg", + content=json.dumps(raw), + headers={"content-type": "application/json"}, + ) + assert resp.status_code == 200 + assert "Battery" in resp.text + + +# --- 7. an exact-end span is labelled with its own side ---------------------------------------- + + +def test_a_span_starting_at_the_right_end_is_labelled_right() -> None: + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [12, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-40, 12] + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + raw["objects"] = [ + { + "type": "window", + "wall_id": "w1", + "span_ft": [12, 20], + "bottom_ft": 3, + "top_ft": 7, + "source": "tap", + "plus_minus_ft": 0.1, + } + ] + assert [side for _, _, side, _ in parsed(raw, PUBLIC).set_aside] == ["right"] diff --git a/server/tests/test_more_space.py b/server/tests/test_more_space.py new file mode 100644 index 000000000..57e7a9267 --- /dev/null +++ b/server/tests/test_more_space.py @@ -0,0 +1,156 @@ +"""Scoreboard S2-3: more space never makes an answer worse. + +The monotone statement the rules support is per check, at a fixed battery start (the chosen spot +itself may move, since another start can improve too). Rank PASS < UNSURE < FAIL. With +everything else fixed and the same start evaluated: + +1. Moving one ground-standing obstacle (a gas meter, AC unit or pool with a plan footprint) + straight out from the wall the battery backs onto, which can only increase its plan distance + from the footprint, never raises any check's rank, nor the start's overall outcome. +2. Adding one more observed span to any band never raises any check's rank, nor the start's + overall outcome. + +And the strict rule holds on both sides: a check that PASSes with a measurement clears its +threshold by more than its error, so a FAIL can only become a PASS once its margin exceeds its +error. +""" + +import copy + +import pytest +from helpers import parsed, rect, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from test_final_review import cornered +from test_s4_round import PUBLIC + +from solver import EPS, PASS, evaluate_start + +RANK = {"pass": 0, "unsure": 1, "fail": 2} + + +def outcomes(raw: dict, s0: float) -> tuple[dict, str, list]: + candidate = evaluate_start(parsed(copy.deepcopy(raw), PUBLIC), PUBLIC, s0, "w1") + return {c.id: c.outcome for c in candidate.checks}, candidate.outcome, candidate.checks + + +def strict_rule_holds(checks: list) -> None: + for c in checks: + if c.outcome != PASS or c.measured is None or c.threshold is None: + continue + error = c.plus_minus or 0.0 + if c.comparison == "at_least": + assert c.measured - error - c.threshold > -EPS, c + elif c.comparison == "at_most": + assert c.threshold - (c.measured + error) > -EPS, c + + +def never_worse(before: tuple, after: tuple) -> None: + by_check, overall, checks = before + by_check_after, overall_after, checks_after = after + for check_id, outcome in by_check.items(): + assert RANK[by_check_after[check_id]] <= RANK[outcome], (check_id, outcome, by_check_after) + assert RANK[overall_after] <= RANK[overall], (overall, overall_after) + strict_rule_holds(checks) + strict_rule_holds(checks_after) + + +def first_wall_end(raw: dict) -> float: + return raw["walls"][0]["baseline"][1][0] + + +@settings(max_examples=40, deadline=None) +@given(raw=cornered(), data=st.data()) +def test_moving_an_obstacle_away_never_makes_a_check_worse(raw: dict, data) -> None: + end = first_wall_end(raw) + s0 = data.draw(st.floats(-25.0, end - 3.5), label="s0") + kind = data.draw(st.sampled_from(["gas_meter", "ac", "pool"]), label="kind") + x = data.draw(st.floats(-28.0, end), label="x") + z = data.draw(st.floats(0.0, 6.0), label="z") + farther = data.draw(st.floats(0.05, 8.0), label="farther") + error = data.draw(st.floats(0.0, 0.5), label="error") + + def with_obstacle(out: float) -> dict: + scene = copy.deepcopy(raw) + scene["objects"] = [ + { + "type": kind, + "wall_id": "w1", + "span_ft": [x, x + 1], + "bottom_ft": 0, + "top_ft": 1, + "source": "tape", + "plus_minus_ft": error, + "footprint": [[x, out], [x + 1, out], [x + 1, out + 1], [x, out + 1]], + } + ] + return scene + + never_worse(outcomes(with_obstacle(z), s0), outcomes(with_obstacle(z + farther), s0)) + + +@settings(max_examples=40, deadline=None) +@given(raw=cornered(), data=st.data()) +def test_seeing_more_never_makes_a_check_worse(raw: dict, data) -> None: + end = first_wall_end(raw) + s0 = data.draw(st.floats(-25.0, end - 3.5), label="s0") + band = data.draw(st.sampled_from(["wall", "ground", "overhead", "facing"]), label="band") + a = data.draw(st.floats(-35.0, end + 20), label="a") + b = data.draw(st.floats(a, end + 25), label="b") + item = {"band": band, "span_ft": [a, b]} + if band == "ground" or data.draw(st.booleans(), label="depth"): + item["out_ft"] = data.draw(st.floats(0.5, 20.0), label="out") + more = copy.deepcopy(raw) + more["coverage"]["observed"].append(item) + never_worse(outcomes(raw, s0), outcomes(more, s0)) + + +@pytest.mark.xfail( + strict=True, + reason="Open finding (listed, not fixed): an obstacle moved off the route turns UNSURE to FAIL", +) +def test_moving_a_gas_meter_off_the_route_start_never_makes_route_length_worse() -> None: + # Found by the property above in a random run (the same at 22292a8). Against the wall at + # x [0, 1] with +/- 0.5 ft, the gas meter may sit over the route's start at the meter, so the + # 20.42 ft run is +/- 1.1 ft of the 20 ft maximum: UNSURE. Moved 1 ft out it can't touch the + # route, the error drops to +/- 0.1 ft and the run is confidently over: FAIL. The FAIL is + # right; the statement above is stronger than the rules support when a move removes an error. + raw = cornered_example() + obstacle = { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [0, 1], + "bottom_ft": 0, + "top_ft": 1, + "source": "tape", + "plus_minus_ft": 0.5, + } + + def at(z: float) -> dict: + scene = copy.deepcopy(raw) + footprint = [[0, z], [1, z], [1, z + 1], [0, z + 1]] + scene["objects"] = [{**obstacle, "footprint": footprint}] + return scene + + never_worse(outcomes(at(0.0), -23.0), outcomes(at(1.0), -23.0)) + + +def cornered_example() -> dict: + """The shrunk scene: two collinear walls, limits at both ends, the ground seen 1 ft out.""" + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [4.0, 0]], "height_ft": 9, "plus_minus_ft": 0.1}, + {"id": "w2", "baseline": [[4.0, 0], [24.0, 0.0]], "height_ft": 9, "plus_minus_ft": 0.1}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-60, 60, -60, 60), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": "wall", "span_ft": [-30.0, 24.0]}, + {"band": "ground", "span_ft": [-30.0, 24.0], "out_ft": 1.0}, + {"band": "overhead", "span_ft": [-30.0, 24.0]}, + {"band": "facing", "span_ft": [-30.0, 24.0]}, + ], + } + return raw diff --git a/server/tests/test_policy_and_auth.py b/server/tests/test_policy_and_auth.py new file mode 100644 index 000000000..0d4acb201 --- /dev/null +++ b/server/tests/test_policy_and_auth.py @@ -0,0 +1,176 @@ +"""The demo policy, the strict one, private rules from the environment, and the key that guards a +server holding them. The private rules here are synthetic.""" + +import base64 +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from helpers import observed_band, shared_fixture + +import api +from rules import load_rules, public_rules_dict, rules_from_dict +from scene import parse_scene +from solver import solve + +EXAMPLES = Path(__file__).resolve().parents[1] / "examples" +SYNTHETIC_PRIVATE = ( + "policy: {id: private-test, version: '1', auto_approve: true, allow_reject: true}\n" + "clearances:\n gas_ft: {value: 4.0, source: 'test citation'}\n" +) +KEY = "test-key-not-a-secret" + + +def decide(name: str, loaded=None) -> dict: + loaded = loaded or rules_from_dict(public_rules_dict()) + raw = json.loads((EXAMPLES / name).read_text()) + return solve(parse_scene(raw, loaded.rules), loaded) + + +# --- the demo policy ------------------------------------------------------------------------------ + + +@pytest.mark.parametrize( + ("name", "decision"), + [ + ("pass-clear-side-wall.json", "pass"), + ("reject-garage-in-the-way.json", "reject"), + ("review-corner-not-walked.json", "manual_review"), + ], +) +def test_each_example_gets_its_decision_under_the_demo_policy(name: str, decision: str) -> None: + result = decide(name) + assert result["decision"] == decision + assert result["policy"]["id"] == "demo" + # Every answer says whose rules decided it. + assert "not Base's" in result["policy"]["notice"] + assert "not Base's" in result["summary"] + + +def test_the_unsure_example_asks_for_views() -> None: + # The walk stopped at the unexplored right end, so the view it lacks is past that end. It + # used to ask for ground at s [-3.93, -3] as well, which its capture had already seen past + # the left limit end. + missing = decide("review-corner-not-walked.json")["missing_evidence"] + assert [m["kind"] for m in missing] == ["past_end"] + + +def test_the_strict_policy_sends_everything_to_a_person(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOUSESCAN_POLICY", "strict") + loaded = load_rules(Path("/nonexistent/rules.yaml")) + assert (loaded.rules.policy.id, loaded.rules.policy.auto_approve) == ("public-strict", False) + assert decide("pass-clear-side-wall.json", loaded)["decision"] == "manual_review" + + +def test_an_unknown_policy_name_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOUSESCAN_POLICY", "lenient") + with pytest.raises(ValueError, match="HOUSESCAN_POLICY"): + load_rules(Path("/nonexistent/rules.yaml")) + + +# --- private rules from the environment ----------------------------------------------------------- + + +def test_private_rules_load_from_base64(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv( + "HOUSESCAN_PRIVATE_RULES_B64", base64.b64encode(SYNTHETIC_PRIVATE.encode()).decode() + ) + loaded = load_rules() + assert loaded.sources == ("public", "private") + assert loaded.rules.clearances.gas_ft.value == 4.0 + # The demo notice belongs to the public policy; private rules replace it with one naming the + # checks still on public placeholders (this synthetic file sets only gas_ft). + notice = loaded.rules.policy.notice or "" + assert "not Base's" not in notice + assert "pool_clearance" in notice + + +def test_bad_base64_is_a_startup_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HOUSESCAN_PRIVATE_RULES_B64", "not base64!") + with pytest.raises(ValueError, match="HOUSESCAN_PRIVATE_RULES_B64"): + load_rules() + + +def test_two_private_sources_are_refused(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + path = tmp_path / "rules.yaml" + path.write_text(SYNTHETIC_PRIVATE) + monkeypatch.setenv("HOUSESCAN_PRIVATE_RULES", str(path)) + monkeypatch.setenv( + "HOUSESCAN_PRIVATE_RULES_B64", base64.b64encode(SYNTHETIC_PRIVATE.encode()).decode() + ) + with pytest.raises(ValueError, match="both"): + load_rules() + + +# --- the key -------------------------------------------------------------------------------------- + + +@pytest.fixture +def private_api(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> TestClient: + path = tmp_path / "rules.yaml" + path.write_text(SYNTHETIC_PRIVATE) + monkeypatch.setattr(api, "LOADED", load_rules(path)) + monkeypatch.setattr(api, "API_KEY", KEY) + return TestClient(api.app) + + +SCENE = (EXAMPLES / "pass-clear-side-wall.json").read_bytes() +JSON = {"content-type": "application/json"} + + +@pytest.mark.parametrize("headers", [{}, {"authorization": "Bearer wrong"}, {"authorization": KEY}]) +def test_a_private_server_refuses_without_the_key(private_api: TestClient, headers: dict) -> None: + resp = private_api.post("/v1/placements", content=SCENE, headers=JSON | headers) + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == "unauthorized" + assert resp.headers["www-authenticate"] == "Bearer" + + +@pytest.mark.parametrize("path", ["/openapi.json", "/docs", "/v1/schemas/scene.json"]) +def test_every_route_but_health_needs_the_key(private_api: TestClient, path: str) -> None: + assert private_api.get(path).status_code == 401 + + +def test_with_the_key_the_private_policy_answers(private_api: TestClient) -> None: + auth = {"authorization": f"Bearer {KEY}"} + resp = private_api.post("/v1/placements", content=SCENE, headers=JSON | auth) + assert resp.status_code == 200 + assert resp.json()["policy"]["id"] == "private-test" + + +def test_health_says_private_rules_are_loaded_but_not_what(private_api: TestClient) -> None: + body = private_api.get("/health").json() + assert body["policy"] == {"sources": ["public", "private"]} + assert body["auth"] == "bearer" + assert "private-test" not in json.dumps(body) + + +def test_a_browser_preflight_is_not_refused(private_api: TestClient) -> None: + resp = private_api.options( + "/v1/placements", + headers={"origin": "https://example.com", "access-control-request-method": "POST"}, + ) + assert resp.status_code == 200 + + +def test_private_rules_without_a_key_answer_nothing( + private_api: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(api, "API_KEY", None) + resp = private_api.post("/v1/placements", content=SCENE, headers=JSON) + assert resp.status_code == 503 + assert resp.json()["error"]["code"] == "no_api_key" + + +def test_the_public_server_needs_no_key() -> None: + resp = TestClient(api.app).post("/v1/placements", content=SCENE, headers=JSON) + assert resp.status_code == 200 + + +def test_one_unseen_check_reads_in_the_singular() -> None: + raw = shared_fixture() + observed_band(raw, "overhead", [(-40, 5), (10, 40)]) # headroom alone unseen at the pad + loaded = rules_from_dict(public_rules_dict()) + result = solve(parse_scene(raw, loaded.rules), loaded) + assert "1 check depends on areas the scan did not see" in result["summary"] diff --git a/server/tests/test_properties.py b/server/tests/test_properties.py new file mode 100644 index 000000000..aae4ee9d6 --- /dev/null +++ b/server/tests/test_properties.py @@ -0,0 +1,512 @@ +"""Properties the placement solver must keep for every scene, not only the golden ones. + +All scenes are synthetic: one straight wall w1 along z = 0 from x = -16 to 16 (s = x, outward +z), +the meter at s = 0, lawn over one pad and deck (not an allowed surface) everywhere else, so every +spot that can pass lies on the pad. + +The rules are the golden test rules with a 0.5 ft filler grid instead of 2 in. That keeps each +solve in the tens of milliseconds, and it thins only the filler: the solver also starts a candidate +at every position where some check can change outcome, which is what these properties exercise. +""" + +import copy +from typing import Any + +import pytest +from helpers import ( + everything_observed, + golden_rules, + pads_ground, + parsed, + rect, + run, + shared_fixture, +) +from hypothesis import given, settings +from hypothesis import strategies as st + +from solver import FAIL, PASS, UNSURE, at_least, evaluate_start, reach_outcome + +LO, HI = -16.0, 16.0 +GRID = 0.5 +STEP = {"sweep": {"step_ft": {"value": GRID, "source": "test: coarse filler grid"}}} +FAST = golden_rules(**STEP) +W = FAST.rules.battery.width_ft.value +D = FAST.rules.battery.depth_ft.value +_C = FAST.rules.clearances +OPENING = _C.opening_ft.value +# The widest plan clearance measured over the ground: how far out the ground must have been seen. +GROUND_REACH = max(_C.gas_ft.value, _C.ac_ft.value, _C.drive_ft.value, _C.pool_ft.value) +SEVERITY = {PASS: 0, UNSURE: 1, FAIL: 2} +# A cut in coverage must overlap the stretch it is meant to hide by at least this much, so the +# unseen area is real geometry and not a floating point sliver. +OVERLAP = 0.05 + +# No example database: a failure is reproduced from the example pytest prints, so every run and CI +# behave the same whatever an earlier local run found. +PROPERTY = settings(max_examples=40, deadline=None, database=None) + + +def ft(lo: float, hi: float) -> st.SearchStrategy[float]: + return st.floats(lo, hi, allow_nan=False).map(lambda x: round(x, 3)) + + +# --- scene builders + + +def gas(g: float, err: float = 0.0) -> dict[str, Any]: + # top_ft is below the cable height, so the cable passes over it without a detour. + return { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [g, g + 1], + "bottom_ft": 0, + "top_ft": 0.8, + "source": "tap", + "plus_minus_ft": err, + "footprint": rect(g, g + 1, 0, 1), + } + + +def window(w: float, err: float = 0.0) -> dict[str, Any]: + return { + "type": "window", + "wall_id": "w1", + "span_ft": [w, w + 3], + "bottom_ft": 3, + "top_ft": 7, + "attrs": {"operable": True, "well": False}, + "source": "tap", + "plus_minus_ft": err, + } + + +def ac(a: float, err: float = 0.0) -> dict[str, Any]: + return { + "type": "ac", + "wall_id": "w1", + "span_ft": [a, a + 3], + "source": "tap", + "plus_minus_ft": err, + "footprint": rect(a, a + 3, 0.5, 3.5), + } + + +def elec_box(e: float, err: float = 0.0) -> dict[str, Any]: + return { + "type": "elec_box", + "wall_id": "w1", + "span_ft": [e, e + 1], + "bottom_ft": 4, + "top_ft": 5, + "source": "tap", + "plus_minus_ft": err, + } + + +def pool(q: float, err: float = 0.0) -> dict[str, Any]: + return { + "type": "pool", + "wall_id": "w1", + "span_ft": [q, q + 8], + "source": "tap", + "plus_minus_ft": err, + "footprint": rect(q, q + 8, 6, 14), + } + + +def scene( + pad: tuple[float, float], + objects: list[dict[str, Any]] | None = None, + facing: float = 9.0, + headroom: float = 9.0, + measured_err: float = 0.0, + wall_err: float = 0.0, + drive: float | None = None, +) -> dict[str, Any]: + # Deck past each end too, where a view past a limit end sees both sides: seen ground with + # no recorded surface may be a driveway (drive_clearance), so a fully observed scene records + # it. + ground = [ + *pads_ground([pad], LO, HI), + {"type": "deck", "polygon": rect(LO - 40, LO, -40, 40)}, + {"type": "deck", "polygon": rect(HI, HI + 40, -40, 40)}, + ] + if drive is not None: + ground.append({"type": "drive", "polygon": rect(drive, drive + 4, 0, 30)}) + return { + "meter": {"pos": [0.0, 5.0, 0.0], "wall_id": "w1", "plus_minus_ft": 0}, + "walls": [ + {"id": "w1", "baseline": [[LO, 0], [HI, 0]], "height_ft": 9, "plus_minus_ft": wall_err} + ], + "objects": objects or [], + "ground": ground, + "overheads": [ + { + "wall_id": "w1", + "span_ft": [LO, HI], + "clearance_ft": headroom, + "plus_minus_ft": measured_err, + } + ], + "facing": [ + { + "wall_id": "w1", + "span_ft": [LO, HI], + "depth_ft": facing, + "plus_minus_ft": measured_err, + } + ], + # Seen well past both wall ends, so no spot on the pad is unsure for lack of coverage. + "coverage": everything_observed(-60, 60, 30), + } + + +def _mirror_span(span: list[float]) -> list[float]: + return [-span[1], -span[0]] + + +def _mirror_points(points: list[list[float]]) -> list[list[float]]: + return [[-x, z] for x, z in reversed(points)] + + +def mirror(raw: dict[str, Any]) -> dict[str, Any]: + """The same scene reflected left for right (x -> -x, so s -> -s). Baselines are reversed so + they still run left to right as seen from outside, which keeps outward pointing out.""" + assert "keyframes" not in raw, "mirroring camera poses is not implemented" + m = copy.deepcopy(raw) + x, y, z = m["meter"]["pos"] + m["meter"]["pos"] = [-x, y, z] + m["walls"] = [{**w, "baseline": _mirror_points(w["baseline"])} for w in reversed(m["walls"])] + for o in m.get("objects", []): + o["span_ft"] = _mirror_span(o["span_ft"]) + if "footprint" in o: + o["footprint"] = _mirror_points(o["footprint"]) + for g in m.get("ground", []): + g["polygon"] = _mirror_points(g["polygon"]) + for item in m.get("overheads", []) + m.get("facing", []): + item["span_ft"] = _mirror_span(item["span_ft"]) + coverage = m.get("coverage", {}) + for obs in coverage.get("observed", []): + obs["span_ft"] = _mirror_span(obs["span_ft"]) + ends = coverage.get("ends", {}) + if ends: + coverage["ends"] = { + side: ends[other] + for side, other in (("left", "right"), ("right", "left")) + if other in ends + } + return m + + +def outcomes(result: dict[str, Any]) -> dict[str, str]: + return {c["id"]: c["outcome"] for c in result["checks"]} + + +# --- (b) equality is unsure: truth tables + + +@pytest.mark.parametrize( + ("value", "error", "threshold", "expected"), + [ + (4.0, 0.0, 3.0, PASS), + (2.0, 0.0, 3.0, FAIL), + (3.0, 0.0, 3.0, UNSURE), # exactly on the line + (3.6, 0.5, 3.0, PASS), + (3.5, 0.5, 3.0, UNSURE), # value - error on the line + (2.5, 0.5, 3.0, UNSURE), # value + error on the line + (2.4, 0.5, 3.0, FAIL), + (3.0, 1.0, 3.0, UNSURE), + (0.1 + 0.2, 0.0, 0.3, UNSURE), # 0.30000000000000004: float noise must not pass it + (0.0, 0.0, 0.0, UNSURE), # touching: the meter working space check + (-0.1, 0.0, 0.0, FAIL), + ], +) +def test_at_least_truth_table(value: float, error: float, threshold: float, expected: str) -> None: + assert at_least(value, error, threshold) == expected + + +@pytest.mark.parametrize( + ("length", "error", "expected"), + [ + (10.0, 0.0, PASS), + (15.0, 0.0, UNSURE), # exactly the confident reach + (17.0, 0.0, UNSURE), # between confident reach and maximum + (20.0, 0.0, UNSURE), # exactly the maximum + (21.0, 0.0, FAIL), + (14.0, 0.5, PASS), + (14.5, 0.5, UNSURE), # length + error on the confident line + (20.5, 0.5, UNSURE), # length - error on the maximum + (21.0, 0.5, FAIL), + (19.9, 0.5, UNSURE), + ], +) +def test_reach_outcome_truth_table(length: float, error: float, expected: str) -> None: + assert reach_outcome(length, error, 15.0, 20.0) == expected + + +@PROPERTY +@given(t=ft(0, 100), e=ft(0, 10)) +def test_at_least_on_either_error_edge_is_unsure(t: float, e: float) -> None: + assert at_least(t, 0.0, t) == UNSURE + assert at_least(t + e, e, t) == UNSURE + assert at_least(t - e, e, t) == UNSURE + + +@PROPERTY +@given(confident=ft(0, 50), extra=ft(0, 50), e=ft(0, 5)) +def test_reach_on_either_line_is_unsure(confident: float, extra: float, e: float) -> None: + maximum = confident + extra + assert reach_outcome(confident, 0.0, confident, maximum) == UNSURE + assert reach_outcome(maximum, 0.0, confident, maximum) == UNSURE + assert reach_outcome(confident - e, e, confident, maximum) == UNSURE + assert reach_outcome(maximum + e, e, confident, maximum) == UNSURE + + +# --- (a) missing coverage never passes + +BANDS = ["wall", "ground", "overhead", "facing"] + + +def needed(band: str, p0: float, p1: float) -> tuple[float, float]: + """The stretch of `band` every candidate on the pad [p0, p1] (right of the meter) depends on. + + A candidate starts at s0 in [p0, p1 - W]. The wall band carries its cable route (0 to s0), its + backing (s0 to s0 + W) and the opening clearance around it (to s0 + W + opening); the ground + band everything within the widest ground clearance of the footprint; overhead and facing only + the footprint's own stretch. The intersection over all candidates is what is returned. + """ + if band == "wall": + return (0.0, p0 + W + OPENING) + if band == "ground": + return (p1 - W - GROUND_REACH, p0 + W + GROUND_REACH) + return (p1 - W, p0 + W) + + +def _observed(raw: dict[str, Any], band: str) -> dict[str, Any]: + return next(o for o in raw["coverage"]["observed"] if o["band"] == band) + + +@st.composite +def pads(draw: st.DrawFn) -> tuple[float, float]: + p0 = draw(ft(2.0, 9.0)) + return (p0, round(p0 + draw(ft(W + 0.2, W + 2.0)), 3)) + + +@st.composite +def coverage_cuts(draw: st.DrawFn) -> tuple[tuple[float, float], str, str, float, float]: + pad = draw(pads()) + band = draw(st.sampled_from(BANDS)) + modes = ["remove", "hole", "cut_left", "cut_right"] + (["shallow"] * (band == "ground")) + mode = draw(st.sampled_from(modes)) + lo, hi = needed(band, *pad) + assert hi - lo > 2 * OVERLAP + at = draw(ft(lo + OVERLAP, hi - OVERLAP)) + size = draw(ft(2 * OVERLAP, 4.0)) + return pad, band, mode, at, size + + +def cut(raw: dict[str, Any], band: str, mode: str, at: float, size: float) -> dict[str, Any]: + raw = copy.deepcopy(raw) + observed = raw["coverage"]["observed"] + obs = _observed(raw, band) + a, b = obs["span_ft"] + if mode == "remove": + observed.remove(obs) + elif mode == "hole": + observed.remove(obs) + for span in ([a, at - size / 2], [at + size / 2, b]): + observed.append({**obs, "span_ft": span}) + elif mode == "cut_left": + obs["span_ft"] = [at, b] + elif mode == "cut_right": + obs["span_ft"] = [a, at] + else: # shallow: the ground was seen, but not as far out as the clearances reach + obs["out_ft"] = round(max(0.0, min(at, D + GROUND_REACH - OVERLAP)), 3) + return raw + + +def test_the_uncut_pad_scene_passes() -> None: + # The coverage property below means something only because the same scene, fully observed, + # passes. Checked at the generator's extremes. + for pad in [(2.0, 2.0 + W + 0.2), (9.0, 9.0 + W + 2.0), (6.0, 9.0)]: + for raw in (scene(pad), mirror(scene(pad))): + result = run(raw, FAST) + assert result["decision"] == PASS, (pad, result["summary"]) + + +@PROPERTY +@given(case=coverage_cuts(), flip=st.booleans()) +def test_missing_coverage_never_passes(case, flip: bool) -> None: + pad, band, mode, at, size = case + raw = cut(scene(pad), band, mode, at, size) + if flip: + raw = mirror(raw) + result = run(raw, FAST) + assert result["stats"]["pass"] == 0, result["summary"] + assert result["decision"] != PASS + assert result["spot"] is None or result["spot"]["outcome"] != PASS + + +# --- (c) left and right mirror + + +@st.composite +def layouts(draw: st.DrawFn) -> dict[str, Any]: + p0, p1 = draw(pads()) + if draw(st.booleans()): + p0, p1 = -p1, -p0 + objects = [] + # Nonzero errors put some best spots inside an error band, so manual_review is reached too. + g = draw(st.one_of(st.none(), ft(-14.0, 13.0))) + if g is not None: + objects.append(gas(g, draw(ft(0.0, 0.5)))) + w = draw(st.one_of(st.none(), ft(-14.0, 11.0))) + if w is not None: + objects.append(window(w, draw(ft(0.0, 0.5)))) + return scene((p0, p1), objects) + + +def assert_mirrored(a: dict[str, Any], b: dict[str, Any], tol: float) -> None: + """`b` is the answer for the mirror of `a`'s scene, positions equal within `tol`.""" + assert a["decision"] == b["decision"] + assert (a["spot"] is None) == (b["spot"] is None) + if a["spot"] is None: + near_a, near_b = a["nearest_considered"], b["nearest_considered"] + assert near_a["route_length_ft"] == pytest.approx(near_b["route_length_ft"], abs=tol) + return + assert outcomes(a) == outcomes(b) + assert a["route"]["length_ft"] == pytest.approx(b["route"]["length_ft"], abs=tol) + s0, s1 = a["spot"]["span_ft"] + assert b["spot"]["span_ft"] == pytest.approx([-s1, -s0], abs=tol) + + +# Start positions are anchored at the meter in left-edge and right-edge form, so a scene and its +# mirror try mirrored starts and must choose mirrored spots, to rounding. +EXACT = 1e-6 + + +@PROPERTY +@given(raw=layouts()) +def test_mirror_gives_the_mirrored_answer(raw: dict[str, Any]) -> None: + assert_mirrored(run(raw, FAST), run(mirror(raw), FAST), EXACT) + + +def test_mirror_of_a_scene_whose_pad_ends_at_its_own_edges() -> None: + # The pad's own edges bound the passing stretch here (standing next to deck is allowed), so + # the best spot is exact and the mirror must match to rounding. + raw = scene((6.0, 9.0), [gas(-5.0), window(12.0)]) + a, b = run(raw, FAST), run(mirror(raw), FAST) + assert a["decision"] == PASS + assert_mirrored(a, b, EXACT) + + +# Regression: the grid used to be anchored at the wall's left end, so with a clearance line +# bounding the pad the chosen start was 6.316667 and the mirrored one ended at -6.358333. +def test_mirror_is_exact_when_a_clearance_bounds_the_pad() -> None: + raw = shared_fixture() + raw["objects"] = [gas(2.3)] + a, b = run(raw), run(mirror(raw)) + assert a["decision"] == PASS + assert_mirrored(a, b, EXACT) + + +# --- (d) monotonicity: more room never hurts, a stricter rule never helps + +MOVERS = {"gas": gas, "ac": ac, "pool": pool, "window": window, "elec_box": elec_box} + + +@st.composite +def cluttered(draw: st.DrawFn) -> dict[str, Any]: + """Every kind of clearance subject at a random place, with random errors.""" + err = ft(0.0, 0.5) + positions = {name: draw(ft(-14.0, 12.0)) for name in MOVERS} + errors = {name: draw(err) for name in MOVERS} + return { + "pad": draw(pads()), + "positions": positions, + "errors": errors, + "facing": draw(ft(3.0, 9.0)), + "headroom": draw(ft(5.0, 9.0)), + "measured_err": draw(err), + "wall_err": draw(ft(0.0, 0.3)), + "drive": draw(st.one_of(st.none(), ft(-15.0, 11.0))), + "s0": draw(ft(-16.0, 16.0)), + } + + +def build(c: dict[str, Any], **changes: Any) -> dict[str, Any]: + c = {**c, **changes} + objects = [MOVERS[n](c["positions"][n], c["errors"][n]) for n in MOVERS] + return scene( + c["pad"], + objects, + facing=c["facing"], + headroom=c["headroom"], + measured_err=c["measured_err"], + wall_err=c["wall_err"], + drive=c["drive"], + ) + + +def evaluate(raw: dict[str, Any], s0: float, loaded=FAST) -> dict[str, str]: + return {c.id: c.outcome for c in evaluate_start(parsed(raw, loaded), loaded, s0).checks} + + +def never_more_severe(a: dict[str, str], b: dict[str, str]) -> None: + """No check is more severe in `a` than in `b` (so no pass in b is a fail in a).""" + worse = {k: (b[k], a[k]) for k in b if SEVERITY[a[k]] > SEVERITY[b[k]]} + assert not worse, worse + + +@PROPERTY +@given(c=cluttered(), mover=st.sampled_from(sorted(MOVERS)), delta=ft(0.01, 10.0)) +def test_moving_an_object_away_never_hurts(c: dict[str, Any], mover: str, delta: float) -> None: + s0 = c["s0"] + width = {"gas": 1.0, "ac": 3.0, "pool": 8.0, "window": 3.0, "elec_box": 1.0}[mover] + at = c["positions"][mover] + away = 1.0 if at + width / 2 >= s0 + W / 2 else -1.0 + moved = {**c["positions"], mover: round(at + away * delta, 3)} + never_more_severe(evaluate(build(c, positions=moved), s0), evaluate(build(c), s0)) + + +@PROPERTY +@given( + c=cluttered(), + which=st.sampled_from(["facing", "headroom"]), + delta=ft(0.01, 5.0), +) +def test_more_facing_depth_or_headroom_never_hurts( + c: dict[str, Any], which: str, delta: float +) -> None: + s0 = c["s0"] + more = build(c, **{which: round(c[which] + delta, 3)}) + never_more_severe(evaluate(more, s0), evaluate(build(c), s0)) + + +RULE_KEYS = [ + ("clearances", "gas_ft"), + ("clearances", "ac_ft"), + ("clearances", "opening_ft"), + ("clearances", "drive_ft"), + ("clearances", "pool_ft"), + ("clearances", "wall_equipment_ft"), + ("facing", "min_ft"), + ("headroom", "min_ft"), + ("meter_working_space", "width_ft"), + ("meter_working_space", "depth_ft"), +] + + +@PROPERTY +@given(c=cluttered(), key=st.sampled_from(RULE_KEYS), delta=ft(0.01, 5.0)) +def test_a_stricter_rule_never_helps(c: dict[str, Any], key: tuple[str, str], delta: float) -> None: + section, name = key + base = getattr(getattr(FAST.rules, section), name).value + stricter = golden_rules( + **STEP, **{section: {name: {"value": base + delta, "source": "test: stricter"}}} + ) + raw, s0 = build(c), c["s0"] + # Same candidate, same scene; the scene is parsed again because its modelled outdoor area + # grows with the widest clearance. + never_more_severe(evaluate(raw, s0, FAST), evaluate(raw, s0, stricter)) diff --git a/server/tests/test_review_regressions.py b/server/tests/test_review_regressions.py new file mode 100644 index 000000000..f7965b9f2 --- /dev/null +++ b/server/tests/test_review_regressions.py @@ -0,0 +1,488 @@ +"""Regressions from the solver review. Synthetic scenes built on the shared fixture in helpers.py. + +Thresholds are the golden test settings (GOLDEN_RULES), not Base policy: facing 5 ft from the +wall, headroom 6.5, clearances 3, confident reach 15, maximum 20, every default error 0. +""" + +import math +import time + +import pytest +from helpers import ( + W, + at_start, + check, + everything_observed, + golden_rules, + observed_band, + pads_ground, + parsed, + rect, + run, + shared_fixture, +) + +from scene import SceneError +from solver import evaluate_start + +# --- 1. unexplored end ---------------------------------------------------------------------------- + + +def test_unexplored_end_next_to_spot_is_not_a_pass() -> None: + # Before: the area past an unexplored end counted as seen, so a spot 0.02 ft from it passed. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [10, 0]] + raw["ground"] = pads_ground([(7.4, 30)], hi=30) + raw["coverage"] = everything_observed(-40, 30) + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + result = run(raw) + assert result["decision"] != "pass" + assert any( + c["outcome"] == "unsure" and c.get("unsure_cause") == "unobserved" for c in result["checks"] + ) + + +def test_limit_end_with_wall_beyond_spot_still_passes() -> None: + # Control for the test above: the same spot on a wall that continues and ends at a real limit. + raw = shared_fixture() + raw["ground"] = pads_ground([(7.4, 10)]) + assert run(raw)["decision"] == "pass" + + +# --- 2. doors with a footprint ------------------------------------------------------------------ + + +def test_garage_door_with_footprint_still_blocks_route() -> None: + # Before: a door given a footprint 0.35 ft off the wall was treated as standing off it. + raw = shared_fixture() + raw["ground"] = pads_ground([(10, 13)]) + raw["objects"].append( + { + "type": "garage_door", + "wall_id": "w1", + "span_ft": [3, 7], + "bottom_ft": 0, + "top_ft": 7, + "source": "tap", + "plus_minus_ft": 0.3, + "footprint": [[3, 0.35], [7, 0.35]], + } + ) + assert run(raw)["decision"] == "reject" + assert at_start(raw, 10.0, "route_path").outcome == "fail" + + +# --- 3. measurement span edges carry their error ------------------------------------------------ + + +def test_headroom_span_edge_error_counts() -> None: + # Before: a 5 ft overhead ending at s = 9 was ignored for a battery starting at 9, though + # where a stretch sits along the wall is only known to the wall's error (± 0.3 here). + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.3 + raw["ground"] = pads_ground([(8.5, 12.5)]) + raw["overheads"] = [ + {"wall_id": "w1", "span_ft": [-40, 9], "clearance_ft": 5, "plus_minus_ft": 0.5}, + {"wall_id": "w1", "span_ft": [9, 40], "clearance_ft": 9, "plus_minus_ft": 0.5}, + ] + assert at_start(raw, 9.0, "headroom").outcome == "unsure" + assert at_start(raw, 9.4, "headroom").outcome == "pass" + # A stretch that lies under the battery wherever the error puts it still fails outright. + assert at_start(raw, 6.0, "headroom").outcome == "fail" + + +def test_exact_touching_measurement_does_not_count() -> None: + # With exact geometry a stretch ending where the battery starts is beside it, not over it. + raw = shared_fixture() + raw["ground"] = pads_ground([(9, 12)]) + raw["overheads"] = [ + {"wall_id": "w1", "span_ft": [-40, 9], "clearance_ft": 5, "plus_minus_ft": 0.5}, + {"wall_id": "w1", "span_ft": [9, 40], "clearance_ft": 9, "plus_minus_ft": 0.5}, + ] + assert at_start(raw, 9.0, "headroom").outcome == "pass" + + +def test_facing_span_edge_error_counts() -> None: + # Before: a 1 ft facing gap ending at s = 9 was ignored for a battery starting at 9 on a wall + # known to ± 0.3. + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.3 + raw["ground"] = pads_ground([(8.5, 12.5)]) + raw["facing"] = [ + {"wall_id": "w1", "span_ft": [-40, 9], "depth_ft": 1, "plus_minus_ft": 0.5}, + {"wall_id": "w1", "span_ft": [9, 40], "depth_ft": 9, "plus_minus_ft": 0.5}, + ] + assert at_start(raw, 9.0, "facing_gap").outcome == "unsure" + assert at_start(raw, 9.4, "facing_gap").outcome == "pass" + + +# --- 4. meter past its wall's end ---------------------------------------------------------------- + + +def test_meter_past_wall_end_is_refused() -> None: + # Before: the meter was clamped to the wall end, silently shortening every route by 2.5 ft. + raw = shared_fixture() + raw["meter"]["pos"] = [42.5, 5, 0] + with pytest.raises(SceneError, match="past"): + parsed(raw) + + +def test_meter_past_wall_end_within_error_is_accepted() -> None: + raw = shared_fixture() + raw["meter"]["pos"] = [40.2, 5, 0] + raw["meter"]["plus_minus_ft"] = 0.3 + parsed(raw) + + +# --- 5. coverage radius ------------------------------------------------------------------------- + + +def test_coverage_radius_includes_wall_error() -> None: + # Before: coverage was checked to exactly 3 ft, though the wall itself is only known to ± 0.3. + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.3 + edge = 6 + W + 3.05 + observed_band(raw, "wall", [(-40, edge)]) + observed_band(raw, "ground", [(-40, edge)]) + for check_id in ("gas_clearance", "ac_clearance", "opening_clearance"): + c = at_start(raw, 6.0, check_id) + assert (c.outcome, c.unsure_cause) == ("unsure", "unobserved"), check_id + + +# --- 6. detour error ---------------------------------------------------------------------------- + + +def test_detour_error_flows_into_route_error() -> None: + # Before: a detour around a ± 1.5 ft photo detection added length but no error to the route. + rules = golden_rules(errors={"vlm_ft": {"value": 1.5, "source": "t"}}) + raw = shared_fixture() + raw["ground"] = pads_ground([(13, 16)]) + raw["objects"].append( + { + "type": "elec_box", + "wall_id": "w1", + "span_ft": [2, 3], + "bottom_ft": 0.5, + "top_ft": 2, + "source": "vlm", + } + ) + result = run(raw, rules) + assert result["route"]["plus_minus_ft"] >= 2 * 1.5 + assert result["decision"] == "manual_review" + + +# --- 7. gaps between walls ---------------------------------------------------------------------- + + +def two_wall_scene(w2_start, w2_end, pad): + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-40, 0], [3, 0]], "plus_minus_ft": 0.5}, + {"id": "w2", "baseline": [[w2_start, 0], [w2_end, 0]], "plus_minus_ft": 0.5}, + ] + raw["ground"] = pads_ground([pad], hi=w2_end) + raw["facing"] = [ + {"wall_id": "w1", "span_ft": [-40, 3], "depth_ft": 9, "plus_minus_ft": 0}, + {"wall_id": "w2", "span_ft": [w2_start, w2_end], "depth_ft": 9, "plus_minus_ft": 0}, + ] + raw["overheads"] = [ + {"wall_id": "w1", "span_ft": [-40, 3], "clearance_ft": 9, "plus_minus_ft": 0}, + {"wall_id": "w2", "span_ft": [w2_start, w2_end], "clearance_ft": 9, "plus_minus_ft": 0}, + ] + raw["coverage"] = everything_observed(-40, w2_end) + return raw + + +def test_gap_within_wall_error_is_manual_review() -> None: + # Before: a 0.7 ft gap between walls each ± 0.5 rejected, though the walls may meet. + raw = two_wall_scene(3.7, 40.7, (9.5, 13.5)) + result = run(raw) + assert result["decision"] == "manual_review" + assert check(result, "route_path")["outcome"] == "unsure" + + +def test_gap_beyond_wall_error_rejects() -> None: + raw = two_wall_scene(5, 40, (11, 15)) + assert run(raw)["decision"] == "reject" + assert at_start(raw, 11.0, "route_path").outcome == "fail" + + +# --- 8. collinear baseline points --------------------------------------------------------------- + + +@pytest.mark.parametrize( + "baseline", + [ + [[-40, 0], [7.5, 0], [40, 0]], + [[x, 0] for x in range(-40, 41, 2)], + ], + ids=["one-mid-point", "every-2-ft"], +) +def test_collinear_baseline_points_are_not_corners(baseline) -> None: + # Before: a point in the middle of a straight wall split it, failing backing across the point. + raw = shared_fixture() + raw["walls"][0]["baseline"] = baseline + result = run(raw) + assert result["decision"] == "pass" + assert result["spot"]["span_ft"] == pytest.approx([6, 6 + W], abs=1e-6) + + +# --- 9. starts from object edges ---------------------------------------------------------------- + + +def test_start_window_between_slanted_pipes_is_found() -> None: + # Before: only grid starts were tried, so a 0.025 ft pass window between two pipes was missed. + rules = golden_rules( + route={ + "confident_reach_ft": {"value": 30, "source": "t"}, + "max_ft": {"value": 40, "source": "t"}, + } + ) + lo, hi = 14.005, 14.03 + c_right = hi + W + 3 * math.sqrt(2) + c_left = lo - 3 * math.sqrt(2) + raw = shared_fixture() + raw["ground"] = pads_ground([(8, 17)]) + for c, pipe in ( + (c_right, [[c_right - 10, -10], [c_right + 30, 30]]), + (c_left, [[c_left + 10, -10], [c_left - 30, 30]]), + ): + raw["objects"].append( + { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [c, c], + "bottom_ft": 3, + "top_ft": 4, + "source": "tap", + "plus_minus_ft": 0, + "footprint": pipe, + } + ) + assert evaluate_start(parsed(raw, rules), rules, (lo + hi) / 2).outcome == "pass" + result = run(raw, rules) + assert result["decision"] == "pass" + assert lo < result["spot"]["span_ft"][0] < hi + + +# --- 10. sweep runs at corners ------------------------------------------------------------------ + + +def test_sweep_runs_split_at_corners() -> None: + # Before: pass runs on both segments of one wall merged into a run spanning the corner. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [8, 0], [8, -32]] + raw["ground"] = [ + {"type": "lawn", "polygon": rect(-40, 8, 0, 30), "plus_minus_ft": 0}, + {"type": "lawn", "polygon": rect(8, 40, -40, 30), "plus_minus_ft": 0}, + ] + result = run(raw) + passing = [r for r in result["sweep"] if r["outcome"] == "pass"] + assert passing + assert all("segment" in r for r in passing) + assert not [r for r in passing if r["start_ft"][0] < 8 - W and r["start_ft"][1] > 8 - W + 1e-6] + assert at_start(raw, 7.0, "wall_backing").outcome == "fail" + + +# --- 11. non-finite and absurd numbers ---------------------------------------------------------- + + +def _set_meter_nan(raw): + raw["meter"]["pos"][0] = float("nan") + + +def _set_baseline_inf(raw): + raw["walls"][0]["baseline"][1] = [float("inf"), 0] + + +def _set_coverage_nan(raw): + raw["coverage"]["observed"][0]["span_ft"] = [-40, float("nan")] + + +def _set_baseline_huge(raw): + raw["walls"][0]["baseline"][1] = [2e5, 0] + + +@pytest.mark.parametrize( + "mutate", + [_set_meter_nan, _set_baseline_inf, _set_coverage_nan, _set_baseline_huge], + ids=["nan-meter", "inf-baseline", "nan-coverage", "huge-baseline"], +) +def test_non_finite_or_huge_numbers_are_refused(mutate) -> None: + # Before: NaN and infinity passed the schema and crashed or poisoned the geometry. + raw = shared_fixture() + mutate(raw) + with pytest.raises(SceneError): + parsed(raw) + + +# --- 12. reach bound ---------------------------------------------------------------------------- + + +def test_long_wall_is_bounded_by_reach() -> None: + # Before: every start along a 2000 ft wall was fully evaluated, far past the cable's reach. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-1000, 0], [1000, 0]] + raw["ground"] = [{"type": "lawn", "polygon": rect(-1000, 1000, 0, 30), "plus_minus_ft": 0}] + raw["overheads"][0]["span_ft"] = [-1000, 1000] + raw["facing"][0]["span_ft"] = [-1000, 1000] + raw["coverage"] = everything_observed(-1000, 1000) + started = time.perf_counter() + result = run(raw) + assert time.perf_counter() - started < 1.5 + assert result["decision"] == "pass" + far = [ + r for r in result["sweep"] if r["outcome"] == "fail" and r["failing"] == ["route_length"] + ] + assert any(r["start_ft"][0] >= 20 for r in far) + assert any(r["start_ft"][1] <= -20 for r in far) + + +# --- 13. route length rule citation ------------------------------------------------------------- + + +def test_route_length_cites_both_lines() -> None: + # Before: the rule named only the maximum, hiding that a pass is decided by the confident + # reach. threshold_ft stays the maximum, the at_most line past which the run fails. + rules = golden_rules( + route={"confident_reach_ft": {"value": 15, "source": "reach test", "placeholder": True}} + ) + reach = check(run(shared_fixture(), rules), "route_length") + assert reach["threshold_ft"] == 20 + assert reach["review_threshold_ft"] == 15 + assert "reach test" in reach["rule"]["source"] + assert reach["rule"]["placeholder"] is True + + +def test_route_length_cites_maximum_on_reject() -> None: + raw = shared_fixture() + raw["ground"] = pads_ground([(21, 24)]) + assert run(raw)["decision"] == "reject" + # The result's nearest considered spot is a deck spot by the meter (it also fails only one + # check, with a shorter route), so check the length-rejected start on the pad directly. + reach = at_start(raw, 21.0, "route_length") + assert (reach.outcome, reach.rule_key) == ("fail", "route.max_ft") + + +# --- 14. gas clearance needs the wall ------------------------------------------------------------ + + +def test_gas_clearance_needs_the_wall_observed() -> None: + # Before: gas clearance checked only the ground band, missing an unseen wall-mounted meter. + rules = golden_rules(clearances={"gas_ft": {"value": 5, "source": "t"}}) + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 5), (12, 40)]) + result = run(raw, rules) + assert result["decision"] == "manual_review" + gas = check(result, "gas_clearance") + assert (gas["outcome"], gas.get("unsure_cause")) == ("unsure", "unobserved") + + +# --- second review round ---------------------------------------------------------------------- + + +def test_side_edge_reaches_an_object_standing_off_the_wall() -> None: + # Before: starts only came from where a footprint corner's track crosses a clearance circle, + # so a gas meter 0.9 ft off the wall (reached by a side edge) left the pass window unfound. + rules = golden_rules( + route={ + "confident_reach_ft": {"value": 30, "source": "t"}, + "max_ft": {"value": 40, "source": "t"}, + } + ) + raw = shared_fixture() + raw["ground"] = pads_ground([(8, 17)]) + for u in (14.005 - 3, 14.03 + W + 3): + raw["objects"].append( + { + "type": "gas_meter", + "wall_id": "w1", + "span_ft": [u, u], + "bottom_ft": 3, + "top_ft": 4, + "source": "tap", + "plus_minus_ft": 0, + "footprint": [[u, 0.9]], + } + ) + result = run(raw, rules) + assert result["decision"] == "pass" + assert 14.005 < result["spot"]["span_ft"][0] < 14.03 + + +def test_curved_taps_are_not_merged_into_one_false_wall() -> None: + # Before: dropped points were never rechecked, so a gentle curve became one straight wall + # up to 1.6 ft off the taps. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[x, 0.004 * x * x] for x in range(-40, 41)] + scene = parsed(raw) + for p in scene.walls: + for x in range(-40, 41): + s, out = p.local((x, 0.004 * x * x)) + if p.s0 <= s <= p.s1: + assert abs(out) <= 0.05 + 1e-9 + + +def test_observed_ground_near_an_unexplored_end_still_counts() -> None: + # Before: everything within reach of an unexplored end was unseen, even ground the scan saw + # in front of the wall, so a fully observed pad asked for photos it already had. + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-40, 0], [30, 0]] + raw["coverage"]["ends"]["right"] = {"kind": "unexplored"} + result = run(raw) + assert result["decision"] == "pass" + assert result["missing_evidence"] == [] + + +def test_walls_meeting_in_a_straight_line_are_one_stretch() -> None: + # Before: two collinear walls with different ids made a corner at the joint. + raw = shared_fixture() + raw["walls"] = [ + {"id": "w1", "baseline": [[-40, 0], [7.5, 0]], "plus_minus_ft": 0}, + {"id": "w2", "baseline": [[7.5, 0], [40, 0]], "plus_minus_ft": 0}, + ] + result = run(raw) + assert result["decision"] == "pass" + assert result["spot"]["span_ft"] == pytest.approx([6, 6 + W]) + assert result["spot"]["wall_id"] in {"w1", "w2"} + + +def test_oversized_scene_is_refused() -> None: + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[x / 10, (x % 2) / 10] for x in range(-400, 400)] + with pytest.raises(SceneError, match="is too long"): + parsed(raw) + + +@pytest.mark.parametrize( + ("start", "cause"), [(15.0, "margin"), (16.0, "rule_requires_review"), (20.0, "margin")] +) +def test_route_length_unsure_cause(start, cause) -> None: + # On a line the run is too close to call; clearly between the lines the policy sends it to a + # person, which more photos can't settle either. + raw = shared_fixture() + raw["ground"] = pads_ground([(start, start + 3)]) + reach = check(run(raw), "route_length") + assert (reach["outcome"], reach["unsure_cause"]) == ("unsure", cause) + + +def test_sweep_runs_only_claim_evaluated_starts_on_one_piece() -> None: + # Before (S4, case g03 at 44607e0): runs merged across pieces, so one pass run covered + # 15 ft of corner-crossing starts between two pads on a wall bent into short chords. + raw = shared_fixture() + arc = [[x, -0.02 * x * x] for x in (-9, -6, -4, -2, 0, 2, 4, 6, 7, 10)] + raw["walls"] = [{"id": "w1", "baseline": arc, "plus_minus_ft": 0}] + raw["ground"] = pads_ground([(-9.5, -5.5), (6.5, 10.5)], lo=-12, hi=12) + raw["coverage"] = everything_observed(-12, 12) + raw["facing"], raw["overheads"] = [], [] + scene = parsed(raw) + result = run(raw) + for r in result["sweep"]: + # The run's segment is the uploaded one; the piece it was evaluated on holds its start. + a, b = r["start_ft"] + piece = next(p for p in scene.walls if p.s0 - 1e-6 <= a < p.s1 - 1e-6) + assert piece.s0 - 1e-6 <= a <= b <= max(piece.s1 - W, piece.s0) + 1e-6, r + if r["outcome"] == "pass": + for s0 in (a, (a + b) / 2, b): + assert evaluate_start(scene, golden_rules(), s0).outcome == "pass", (r, s0) diff --git a/server/tests/test_s4_round.py b/server/tests/test_s4_round.py new file mode 100644 index 000000000..dce7a06ce --- /dev/null +++ b/server/tests/test_s4_round.py @@ -0,0 +1,270 @@ +"""Regression tests for S4's end-to-end run at 739fb6f; each failed before its fix.""" + +import copy + +import pytest +from helpers import at_start, observed_band, parsed, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from shapely.geometry import Point + +import solver +from rules import LoadedRules, public_rules_dict, rules_from_dict +from solver import FAIL, PASS, UNSURE, at_least, evaluate_start, solve + +PUBLIC = rules_from_dict(public_rules_dict()) +# Ground captured in answer to a request is seen this far out: past every clearance's reach. +FAR_FT = 40.0 + + +def answer(raw: dict, rules: LoadedRules = PUBLIC) -> dict: + return solve(parsed(raw, rules), rules) + + +def captured(raw: dict, result: dict) -> dict: + """The scene after the homeowner shows exactly the band views the answer asked for.""" + out = copy.deepcopy(raw) + observed = out.setdefault("coverage", {}).setdefault("observed", []) + for item in result["missing_evidence"]: + if item["kind"] == "band": + # Exactly what the request names, depth included. + depth = {"out_ft": item["out_ft"]} if "out_ft" in item else {} + observed.append({"band": item["band"], "span_ft": item["span_ft"], **depth}) + return out + + +def repeated_requests(raw: dict, result: dict) -> list[dict]: + """Band requests the scene already satisfies: the same request coming back.""" + seen: dict[str, list[tuple[float, float, float]]] = {} + for o in raw.get("coverage", {}).get("observed", []): + seen.setdefault(o["band"], []).append((*o["span_ft"], o.get("out_ft", float("inf")))) + return [ + item + for item in result["missing_evidence"] + if item["kind"] == "band" + and any( + a <= item["span_ft"][0] and item["span_ft"][1] <= b and out >= item.get("out_ft", 0) + for a, b, out in seen.get(item["band"], []) + ) + ] + + +# --- 1. every request can be satisfied by what it asks for -------------------------------------- + + +def no_coverage() -> dict: + """S4 case c5-no-coverage: a 16 ft wall, nothing observed, both ends unexplored.""" + return { + "schema_version": "1.0", + "meter": {"pos": [0.0, 4.0, 0.0], "wall_id": "w1", "plus_minus_ft": 0.0}, + "walls": [{"id": "w1", "baseline": [[-6, 0.0], [10, 0.0]], "plus_minus_ft": 0.0}], + "objects": [], + "ground": [ + { + "type": "concrete", + "polygon": [[-6, 0], [10, 0], [10, 10], [-6, 10]], + "plus_minus_ft": 0, + } + ], + "facing": [{"wall_id": "w1", "span_ft": [-6.0, 10.0], "depth_ft": 9.0, "plus_minus_ft": 0}], + } + + +def test_requests_past_an_unexplored_end_do_not_come_back() -> None: + # Before: ground [-13.9, -6] and wall [-6.9, -6], past the unexplored left end, were asked + # for again after being shown; only walking past the end (a past_end request) settles them. + raw = no_coverage() + for _ in range(3): + result = answer(raw) + assert repeated_requests(raw, result) == [] + raw = captured(raw, result) + assert any(m["kind"] == "past_end" for m in result["missing_evidence"]) + + +@st.composite +def scenes_with_ends(draw: st.DrawFn) -> dict: + """The shared wall shortened at random, each end limit or unexplored, coverage with gaps.""" + raw = shared_fixture() + left = draw(st.floats(min_value=-12, max_value=-0.5)) + right = draw(st.floats(min_value=12, max_value=25)) + raw["walls"][0]["baseline"] = [[left, 0], [right, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [left, right] + raw["coverage"]["ends"] = { + side: {"kind": draw(st.sampled_from(["limit", "unexplored"]))} for side in ("left", "right") + } + for band in ("wall", "ground", "overhead", "facing"): + cuts = draw(st.lists(st.floats(min_value=left, max_value=right), max_size=4)) + edges = [left, *sorted(cuts[: len(cuts) // 2 * 2]), right] + spans = [(a, b) for a, b in zip(edges[::2], edges[1::2], strict=True) if b > a] + observed_band(raw, band, spans or [(left, left + 0.1)], draw(st.floats(1, 30))) + if band in ("facing", "overhead"): + # Seen clear only so far (a walked path, a tilt-up frame), or all the way. + depth = draw(st.none() | st.floats(1, 12)) + for o in raw["coverage"]["observed"]: + if o["band"] == band and depth is not None: + o["out_ft"] = depth + return raw + + +@settings(max_examples=40, deadline=None) +@given(raw=scenes_with_ends()) +def test_a_captured_request_never_comes_back(raw: dict) -> None: + for _ in range(3): + result = answer(raw) + assert repeated_requests(raw, result) == [], result["missing_evidence"] + raw = captured(raw, result) + + +def request_past_a_limit_end() -> dict: + """One wall from s = -0.5 to 12, a limit at the left end and an unexplored right end whose + disc (radius reach, about 12.8 ft) reaches past the left end.""" + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-0.5, 0], [12.0, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-0.5, 12.0] + raw["coverage"]["ends"] = {"left": {"kind": "limit"}, "right": {"kind": "unexplored"}} + for band in ("wall", "ground", "overhead", "facing"): + observed_band(raw, band, [(-0.5, 12.0)], 1.0) + return raw + + +def test_a_request_past_a_limit_end_is_settled_by_capturing_it() -> None: + # Before: the first answer asked for ground from s = -4.01, past the left limit end; after + # that capture [-0.833, -0.5] 2.85 ft out (pool_clearance) was asked for on every round. + # That ground is in the right end's disc, where ground seen past the limit end didn't count. + raw = request_past_a_limit_end() + for _ in range(3): + result = answer(raw) + assert repeated_requests(raw, result) == [], result["missing_evidence"] + raw = captured(raw, result) + + +def test_ground_seen_past_a_limit_end_counts_in_an_unexplored_ends_disc() -> None: + # Inside the right end's disc, past the left limit end: seen once a view there reaches it. + raw = request_past_a_limit_end() + probe = Point(-0.7, 1.5) + assert parsed(raw, PUBLIC).unobserved_ground().contains(probe) + raw["coverage"]["observed"].append({"band": "ground", "span_ft": [-1.0, -0.5], "out_ft": 2.0}) + assert not parsed(raw, PUBLIC).unobserved_ground().contains(probe) + + +def test_ground_seen_past_an_unexplored_end_still_counts_for_nothing() -> None: + # Past the unexplored right end the extended line is a guess, so a view along it shows + # nothing about where the walls went. + raw = request_past_a_limit_end() + probe = Point(13.0, 1.5) + raw["coverage"]["observed"].append({"band": "ground", "span_ft": [12.0, 15.0], "out_ft": 5.0}) + assert parsed(raw, PUBLIC).unobserved_ground().contains(probe) + + +# --- 2. ground past a limit end is not clear until it is seen ------------------------------------ + + +def limit_end_near_the_spot() -> dict: + """A limit end at s = -1, 7 ft left of the only spot: the pool clearance reaches past it.""" + raw = shared_fixture() + raw["walls"][0]["baseline"] = [[-1, 0], [40, 0]] + raw["overheads"][0]["span_ft"] = raw["facing"][0]["span_ft"] = [-1, 40] + for band in ("wall", "ground", "overhead", "facing"): + observed_band(raw, band, [(-1, 40)]) + return raw + + +def test_pool_clearance_needs_the_ground_past_a_limit_end() -> None: + # Before: past a limit end the ground was never required, so the pool check passed with + # nothing seen beyond the fence. + raw = limit_end_near_the_spot() + result = answer(raw) + pool = next(c for c in result["checks"] if c["id"] == "pool_clearance") + assert (pool["outcome"], pool["unsure_cause"]) == (UNSURE, "unobserved") + ground = next(m for m in result["missing_evidence"] if m.get("band") == "ground") + assert ground["span_ft"][0] < -1 # past the end, shown by pointing the camera there + + +def test_showing_the_ground_past_a_limit_end_settles_it() -> None: + raw = limit_end_near_the_spot() + after = answer(captured(raw, answer(raw))) + pool = next(c for c in after["checks"] if c["id"] == "pool_clearance") + assert pool["outcome"] == PASS + + +def test_ground_behind_the_line_past_a_limit_end_counts() -> None: + # Ground seen only in front of the continued wall line leaves the other side unseen. + raw = limit_end_near_the_spot() + observed_band(raw, "ground", [(-20, 40)], out=FAR_FT) + s0 = answer(raw)["spot"]["span_ft"][0] + pool = at_start(raw, s0, "pool_clearance", PUBLIC) + assert pool.outcome == PASS # a span past the end covers both sides of the line + + +# --- 3. the reported error is the one the decision used ----------------------------------------- + + +def uncertain_obstruction() -> dict: + """An obstruction in front of the wall ends 0.3 ft before the battery, and the wall is only + known to ± 0.6 ft, so it may or may not be in front of the battery.""" + raw = shared_fixture() + raw["walls"][0]["plus_minus_ft"] = 0.6 + raw["facing"] = [ + {"wall_id": "w1", "span_ft": [-40, 5.7], "depth_ft": 9, "plus_minus_ft": 0.1}, + {"wall_id": "w1", "span_ft": [5.7, 6.7], "depth_ft": 1.0, "plus_minus_ft": 0.1}, + {"wall_id": "w1", "span_ft": [6.7, 40], "depth_ft": 9, "plus_minus_ft": 0.1}, + ] + return raw + + +def test_a_band_check_reports_numbers_that_give_its_outcome() -> None: + # Before: "-0 ft 10 in (± 0 ft 1 in) against a 3 ft rule: too close to call", which the + # C5 rule makes a clear fail; the position error that made it UNSURE went unreported. + gap = evaluate_start(parsed(uncertain_obstruction(), PUBLIC), PUBLIC, 7.0) + facing = next(c for c in gap.checks if c.id == "facing_gap") + assert facing.outcome == UNSURE + assert at_least(facing.measured, facing.plus_minus, facing.threshold) == UNSURE + assert "facing[1]" in facing.reason + + +@settings(max_examples=40, deadline=None) +@given(s0=st.floats(min_value=4.0, max_value=9.0)) +def test_band_check_numbers_agree_with_c5(s0: float) -> None: + candidate = evaluate_start(parsed(uncertain_obstruction(), PUBLIC), PUBLIC, s0) + for c in candidate.checks: + decided_by_numbers = c.outcome in (PASS, FAIL) or c.unsure_cause == "margin" + if c.id in ("facing_gap", "headroom") and c.measured is not None and decided_by_numbers: + assert at_least(c.measured, c.plus_minus, c.threshold) == c.outcome, c + + +# --- 4. a scene too slow to place is refused early ------------------------------------------------ + + +def test_a_scene_that_would_overrun_is_refused_early(monkeypatch: pytest.MonkeyPatch) -> None: + # Before: the solver spent the whole budget before refusing. Now the pace after the first + # PROJECT_AFTER positions decides. A clock that advances 1 ms per reading makes the pace + # exact: the shared wall's positions project past a budget not yet spent at that point. + scene = parsed(shared_fixture(), PUBLIC) + positions = sum(len(solver.Solver(scene, PUBLIC).starts(p)) for p in scene.walls) + readings = iter(range(10**6)) + + class Clock: + @staticmethod + def perf_counter() -> float: + return next(readings) / 1000 + + monkeypatch.setattr(solver, "time", Clock) + budget = (solver.PROJECT_AFTER + positions) / 2 / 1000 + with pytest.raises(solver.SceneTooComplex, match="would take about"): + solver.solve(scene, PUBLIC, budget_s=budget) + assert next(readings) < solver.PROJECT_AFTER + 10 + + +def test_unseen_ground_under_an_exact_footprint_is_not_clear() -> None: + # The coverage test at radius 0 (a wall with no error) must still see unseen ground under + # the footprint itself; a distance of 0 there is overlap, not touching. + raw = shared_fixture() + observed_band(raw, "ground", [(-40, 5), (10, 40)]) + ground = at_start(raw, 6.0, "ground_surface", PUBLIC) + assert (ground.outcome, ground.unsure_cause) == (UNSURE, "unobserved") + + +def test_ground_seen_right_up_to_the_footprint_is_clear() -> None: + raw = shared_fixture() + observed_band(raw, "ground", [(-40, 6), (6, 40)]) + assert at_start(raw, 6.0, "ground_surface", PUBLIC).outcome == PASS diff --git a/server/tests/test_schemas.py b/server/tests/test_schemas.py new file mode 100644 index 000000000..08e3888e8 --- /dev/null +++ b/server/tests/test_schemas.py @@ -0,0 +1,60 @@ +import copy +import json +from pathlib import Path + +import pytest +from jsonschema import Draft202012Validator + +SERVER = Path(__file__).resolve().parents[1] +SCENE_SCHEMA = json.loads((SERVER / "schemas" / "scene.schema.json").read_text()) +RESULT_SCHEMA = json.loads((SERVER / "schemas" / "result.schema.json").read_text()) +EXAMPLE = json.loads((SERVER / "tests" / "fixtures" / "example-scene.json").read_text()) + + +@pytest.mark.parametrize("schema", [SCENE_SCHEMA, RESULT_SCHEMA], ids=["scene", "result"]) +def test_schema_is_valid_draft_2020_12(schema) -> None: + Draft202012Validator.check_schema(schema) + + +def test_example_scene_validates() -> None: + Draft202012Validator(SCENE_SCHEMA).validate(EXAMPLE) + + +def test_minimal_scene_validates() -> None: + # The required fields alone, without any optional addition, are a valid scene. + minimal = { + "meter": {"pos": [0, 5, 0], "wall_id": "w1"}, + "walls": [{"id": "w1", "baseline": [[-10, 0], [10, 0]], "height_ft": 9}], + "objects": [ + { + "type": "window", + "wall_id": "w1", + "span_ft": [2, 4], + "bottom_ft": 3.1, + "top_ft": 6.2, + "attrs": {"operable": True, "well": False}, + "source": "vlm", + "conf": 0.8, + } + ], + "ground": [{"type": "lawn", "polygon": [[-10, 0], [10, 0], [10, 5]]}], + "overheads": [{"wall_id": "w1", "span_ft": [1, 2], "clearance_ft": 3.0}], + "facing": [{"wall_id": "w1", "span_ft": [1, 2], "depth_ft": 4.5}], + } + Draft202012Validator(SCENE_SCHEMA).validate(minimal) + + +@pytest.mark.parametrize( + ("mutate", "message"), + [ + (lambda s: s["objects"][0].update(plusminus_ft=0.3), "Additional properties"), + (lambda s: s["coverage"]["observed"][1].pop("out_ft"), "'out_ft' is a required property"), + (lambda s: s["objects"][0].update(type="fence"), "is not one of"), + (lambda s: s["walls"][0].update(baseline=[[0, 0]]), "is too short"), + ], +) +def test_scene_schema_rejects_loudly(mutate, message: str) -> None: + scene = copy.deepcopy(EXAMPLE) + mutate(scene) + errors = [e.message for e in Draft202012Validator(SCENE_SCHEMA).iter_errors(scene)] + assert any(message in e for e in errors), errors diff --git a/server/tests/test_siteplan.py b/server/tests/test_siteplan.py new file mode 100644 index 000000000..e4e1e0fd9 --- /dev/null +++ b/server/tests/test_siteplan.py @@ -0,0 +1,54 @@ +import html +import json +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest +from helpers import golden_rules, parsed, shared_fixture + +from siteplan import render, short +from solver import solve + +SVG = "{http://www.w3.org/2000/svg}" +EXAMPLE = json.loads((Path(__file__).parent / "fixtures" / "example-scene.json").read_text()) + + +def draw(raw): + loaded = golden_rules() + scene = parsed(raw, loaded) + result = solve(scene, loaded) + return result, render(scene, result, loaded) + + +@pytest.mark.parametrize("raw", [shared_fixture(), EXAMPLE], ids=["shared", "example"]) +def test_site_plan_is_well_formed_svg_with_the_key_elements(raw) -> None: + result, svg = draw(raw) + root = ET.fromstring(svg) + assert root.tag == f"{SVG}svg" + assert root.find(f"{SVG}title") is not None + assert html.escape(result["summary"]) in svg + classes = {el.get("class") for el in root.iter()} + assert {"wall", "meter", "cable"} <= classes + assert "battery" in classes or "battery unsure" in classes + assert not re.search(r"(? None: + assert draw(shared_fixture())[1] == draw(shared_fixture())[1] + + +def test_reject_draws_the_nearest_spot_as_unsure() -> None: + raw = shared_fixture() + for g in raw["ground"]: + g["type"] = "deck" + result, svg = draw(raw) + assert result["spot"] is None + assert 'class="battery unsure"' in svg + + +@pytest.mark.parametrize( + ("feet", "text"), [(3.0, "3 ft"), (7 / 12, "7 in"), (9 + 8 / 12, "9 ft 8 in")] +) +def test_short_lengths(feet, text) -> None: + assert short(feet) == text diff --git a/server/tests/test_smoke_script.py b/server/tests/test_smoke_script.py new file mode 100644 index 000000000..4835b0106 --- /dev/null +++ b/server/tests/test_smoke_script.py @@ -0,0 +1,99 @@ +"""examples/smoke.py sends the private key only over HTTPS (or to this machine), and never +follows a redirect with it.""" + +import http.server +import importlib.util +import threading +import urllib.request +from pathlib import Path +from typing import ClassVar + +import pytest + +SMOKE = Path(__file__).resolve().parents[1] / "examples" / "smoke.py" +spec = importlib.util.spec_from_file_location("smoke", SMOKE) +smoke = importlib.util.module_from_spec(spec) +spec.loader.exec_module(smoke) + + +@pytest.mark.parametrize( + "url", + [ + "http://house-scanning-server-private.vercel.app", + "http://192.168.1.20:8000", + "ftp://x.example", + ], +) +def test_a_key_is_never_sent_in_the_clear(url: str) -> None: + with pytest.raises(SystemExit, match="https"): + smoke.check_keyed_url(url) + + +@pytest.mark.parametrize( + "url", + [ + "https://house-scanning-server-private.vercel.app", + "http://localhost:8000", + "http://127.0.0.1:9", + ], +) +def test_https_and_this_machine_may_carry_a_key(url: str) -> None: + smoke.check_keyed_url(url) + + +class Recorder(http.server.BaseHTTPRequestHandler): + """Answers every POST; `target` redirects to it, and records what reached it.""" + + seen: ClassVar[list[str | None]] = [] + location = "" + + def do_POST(self) -> None: + if self.location: + self.send_response(302) + self.send_header("Location", self.location) + self.end_headers() + return + type(self).seen.append(self.headers.get("Authorization")) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"ok": true}') + + # urllib follows a 302 as a GET, carrying the request's headers with it. + do_GET = do_POST + + def log_message(self, *args: object) -> None: + pass + + +def serve(handler: type) -> http.server.HTTPServer: + server = http.server.HTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server + + +def test_a_keyed_request_does_not_follow_a_redirect() -> None: + target = serve(type("Target", (Recorder,), {"seen": [], "location": ""})) + port = target.server_address[1] + redirecting = serve( + type("Redirect", (Recorder,), {"seen": [], "location": f"http://127.0.0.1:{port}/"}) + ) + try: + url = f"http://127.0.0.1:{redirecting.server_address[1]}/v1/placements" + status, body = smoke.post(url, b"{}", "secret-for-this-test") + assert status == 302 + assert body["error"]["code"] == "redirect_refused" + assert target.RequestHandlerClass.seen == [] # the key never reached the other server + finally: + target.shutdown() + redirecting.shutdown() + + +def test_a_keyed_request_never_goes_through_a_proxy(monkeypatch: pytest.MonkeyPatch) -> None: + # CodeRabbit on 9edcd4b: with http_proxy set, a keyed request to localhost could go through + # the proxy, key included. Load the script with a proxy configured: the keyed opener must + # still have no proxy handler (an empty proxy table registers none). + monkeypatch.setenv("http_proxy", "http://127.0.0.1:9") + monkeypatch.setenv("https_proxy", "http://127.0.0.1:9") + fresh = importlib.util.module_from_spec(spec) + spec.loader.exec_module(fresh) + assert not [h for h in fresh._KEYED.handlers if isinstance(h, urllib.request.ProxyHandler)] diff --git a/server/tests/test_wall_height.py b/server/tests/test_wall_height.py new file mode 100644 index 000000000..8021607b7 --- /dev/null +++ b/server/tests/test_wall_height.py @@ -0,0 +1,105 @@ +"""How high up the wall the capture saw (`out_ft` on a wall entry), against the height each check +needs from rules.yaml. Absent `out_ft` means seen up to headroom height, as before.""" + +import copy +import itertools + +from helpers import at_start, observed_band, parsed, shared_fixture +from hypothesis import given, settings +from hypothesis import strategies as st +from test_s4_round import PUBLIC, answer + +from solver import SOLVE_BUDGET_S, UNSURE, Solver, evaluate_start + +RULES = PUBLIC.rules +BATTERY_TOP = RULES.battery.height_ft.value # the wall behind the battery +ROUTE = RULES.route.height_ft.value # where the cable runs +HEADROOM = RULES.headroom.min_ft.value # boxes, vents, openings and gas on the wall + + +def wall_seen_to(height: float | None) -> dict: + raw = shared_fixture() + observed_band(raw, "wall", [(-40, 40)]) + if height is not None: + raw["coverage"]["observed"][-1]["out_ft"] = height + return raw + + +def unseen(raw: dict, check_id: str) -> bool: + check = at_start(raw, 6.0, check_id, PUBLIC) + return check.outcome == UNSURE and check.unsure_cause == "unobserved" + + +def without_timing(result: dict) -> dict: + return result | {"stats": {k: v for k, v in result["stats"].items() if k != "elapsed_ms"}} + + +def test_absent_out_ft_is_seen_all_the_way_up() -> None: + absent, far = answer(wall_seen_to(None)), answer(wall_seen_to(100.0)) + # Only the input hash differs: the scenes differ by that one field. + far["stats"]["input_sha256"] = absent["stats"]["input_sha256"] + assert without_timing(absent) == without_timing(far) + + +def test_each_check_needs_its_own_height() -> None: + # Seen 2 ft up: enough for the cable at 1 ft, not for the battery's back or anything above it. + raw = wall_seen_to(2.0) + assert ROUTE < 2.0 < BATTERY_TOP + assert not unseen(raw, "route_path") + for check_id in ("wall_backing", "wall_equipment_above", "opening_clearance", "gas_clearance"): + assert unseen(raw, check_id), check_id + + +def test_the_battery_height_settles_its_back_but_not_what_is_above() -> None: + raw = wall_seen_to(BATTERY_TOP + 0.1) + assert not unseen(raw, "wall_backing") + assert unseen(raw, "wall_equipment_above") + + +def test_a_wall_request_names_the_height_that_settles_it() -> None: + request = next(m for m in answer(wall_seen_to(2.0))["missing_evidence"] if m["band"] == "wall") + assert request["out_ft"] > HEADROOM + assert "up the wall" in request["message"] + + +def supplied(raw: dict, result: dict, shortfall: float = 0.0) -> dict: + out = copy.deepcopy(raw) + for item in result["missing_evidence"]: + if item["kind"] == "band": + depth = {"out_ft": item["out_ft"] - shortfall} if "out_ft" in item else {} + out["coverage"]["observed"].append( + {"band": item["band"], "span_ft": item["span_ft"], **depth} + ) + return out + + +@settings(max_examples=40, deadline=None) +@given( + heights=st.lists(st.floats(min_value=0.2, max_value=9.0), min_size=1, max_size=4), + cuts=st.lists(st.floats(min_value=-15, max_value=25), min_size=3, max_size=3), +) +def test_supplying_the_requested_height_settles_it_and_less_does_not( + heights: list[float], cuts: list[float] +) -> None: + raw = shared_fixture() + edges = [-40.0, *sorted(cuts), 40.0] + observed_band(raw, "wall", []) + # Each stretch between cuts seen to its own height (the heights repeat if there are fewer). + for (a, b), height in zip(itertools.pairwise(edges), heights * 4, strict=False): + raw["coverage"]["observed"].append({"band": "wall", "span_ft": [a, b], "out_ft": height}) + result = answer(raw) + requests = [m for m in result["missing_evidence"] if m.get("band") == "wall"] + if not requests or result["spot"] is None: + return + # The exact start the solver chose; the answer's is rounded to 6 decimals. + s0 = min( + (c.s0 for c in Solver(parsed(raw, PUBLIC), PUBLIC).candidates(SOLVE_BUDGET_S)), + key=lambda start: abs(start - result["spot"]["span_ft"][0]), + ) + + def unseen_at_spot(scene: dict) -> list[str]: + candidate = evaluate_start(parsed(scene, PUBLIC), PUBLIC, s0) + return [c.id for c in candidate.checks if c.unsure_cause == "unobserved"] + + assert unseen_at_spot(supplied(raw, result)) == [] + assert unseen_at_spot(supplied(raw, result, shortfall=0.05)) != [] diff --git a/server/tests/test_wall_source.py b/server/tests/test_wall_source.py new file mode 100644 index 000000000..15642c34c --- /dev/null +++ b/server/tests/test_wall_source.py @@ -0,0 +1,53 @@ +"""A wall's `source` picks its default position error: tap (as when absent), mesh or plane.""" + +import pytest +from helpers import at_start, parsed, shared_fixture + +from rules import public_rules_dict, rules_from_dict +from scene import SceneError + +PUBLIC = rules_from_dict(public_rules_dict()) +ERRORS = PUBLIC.rules.errors + + +def wall_from(source: str | None) -> dict: + raw = shared_fixture() + del raw["walls"][0]["plus_minus_ft"] # take the default for the source + if source is not None: + raw["walls"][0]["source"] = source + return raw + + +@pytest.mark.parametrize( + ("source", "default"), + [ + (None, ERRORS.wall_ft), + ("tap", ERRORS.wall_ft), + ("mesh", ERRORS.mesh_ft), + ("plane", ERRORS.plane_ft), + ], +) +def test_each_source_takes_its_default_error(source: str | None, default) -> None: + meter_piece = parsed(wall_from(source), PUBLIC).meter_piece + assert meter_piece.plus_minus == pytest.approx(default.value) + + +def test_a_mesh_wall_and_a_tapped_wall_get_different_error_bars() -> None: + tapped = at_start(wall_from("tap"), 6.0, "meter_working_space", PUBLIC) + meshed = at_start(wall_from("mesh"), 6.0, "meter_working_space", PUBLIC) + # The same spot, measured against a wall known to 0.3 ft or to 0.5 ft (plus the same drift). + assert meshed.plus_minus - tapped.plus_minus == pytest.approx( + ERRORS.mesh_ft.value - ERRORS.wall_ft.value + ) + + +def test_an_explicit_error_overrides_the_source() -> None: + raw = wall_from("plane") + raw["walls"][0]["plus_minus_ft"] = 0.1 + assert parsed(raw, PUBLIC).meter_piece.plus_minus == pytest.approx(0.1) + + +def test_an_unknown_source_is_refused() -> None: + with pytest.raises(SceneError) as caught: + parsed(wall_from("lidar"), PUBLIC) + assert caught.value.path == "/walls/0/source" diff --git a/server/tests/test_within_errors.py b/server/tests/test_within_errors.py new file mode 100644 index 000000000..0490dafe5 --- /dev/null +++ b/server/tests/test_within_errors.py @@ -0,0 +1,603 @@ +"""A PASS holds in every world the declared errors allow. + +A scene is what the phone measured, each position to within its declared error. A check that +PASSes must stay clear of its rule wherever the true positions lie within those errors, so its +error has to include the error of everything its measurement depends on, not only the candidate +wall's. The property moves every input within its declared error and re-evaluates the same +battery: + +- The battery stays where the answer puts it: at its offset from the meter, where the AR view + anchors it (AGENTS.md), flush against its own wall. +- A position moves in its own coordinates: wall ends and corners, the meter, and object and + ground outlines anywhere within their error in plan; an object's span along s by its error; + a measured height or depth by its error. The contract defines an error as a distance ("the true + value lies within this distance"), so the extremes lie on a circle of that radius (sixteen + directions), not at the corners of a box, which are sqrt(2) times further. A corner shared by two + walls moves as one point, within both walls' errors. +- Coverage spans have no declared error and keep their s. +- Moves that contradict each other (the meter past the end of its own wall) describe no real + house and are discarded. + +Then, for every check that PASSed: in the perturbed scene read as exact (every error zero, so the +check reports whether the rule truly holds there) it still PASSes, its measurement on the pass +side of every threshold, the confident reach as well as the maximum. Only a measurement exactly +on a threshold (within float noise) may read as a tie there. In the perturbed scene with its +declared errors it is not FAIL. +""" + +import copy +import math + +import pytest +from helpers import parsed, rect, shared_fixture +from hypothesis import assume, given, reject, settings, target +from hypothesis import strategies as st +from shapely.geometry import Point +from test_final_review import cornered +from test_s4_round import PUBLIC + +from scene import SceneError +from solver import FAIL, PASS, UNSURE, evaluate_start, solve + +W = PUBLIC.rules.battery.width_ft.value +DIRECTIONS = [(math.cos(k * math.pi / 8), math.sin(k * math.pi / 8)) for k in range(16)] +# A measurement this close to a threshold is on it: float noise, not a side. +TIE_FT = 1e-9 +OBJECT_TYPES = ["gas_meter", "ac", "pool", "window", "door", "elec_box", "vent", "battery"] +ERROR = st.sampled_from([0.0, 0.05, 0.1, 0.2, 0.3]) + + +def error_of(item: dict) -> float: + return item.get("plus_minus_ft", 0.0) + + +@st.composite +def uncertain_corners(draw: st.DrawFn) -> dict: + """The fixed-point test's corner scenes with the meter anywhere up to the corner, declared + errors on the walls, the meter and every object, objects with and without plan outlines, a + second ground patch, measured overheads and facing gaps, and, half the time, everything + observed so checks can pass.""" + raw = draw(cornered()) + # Anywhere along its wall up to the corner, so a battery round the corner can come near the + # meter's working space. + corner = raw["walls"][0]["baseline"][1][0] + raw["meter"]["pos"][0] = draw(st.floats(-3.0, corner)) + scene = parsed(raw, PUBLIC) + # Two walls a join treats as one straight wall have no corner to move. + assume(sum(1 for p in scene.walls) == 2) + for wall in raw["walls"]: + wall["plus_minus_ft"] = draw(ERROR) + raw["meter"]["plus_minus_ft"] = draw(ERROR) + lo, hi = scene.walls[0].s0, scene.walls[-1].s1 + if draw(st.booleans()): + raw["coverage"]["observed"] = [ + {"band": b, "span_ft": [lo, hi], **({"out_ft": 30.0} if b == "ground" else {})} + for b in ("wall", "ground", "overhead", "facing") + ] + + def wall_at(s: float): + return next(p for p in scene.walls if p.s0 - 1e-9 <= s <= p.s1 + 1e-9) + + objects = [] + for _ in range(draw(st.integers(0, 3))): + kind = draw(st.sampled_from(OBJECT_TYPES)) + a = draw(st.floats(lo, hi - 1.0)) + length = draw(st.floats(0.5, 4.0)) + b = min(a + length, hi) + wall = wall_at(a) + obj = { + "type": kind, + "wall_id": wall.wall_id, + "span_ft": [a, b], + "bottom_ft": draw(st.sampled_from([0.0, 1.0, 3.0])), + "top_ft": 7.0, + "source": "tape", + "plus_minus_ft": draw(ERROR), + } + if kind in ("gas_meter", "ac", "pool", "battery") and draw(st.booleans()): + out = draw(st.floats(0.0, 8.0)) + depth = draw(st.floats(0.5, 3.0)) + # A rectangle along the wall its span starts on (one crossing the corner keeps going + # straight rather than folding into a self-intersecting outline). + corners = [(a, out), (b, out), (b, out + depth), (a, out + depth)] + obj["footprint"] = [list(wall.point(s, o)) for s, o in corners] + if kind == "window": + obj["attrs"] = {"operable": draw(st.booleans())} + objects.append(obj) + raw["objects"] = objects + if draw(st.booleans()): + kind = draw(st.sampled_from(["drive", "deck", "concrete"])) + x0, z0 = draw(st.floats(-20, 20)), draw(st.floats(0.5, 12)) + raw["ground"].append( + {"type": kind, "polygon": rect(x0, x0 + 6, z0, z0 + 4), "plus_minus_ft": draw(ERROR)} + ) + for key, value_key in (("overheads", "clearance_ft"), ("facing", "depth_ft")): + raw[key] = [] + for _ in range(draw(st.integers(0, 2))): + a = draw(st.floats(lo, hi - 1.0)) + raw[key].append( + { + "wall_id": wall_at(a).wall_id, + "span_ft": [a, min(a + draw(st.floats(0.5, 6.0)), hi)], + value_key: draw(st.floats(1.0, 12.0)), + "plus_minus_ft": draw(ERROR), + } + ) + return raw + + +RULE_OF = { + "gas_meter": "gas_ft", + "ac": "ac_ft", + "pool": "pool_ft", + "battery": "battery_ft", + "window": "opening_ft", + "door": "opening_ft", + "elec_box": "wall_equipment_ft", + "vent": "wall_equipment_ft", +} + + +def near_misses(raw: dict, piece, s0: float, data) -> dict: + """Up to two objects just past their rule from this battery, by a fraction of the errors + (up to twice the largest), beside it along the wall or in front of it: margins where an error + left out of a check shows.""" + out = copy.deepcopy(raw) + errors = [error_of(out["meter"]), *(error_of(w) for w in out["walls"])] + for i in range(data.draw(st.integers(0, 2), label="near misses")): + kind = data.draw(st.sampled_from(sorted(RULE_OF)), label=f"near {i} type") + rule = getattr(PUBLIC.rules.clearances, RULE_OF[kind]).value + gap = rule + data.draw(st.floats(0.0, 2.0), label=f"near {i} margin") * max(errors) + e = data.draw(ERROR, label=f"near {i} error") + obj = { + "type": kind, + "wall_id": piece.wall_id, + "bottom_ft": 3.0, + "top_ft": 7.0, + "source": "tape", + "plus_minus_ft": e, + } + where = data.draw( + st.sampled_from(["left", "right", "front", "other wall"]), label=f"near {i} where" + ) + others = [p for p in parsed(out, PUBLIC).walls if p.wall_id != piece.wall_id] + if where == "other wall" and others: + # On another wall's line, no outline, where its plan distance from the battery is + # just past the rule: it moves with that wall and the corners between. + other = others[0] + fp = piece.rect(s0, s0 + W, 0.0, PUBLIC.rules.battery.depth_ft.value) + near = [ + s + for s in (other.s0 + x * 0.05 for x in range(int((other.s1 - other.s0) / 0.05))) + if fp.distance(Point(other.point(s))) >= gap + e + ] + if not near: + continue + a = min(near, key=lambda s: fp.distance(Point(other.point(s)))) + obj["wall_id"] = other.wall_id + obj["span_ft"] = [a, a + 0.05] + elif where == "front" and kind in ("gas_meter", "ac", "pool", "battery"): + a = s0 + data.draw(st.floats(-1.0, W), label=f"near {i} along") + o = PUBLIC.rules.battery.depth_ft.value + gap + e + corners = [(a, o), (a + 1, o), (a + 1, o + 1), (a, o + 1)] + obj["span_ft"] = [a, a + 1] + obj["footprint"] = [list(piece.point(s, v)) for s, v in corners] + elif where == "left": + obj["span_ft"] = [s0 - gap - e - 1, s0 - gap - e] + else: + obj["span_ft"] = [s0 + W + gap + e, s0 + W + gap + e + 1] + if kind == "window": + obj["attrs"] = {"operable": True} + out["objects"].append(obj) + return out + + +def moved(point: list[float], radius: float, k: int | None) -> list[float]: + if k is None: + return list(point) + dx, dz = DIRECTIONS[k] + return [point[0] + radius * dx, point[1] + radius * dz] + + +def corner_moved(point: list[float], wall: dict, nxt: dict, next_error: float, k) -> list[float]: + """A corner two walls share, moved in direction k by up to the larger of their errors, as + far as it stays within each wall's error across that wall's line: along an exact wall it may + still move by the other wall's error (the caretaker's hazard-wall case).""" + if k is None: + return list(point) + dx, dz = DIRECTIONS[k] + limits = [] + for w, e in ((wall, error_of(wall)), (nxt, next_error)): + (ax, az), (bx, bz) = w["baseline"][0], w["baseline"][-1] + length = math.hypot(bx - ax, bz - az) + across = abs(dx * -(bz - az) / length + dz * (bx - ax) / length) + limits.append(e / across if across > 1e-12 else math.inf) + radius = min(max(error_of(wall), next_error), *limits) + return [point[0] + radius * dx, point[1] + radius * dz] + + +def perturbed(raw: dict, data) -> tuple[dict, tuple[float, float]]: + """The scene with every input moved within its declared error (the docstring's model), and + the meter's plan displacement.""" + out = copy.deepcopy(raw) + direction = st.none() | st.integers(0, len(DIRECTIONS) - 1) + sign = st.sampled_from([-1, 0, 1]) + walls, given = out["walls"], raw["walls"] + + def joined(i: int) -> bool: + """Whether wall i starts exactly where wall i - 1 ends: one corner, moved once.""" + return i > 0 and given[i - 1]["baseline"][-1] == given[i]["baseline"][0] + + for i, wall in enumerate(walls): + pts = wall["baseline"] + for j in range(len(pts)): + if j == 0 and joined(i): + pts[0] = list(walls[i - 1]["baseline"][-1]) + continue + k = data.draw(direction, label=f"wall {i} point {j}") + if j == len(pts) - 1 and i + 1 < len(walls) and joined(i + 1): + pts[j] = corner_moved(pts[j], wall, given[i + 1], error_of(walls[i + 1]), k) + else: + pts[j] = moved(pts[j], error_of(wall), k) + m = out["meter"] + k = data.draw(direction, label="meter") + x, _, z = m["pos"] + mx, mz = moved([x, z], error_of(m), k) + m["pos"] = [mx, m["pos"][1], mz] + for i, obj in enumerate(out["objects"]): + e = error_of(obj) + shift = data.draw(sign, label=f"object {i} span") * e + obj["span_ft"] = [obj["span_ft"][0] + shift, obj["span_ft"][1] + shift] + if "footprint" in obj: + k = data.draw(direction, label=f"object {i} outline") + if k is not None: + d = moved([0.0, 0.0], e, k) + obj["footprint"] = [[p[0] + d[0], p[1] + d[1]] for p in obj["footprint"]] + for i, patch in enumerate(out["ground"]): + k = data.draw(direction, label=f"ground {i}") + if k is not None: + d = moved([0.0, 0.0], error_of(patch), k) + patch["polygon"] = [[p[0] + d[0], p[1] + d[1]] for p in patch["polygon"]] + for key, value_key in (("overheads", "clearance_ft"), ("facing", "depth_ft")): + for i, entry in enumerate(out[key]): + change = data.draw(sign, label=f"{key} {i}") * error_of(entry) + entry[value_key] = max(0.0, entry[value_key] + change) + return out, (mx - x, mz - z) + + +def exact(raw: dict) -> dict: + """The same scene read as exact: every declared error zero.""" + out = copy.deepcopy(raw) + for item in [out["meter"], *out["walls"], *out["objects"], *out["ground"]]: + item["plus_minus_ft"] = 0.0 + for item in [*out["overheads"], *out["facing"]]: + item["plus_minus_ft"] = 0.0 + return out + + +def same_battery(raw: dict, s0: float, wall_id: str, moved_raw: dict, meter_shift) -> dict: + """The battery at its offset from the meter in the perturbed scene: the point of the wall + behind its middle moves with the meter, and it stands flush against the moved wall there. + Returns its checks by id.""" + scene = parsed(raw, PUBLIC) + candidate = evaluate_start(scene, PUBLIC, s0, wall_id) + middle = candidate.piece.point(s0 + W / 2) + target = (middle[0] + meter_shift[0], middle[1] + meter_shift[1]) + other = parsed(moved_raw, PUBLIC) + # A move within the errors can make two nearly collinear walls one straight wall, which keeps + # the first wall's id. + pieces = [p for p in other.walls if p.wall_id == candidate.piece.wall_id] or other.walls + piece = min(pieces, key=lambda p: abs(p.local(target)[1])) + s_mid = piece.local(target)[0] + # The round trip through plan coordinates adds float noise (1e-16 ft) that can cross the + # solver's 1e-9 ft thresholds; a start that didn't move keeps its value. Every nonzero error + # here is 0.05 ft or more. + start = s0 if abs(s_mid - W / 2 - s0) < 1e-6 else s_mid - W / 2 + result = evaluate_start(other, PUBLIC, start, piece.wall_id) + return {c.id: c for c in result.checks} + + +@settings(max_examples=40, deadline=None) +@given(raw=uncertain_corners(), data=st.data()) +def test_a_pass_holds_wherever_the_declared_errors_put_things(raw: dict, data) -> None: + scene = parsed(raw, PUBLIC) + piece = data.draw(st.sampled_from(scene.walls), label="wall") + assume(piece.s1 - piece.s0 >= W) + s0 = data.draw(st.floats(piece.s0, piece.s1 - W), label="s0") + if data.draw(st.integers(0, 2), label="near the confident reach") == 0: + # The route just short of the confident reach, by a fraction of the errors, where a route + # error left out shows (the caretaker's witness): shift the battery by what its nominal + # route lacks. + reach = PUBLIC.rules.route.confident_reach_ft.value + errors = [error_of(raw["meter"]), *(error_of(w) for w in raw["walls"])] + gap = data.draw(st.floats(0.0, 1.0), label="short of the reach") * 3 * max(errors) + length = evaluate_start(scene, PUBLIC, s0, piece.wall_id).route.length + start = s0 + (1 if piece.s0 >= 0 else -1) * (reach - gap - length) + if piece.s0 <= start <= piece.s1 - W: + s0 = start + raw = near_misses(raw, piece, s0, data) + scene = parsed(raw, PUBLIC) + before = {c.id: c for c in evaluate_start(scene, PUBLIC, s0, piece.wall_id).checks} + passing = [cid for cid, c in before.items() if c.outcome == PASS] + assume(passing) + moved_raw, shift = perturbed(raw, data) + try: + declared = same_battery(raw, s0, piece.wall_id, moved_raw, shift) + truth = same_battery(raw, s0, piece.wall_id, exact(moved_raw), shift) + except SceneError: + # Moves that contradict each other (the meter past the end of its own wall) describe no + # real house. + reject() + # Steer the search toward near misses: the least slack any passing check keeps in the exact + # scene. + slack = [slack_of(truth[cid]) for cid in passing if slack_of(truth[cid]) is not None] + if slack: + target(-min(slack), label="closest to breaking") + for cid in passing: + c = declared[cid] + assert c.outcome != FAIL, (cid, "declared errors", c.measured, c.plus_minus, c.reason) + c = truth[cid] + slack = slack_of(c) + tie = c.outcome == UNSURE and slack is not None and abs(slack) <= TIE_FT + assert c.outcome == PASS or tie, (cid, "exact", c.measured, c.reason) + + +def slack_of(c) -> float | None: + """How far a check's measurement is from the nearest threshold it must clear (the maximum, + and the confident reach for the route), positive on the pass side.""" + if c.measured is None or c.threshold is None: + return None + if c.comparison == "at_least": + return c.measured - c.threshold + lines = [c.threshold] + ([c.review_threshold] if c.review_threshold is not None else []) + return min(lines) - c.measured + + +def test_a_scene_whose_overlays_meet_at_a_far_corner_is_answered() -> None: + # Found by the property: these exact walls (w1's ends moved within 0.3 ft of a corner scene) + # made GEOS raise "side location conflict" while subtracting the seen ground, a 500 for a + # valid scene. + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.0 + corner = [8.382847167508826, -0.035355339059327376] + raw["walls"] = [ + { + "id": "w1", + "baseline": [[-30.212132034355964, 0.21213203435596426], corner], + "height_ft": 9, + "plus_minus_ft": 0.0, + }, + { + "id": "w2", + "baseline": [corner, [23.96112173570757, -12.586407820996747]], + "height_ft": 9, + "plus_minus_ft": 0.0, + }, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-60, 60, -60, 60), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + end = 28.418202506568154 + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": b, "span_ft": [-30.0, end], **({"out_ft": 1.0} if b == "ground" else {})} + for b in ("wall", "ground", "overhead", "facing") + ], + } + assert solve(parsed(raw, PUBLIC), PUBLIC)["decision"] in ("pass", "reject", "manual_review") + + +def meter_corner(meter_wall_z: float = 0.0, meter_wall_error: float = 0.5) -> dict: + """The caretaker's witness: the meter on w1 [[-4, 0], [2, 0]] (+/- 0.5 ft), the battery on the + exact wall w2 [[2, 0], [2, 10]] over lawn, everything seen.""" + raw = shared_fixture() + raw["meter"] = {"pos": [0.0, 5.0, 0.0], "wall_id": "w1", "plus_minus_ft": 0.0} + raw["walls"] = [ + { + "id": "w1", + "baseline": [[-4, meter_wall_z], [2, meter_wall_z]], + "height_ft": 9, + "plus_minus_ft": meter_wall_error, + }, + {"id": "w2", "baseline": [[2, 0], [2, 10]], "height_ft": 9, "plus_minus_ft": 0.0}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(0.1, 2.1, 3.2, 6), "plus_minus_ft": 0.0}] + raw["objects"], raw["overheads"], raw["facing"] = [], [], [] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": b, "span_ft": [-4, 12], **({"out_ft": 20} if b == "ground" else {})} + for b in ("wall", "ground", "overhead", "facing") + ], + } + return raw + + +def working_space(raw: dict, s0: float = 5.2): + checks = evaluate_start(parsed(raw, PUBLIC), PUBLIC, s0, "w2").checks + return next(c for c in checks if c.id == "meter_working_space") + + +def test_the_meter_walls_error_counts_toward_its_working_space() -> None: + # Before: 0.2 ft clear (+/- 0) was a PASS, though the working space is drawn in front of w1, + # which may lie 0.5 ft closer to the battery. + c = working_space(meter_corner()) + assert (c.measured, c.outcome) == (pytest.approx(0.2), "unsure") + assert c.plus_minus >= 0.5 + + +def test_a_battery_in_the_working_space_from_another_wall_fails() -> None: + # w1 moved 0.3 ft toward the battery, read as exact: the battery on w2 still starts at + # z = 3.2 (s = 5.5, as w2 now follows a 0.3 ft gap) and stands 0.1 ft inside the working + # space. Before, the overlap was measured along s as 0, a tie, never a FAIL. + c = working_space(meter_corner(meter_wall_z=0.3, meter_wall_error=0.0), s0=5.5) + assert c.measured == pytest.approx(-0.1) + assert c.outcome == "fail" + + +def test_the_meters_error_counts_toward_where_the_battery_stands() -> None: + # Found by the property: on the exact wall w2 the battery ended 0.042 ft short of the wall's + # end, a PASS, but it is placed by its offset from the meter, and the meter (+/- 0.05 ft) may + # stand 0.05 ft further along: the battery then runs past the end. + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.05 + end = [25.121953903127825, 0.34904812874567026] + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [5.125, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[5.125, 0], end], "height_ft": 9, "plus_minus_ft": 0.0}, + ] + checks = evaluate_start(parsed(raw, PUBLIC), PUBLIC, 22.5, "w2").checks + backing = next(c for c in checks if c.id == "wall_backing") + assert backing.outcome == "unsure" + + +def test_a_meter_move_slides_a_battery_round_the_corner_along_its_wall() -> None: + # The meter (+/- 0.3 ft) on w1 and the battery on the exact wall w2 at a right angle: a box + # on w2 cleared the battery along the wall by 0.35 ft, a PASS with 0.3 ft of error. Moving + # the meter 0.3 ft at 135 degrees moves s = 0 back along w1 and the battery up w2, 0.42 ft in + # s together (0.3 * sqrt(2)), and the box then sits over the battery. + t = PUBLIC.rules.clearances.wall_equipment_ft.value + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.3 + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [4.0, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[4.0, 0], [4.0, 20]], "height_ft": 9, "plus_minus_ft": 0.0}, + ] + box = 10.0 + W + t + 0.35 + raw["objects"] = [ + { + "type": "elec_box", + "wall_id": "w2", + "span_ft": [box, box + 1], + "bottom_ft": 3, + "top_ft": 5, + "source": "tape", + "plus_minus_ft": 0.0, + } + ] + checks = evaluate_start(parsed(raw, PUBLIC), PUBLIC, 10.0, "w2").checks + above = next(c for c in checks if c.id == "wall_equipment_above") + assert (above.measured, above.outcome) == (pytest.approx(0.35), "unsure") + + +def test_ground_requests_ignore_lines_left_where_edges_touch() -> None: + # Reduced from a simulator capture: with the snapped overlays, clipping its unseen ground to + # a check's radius left a line beside the area, and the next overlay raised "mixed-dimension". + raw = { + "meter": {"pos": [5.3468, 4.9213, 15.6219], "wall_id": "wall", "plus_minus_ft": 0.9843}, + "walls": [{"id": "wall", "baseline": [[2.7079, 4.9432], [8.9454, 30.1839]]}], + "objects": [ + { + "type": "window", + "wall_id": "wall", + "span_ft": [6.7257, 9.6785], + "bottom_ft": 3.1168, + "top_ft": 6.0696, + "source": "tap", + "plus_minus_ft": 0.9843, + "attrs": {"operable": True}, + } + ], + "ground": [], + "overheads": [], + "facing": [], + "coverage": { + "ends": {"left": {"kind": "unexplored"}, "right": {"kind": "unexplored"}}, + "observed": [{"band": "ground", "span_ft": [-1.5, 0], "out_ft": 3.5}], + }, + } + assert solve(parsed(raw, PUBLIC), PUBLIC)["decision"] == "manual_review" + + +def route_corner(meter: tuple[float, float] = (0.0, 0.0), meter_error: float = 0.3) -> dict: + """The caretaker's route witness: exact right-angle walls, the meter (+/- 0.3 ft) on w1's + line, lawn only where the battery stands on w2, everything seen.""" + raw = shared_fixture() + raw["meter"] = {"pos": [meter[0], 5.0, meter[1]], "wall_id": "w1", "plus_minus_ft": meter_error} + raw["walls"] = [ + {"id": "w1", "baseline": [[-4, 0], [4, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[4, 0], [4, 20]], "height_ft": 9, "plus_minus_ft": 0.0}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(1.5, 4.5, 9.5, 13.5), "plus_minus_ft": 0.0}] + raw["objects"], raw["overheads"], raw["facing"] = [], [], [] + raw["coverage"] = { + "ends": {"left": {"kind": "limit"}, "right": {"kind": "limit"}}, + "observed": [ + {"band": b, "span_ft": [-40, 40], **({"out_ft": 30} if b == "ground" else {})} + for b in ("wall", "ground", "overhead", "facing") + ], + } + return raw + + +def test_a_meter_move_across_and_along_its_wall_lengthens_the_route_by_sqrt_5_e() -> None: + # Before: the route to [13.924, 16.508] passed within the 15 ft confident reach, bounded + # above by 14.944 ft with the meter's error counted as e * sqrt(1 + k^2) = sqrt(3) * e. Moving + # the meter (-0.212, +0.212), within its 0.3 ft, slides the battery up w2 by dz - dx and puts + # the meter dz off its wall: 15.061 ft read as exact, past the reach. The worst move gives + # sqrt(5) * e. + c = next( + c + for c in evaluate_start(parsed(route_corner(), PUBLIC), PUBLIC, 13.924, "w2").checks + if c.id == "route_length" + ) + assert c.outcome == "unsure" + moved = exact(route_corner(meter=(-0.212, 0.212))) + truth = same_battery(route_corner(), 13.924, "w2", moved, (-0.212, 0.212))["route_length"] + assert truth.measured > 15.0 + assert c.measured + c.plus_minus >= truth.measured + + +def test_the_wall_above_the_battery_is_seen_wherever_the_battery_may_stand() -> None: + # Found by the strict property: the wall seen from s = 15 on, the battery on w2 from 15, the + # meter +/- 0.2 on w1. Moving the meter 0.2 ft along w1 slides the battery 0.011 ft onto + # unseen wall, where a box could hang; the check only looked over [s0, s1]. + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.2 + end = [22.910371511986337, 6.511363089143134] + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [4.0, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[4.0, 0], end], "height_ft": 9, "plus_minus_ft": 0.0}, + ] + raw["coverage"]["observed"] = [ + o for o in raw["coverage"]["observed"] if o["band"] != "wall" + ] + [{"band": "wall", "span_ft": [15.0, 24.0]}] + checks = evaluate_start(parsed(raw, PUBLIC), PUBLIC, 15.0, "w2").checks + above = next(c for c in checks if c.id == "wall_equipment_above") + assert above.outcome == "unsure" + assert above.unsure_cause == "unobserved" + + +def test_a_hazard_on_another_walls_line_moves_with_that_wall() -> None: + # The caretaker's class (3.037 PASS became 2.951 FAIL at 3baa338): the meter and battery on + # exact w1, a gas meter with no outline on w2 (+/- 0.3 ft), 3.037 ft away. It lies on w2's + # line at its s: moving w2's corner 0.3 ft along w1 and its far end back puts it 2.866 ft + # from the battery, read as exact. Its check left w2's error out. + raw = shared_fixture() + raw["meter"]["plus_minus_ft"] = 0.0 + raw["walls"] = [ + {"id": "w1", "baseline": [[-30, 0], [4.0, 0]], "height_ft": 9, "plus_minus_ft": 0.0}, + {"id": "w2", "baseline": [[4.0, 0], [4.0, 20]], "height_ft": 9, "plus_minus_ft": 0.3}, + ] + raw["ground"] = [{"type": "lawn", "polygon": rect(-60, 60, -60, 60), "plus_minus_ft": 0}] + raw["overheads"], raw["facing"] = [], [] + raw["objects"] = [ + { + "type": "gas_meter", + "wall_id": "w2", + "span_ft": [8.52, 9.52], + "bottom_ft": 0, + "top_ft": 3, + "source": "tape", + "plus_minus_ft": 0.0, + } + ] + checks = evaluate_start(parsed(raw, PUBLIC), PUBLIC, 0.0, "w1").checks + gas = next(c for c in checks if c.id == "gas_clearance") + assert gas.measured == pytest.approx(3.0373, abs=1e-3) + assert gas.outcome == "unsure" + moved = copy.deepcopy(raw) + moved["walls"][0]["baseline"][1] = [4.3, 0] + moved["walls"][1]["baseline"] = [[4.3, 0], [3.7, 20]] + truth = same_battery(raw, 0.0, "w1", exact(moved), (0.0, 0.0))["gas_clearance"] + assert truth.measured < 3.0 diff --git a/server/uv.lock b/server/uv.lock index 9a0f1c8a1..6734267c3 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -2,6 +2,64 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -11,25 +69,183 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "house-scanning-server" version = "0.1.0" source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "jsonschema" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "shapely" }, + { name = "uvicorn" }, +] [package.dev-dependencies] dev = [ + { name = "httpx" }, + { name = "hypothesis" }, { name = "pytest" }, { name = "ruff" }, ] [package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.141.1" }, + { name = "jsonschema", specifier = ">=4.26.0" }, + { name = "python-multipart", specifier = ">=0.0.32" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "shapely", specifier = ">=2.1.2" }, + { name = "uvicorn", specifier = ">=0.54.0" }, +] [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "hypothesis", specifier = ">=6.168.1" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.16.9" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +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.168.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/31/b54cd138ee36a32d0047d40fe7bdcb63cefd57ce098a26024161332d15fd/hypothesis-6.168.1.tar.gz", hash = "sha256:fd8acb5c67f260dfbdcc1b2dc95ff48b2b3f90708ae595c76f591bbf3f6f8eeb", size = 510823, upload-time = "2026-09-23T04:36:04.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/95/3091b9ad24baa9666d49feea433a8c4060ad47245f0d529d736af3672ddd/hypothesis-6.168.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5d9cc2d2a4779c92788389564fc6760df7c2909185f6431378133dea6933194f", size = 791345, upload-time = "2026-09-23T04:35:39.379Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b3/b954ffa69f91d111f801030c28d6721f422222af245a846571fc87874242/hypothesis-6.168.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d0e1ec15d32af11da6784d2506de0c901e4c1abf97a3cdcfc144225457783f4e", size = 787104, upload-time = "2026-09-23T04:35:21.799Z" }, + { url = "https://files.pythonhosted.org/packages/e5/dc/732df08f570d845a480d3729bfb46beccc441bfabe2804bb3a458649be73/hypothesis-6.168.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cc38519a57c4b919b2c59714a67501d20b46b0c96a0078cbdc9f86e728efce3", size = 1123848, upload-time = "2026-09-23T04:33:48.857Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/e1e937df42243267ba25ede5e9b29921186f290f6c0515729481cc41726e/hypothesis-6.168.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e229e937e761830c7fa6437fb12cb801616a1737af0898abf5edba57d2ad691", size = 1147698, upload-time = "2026-09-23T04:35:33.439Z" }, + { url = "https://files.pythonhosted.org/packages/be/d1/3b41c008dc2a49e43bc4893dcd27ea9d9fef64fef50382ca5f7107680b1f/hypothesis-6.168.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:569ba21e5543840895d17633cc6716a9578ac83b1193a2978b3f24cc16b93085", size = 1149280, upload-time = "2026-09-23T04:35:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/71/ea/d54781058fe6a487b81ec6c636f3806fff6a417b48cf3fcd2b003d80f3ce/hypothesis-6.168.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a232665716607c3c50c9a3aeeac5929be1d4e58703f5e7cb51db7099445a8c6", size = 1191693, upload-time = "2026-09-23T04:35:57.896Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f2/43bdd07450978611d25989ffdcbe1579acd46c1500a484f8838359fe7880/hypothesis-6.168.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9022fe330d8839259da9b496573c4b83ea248bf32029eba23ff589f3c25754bf", size = 1169725, upload-time = "2026-09-23T04:34:32.59Z" }, + { url = "https://files.pythonhosted.org/packages/d1/72/fd4b657cba66940f208bc0eccaceaa777553009d26e86068ff3ab5e55b0b/hypothesis-6.168.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:69be660b831d55ab35619b30abd311a6cb8cd0068a1192210d194a776b211094", size = 1129181, upload-time = "2026-09-23T04:35:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3f/516a29d7a2bd31e28972de635350eab086c94faa07afcdd3ad0f06e70db8/hypothesis-6.168.1-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e2cccca80ee4ffe8f66edf5b2adb2a16b8cd3c5110b3a774d70f2fc75a42aa", size = 1160162, upload-time = "2026-09-23T04:35:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/e2c7e32f281b31d4c19cc2fa935bfdde5b31ad4848a485e5ebb8de2389da/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:11cbd2a2539194f0960de4ed9f980882e3a3f55c476afcc6f4cc342eec5a07d2", size = 1299714, upload-time = "2026-09-23T04:35:12.529Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bc/85850bdb1fc35d0fa9e299ca3b2e354f403959666c7ef4d0bfda9c308eee/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:aa1620111616c660a1db03347b1790b5914ef30f86c6e8ddd6c765785291f0b4", size = 1425335, upload-time = "2026-09-23T04:35:23.557Z" }, + { url = "https://files.pythonhosted.org/packages/40/c0/66bdb24f32d32aa5636655b6037af07962118b89fae16ee74bc9a1bb982e/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:a07c8665c95bb856f99ecd60b6f50d94dcf8157415387c32dcd5bb5b5061d032", size = 1376916, upload-time = "2026-09-23T04:34:59.742Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/7ad0b17bda81c473cbc539009148f3e0950880c4618028691bcf62eeb8d2/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:8a50b6335d5fa2312dd9f5d50faeb953e61475b301d2c073cd7a4c5efd7e6171", size = 1280975, upload-time = "2026-09-23T04:34:21.966Z" }, + { url = "https://files.pythonhosted.org/packages/e2/01/8d04d1eecf03a6097e26400e8393eff7a81fd52c877a664ee4e97adf7880/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:4ccb0276db8ad197a63dff25c4868e11123346347df6900625d2b1f794ce7c19", size = 1300238, upload-time = "2026-09-23T04:34:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/f5/74/eda0c70b3845789c713fe2ac72d3d4586da83f38a34f1f7aefbda0b5b01e/hypothesis-6.168.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85ca7788574e06d0591d51e19ecbb1d09bc82d50c6eae5de0fe8317590b1e138", size = 1336088, upload-time = "2026-09-23T04:33:42.002Z" }, + { url = "https://files.pythonhosted.org/packages/80/c9/43d8528e6d43eebb7688d36e530d1fc7763809b8f923b62cc8b4d95f99ff/hypothesis-6.168.1-cp310-abi3-win32.whl", hash = "sha256:77378e04aa47a22a615af80f42970dc44782f02b8a82ac105b619a2cc7f5eee2", size = 678004, upload-time = "2026-09-23T04:34:29.564Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8b/f7d4de5b57972f7f68a6c7fb5698f60425589eae6fcb175f285f2525e8c0/hypothesis-6.168.1-cp310-abi3-win_amd64.whl", hash = "sha256:f94d3dbc31112f8fbcb3af19a662ee5e90e380e6ae676357903caa14a1604763", size = 684722, upload-time = "2026-09-23T04:34:41.279Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/528eb221a82da0aa477e34c7af6ed2b4a5109b326638d4418b9e49435e96/hypothesis-6.168.1-cp310-abi3-win_arm64.whl", hash = "sha256:9bd7e8c6e2c3f6a5befee6b03ebd2170da2e50658075c6decad184777c72a726", size = 682711, upload-time = "2026-09-23T04:33:46.085Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6f/9d5ae55d81e10f0fc46868c19ab9d468e9959e82ff8cc8c26990b16114f3/hypothesis-6.168.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:dfe14a88b1ed03ce47333d800cc37949da73feb0cd9767e7ef076d2202768928", size = 793124, upload-time = "2026-09-23T04:33:52.589Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/54c19d259d7f92176aa2199c90f7951e4e396a26898a596dc875ccb9c389/hypothesis-6.168.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8acd000c3bf233cdcd28c1ff03f1bf56cb03821b1c742d00d57978b20fc6e6f8", size = 784674, upload-time = "2026-09-23T04:34:38.137Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ae/f80a9810f88bd654ab0d0ed0652c274e4c0384e22d4f9442b6753b9200a6/hypothesis-6.168.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29280d4743546ddcf74644f9acdc5726972cba9182699a86b7e77f3f8f6e5dde", size = 1122860, upload-time = "2026-09-23T04:33:51.375Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5d/aa5b7ffd57ec475a9252c55985c07a366823c3f85267d44648e94c1ec321/hypothesis-6.168.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd9a488d46b8c7da3103058c7569376a3a08994aee9a7d6f1d160d3d3b49b82d", size = 1168922, upload-time = "2026-09-23T04:34:44.567Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/4862448192d4c9cc61a3b8a620730800f56b1e83b0bfda63dbc728526eb3/hypothesis-6.168.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451ee6c42955887a9e1850c53813e31182eab1663e4d3dacea4e535dedc45b5d", size = 1298568, upload-time = "2026-09-23T04:35:31.191Z" }, + { url = "https://files.pythonhosted.org/packages/15/ad/55e73fda9afbfcf3336b2eee564833bc9a4fee1f7a32fe5f9d183742da39/hypothesis-6.168.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bc2c036f4a9534af64977e38b2976295136f83417fdb95bed816a52cc3023497", size = 1335091, upload-time = "2026-09-23T04:34:23.42Z" }, + { url = "https://files.pythonhosted.org/packages/ef/99/b6340fa21a89b0088ed023fe984109e1c47a07dfd741fd6d8e17a9e2795f/hypothesis-6.168.1-cp312-cp312-win_amd64.whl", hash = "sha256:d603d7c96d030a63701b6ad9bff0bc4870e4c226335b3db509f1eed1f7fc061b", size = 682011, upload-time = "2026-09-23T04:34:34.565Z" }, + { url = "https://files.pythonhosted.org/packages/f5/23/093b9dc768e89d2cec74d29115f4004fe1be64218bac65f8d817d727d30b/hypothesis-6.168.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aebf7e9d7ba9f920e154abc65851d4affab497daeaea285474bed93840a983ea", size = 793062, upload-time = "2026-09-23T04:33:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/df/d7/f191686b44b3f892ae1404e238a55260152a645a2deafe8b2949c7e90fe9/hypothesis-6.168.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b26616604cac458dd83e75900fefd9190f1074e67d5d9dc4d9e321d30f295eca", size = 784518, upload-time = "2026-09-23T04:34:02.012Z" }, + { url = "https://files.pythonhosted.org/packages/10/25/b7a5dca1911c9012a564d0b7aaeb44e393ade9424a695dea179ff32d79fe/hypothesis-6.168.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb6d89cff2edcd45e5af957c7b6a71ae7887419561f7b9c7055e1c50dea41017", size = 1122844, upload-time = "2026-09-23T04:35:06.897Z" }, + { url = "https://files.pythonhosted.org/packages/ea/60/ddacd0d244e6719ea9a9d322d4d4b6b054fd27ada94ec7aa7dce7ef11297/hypothesis-6.168.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a4ce6310ec21000446ae75cdc6667b81d4ee0f2d226a0dfaf8baca42dd151d0", size = 1168779, upload-time = "2026-09-23T04:33:56.885Z" }, + { url = "https://files.pythonhosted.org/packages/dc/07/343bab6676a41f38cb7c5912ab3ca059258bf40e41c181d1b77cc7251801/hypothesis-6.168.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a22c6e730c327d22a4d61b53f66639cdf6f0fe4a5a1b3595c36533e5b8127169", size = 1298434, upload-time = "2026-09-23T04:34:48.576Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/4b72fafa5f46a455024a9242b62505b1a53fad95ace162e831c4ffb233b9/hypothesis-6.168.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dcd66493fe180d38641b02dd0456cc1e429655f8940dd1396ab874808f16dd34", size = 1334975, upload-time = "2026-09-23T04:35:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/48643915cda400ca2dccb9bcdc363690b13bcd5c179172bc6450c3d76ce8/hypothesis-6.168.1-cp313-cp313-win_amd64.whl", hash = "sha256:a8a96f2e9fa2a57f95865aaf092943775e7ea96f72535371b2be20fe5464298c", size = 681978, upload-time = "2026-09-23T04:36:00.056Z" }, + { url = "https://files.pythonhosted.org/packages/0c/0e/e6999cddbaef975ecc1b0a7e9028a3d1a1014b542fa003889d0cb3870fa9/hypothesis-6.168.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:35b6582ecc22ed682ea39bbf972f8b8b1798043a3bc624ae1dce02d42fcf3ad4", size = 793097, upload-time = "2026-09-23T04:35:03.374Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f3/2ff6bf4bf43ab12641b163bd8fea4b6cddc9a0f07499c4779519099034c9/hypothesis-6.168.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:48e467c73b7fb46bc072323c5d67e8e683cf32218fbba47c4e6ee0fe0d72687f", size = 784662, upload-time = "2026-09-23T04:35:08.976Z" }, + { url = "https://files.pythonhosted.org/packages/10/3a/b3653de877981cdab6861476c3b68d02b7c682c2875557cff7b92dbde068/hypothesis-6.168.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c82fcc3dece367bd43f3eca5c2746cf60b73f61bba071ef9421d4cfd6188a85", size = 1123106, upload-time = "2026-09-23T04:34:26.476Z" }, + { url = "https://files.pythonhosted.org/packages/49/25/24c725cb94c8953a98d8bdec83971f76c8f4b103070df1fdacd8b7f37253/hypothesis-6.168.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36031c8f8fd4bec7185cf70ed080ba54d45c106cf4f575bc8c2482c01f5f9b21", size = 1168942, upload-time = "2026-09-23T04:35:41.237Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e4/cbe45b9e78e4dd9c18ed2328ff95f659f59235b04ae3c73cf7e9c498adc9/hypothesis-6.168.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ddfe21791579fdc90423973c733578efa23b010cbfb685dcd5201cf7b0a928b6", size = 1298937, upload-time = "2026-09-23T04:33:50.181Z" }, + { url = "https://files.pythonhosted.org/packages/93/e9/cacaa3f58ee6eb98ce29a2182483d57686011bba35c8fb8a9bc8bf981c7f/hypothesis-6.168.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ecf86b1c9c8700f6d45324becbbe25dd581c69dc03e1357a64f4bf9a1afe823f", size = 1335198, upload-time = "2026-09-23T04:36:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/31/d8/ca51626959f59a557656cf8024d9afda4d213257c170412fa3193d11e11f/hypothesis-6.168.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:d5ab336a7dd233858bc3066a69479e295da788cef345d7e4efd7b77dad724db0", size = 624105, upload-time = "2026-09-23T04:34:08.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/61/6ce96e7d58fc7a592ad7c84d8c7cf9ea36db51468b30ac8306bbf4c3e6ba/hypothesis-6.168.1-cp314-cp314-win_amd64.whl", hash = "sha256:63244ba76464202d3d24126d2e4c2c1abf36db60fffe16ebaa93292572c2a42a", size = 681859, upload-time = "2026-09-23T04:34:50.801Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b1/6c7345e2b37ea8a1df2520a0ecfbdc0243a897895ff950309ca7f687429a/hypothesis-6.168.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:6cec76c8cf76738ad4c0c0c5d6e4632a32d74748639ea6ce26fa672490d9bf95", size = 791685, upload-time = "2026-09-23T04:33:54.243Z" }, + { url = "https://files.pythonhosted.org/packages/72/8c/4f6cfafde4b31add263d6a562f615939cd49649e87a3695ac8693506c811/hypothesis-6.168.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9da10ba2dbbcaba93c6a11eb394fd1a0cfee6e2ba21345c3d6f2fc7401d80670", size = 783237, upload-time = "2026-09-23T04:34:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/a0/25/646c301a9d1fe3825a2ff8362594c4aee0ceb2ce192f49469f7f2824a742/hypothesis-6.168.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3218c6d18a041c0eba0cccd88b20d74be24687e42a73338abd0a31ec5294c265", size = 1121414, upload-time = "2026-09-23T04:34:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/27/4f/f84ec8434ef765e6a5c4f9942bbb7652be444b23b9ff868da43e96e61e86/hypothesis-6.168.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760cad0526a84122bbe2a0157217e16d76d649d333422142853c7f1ba43ca44d", size = 1167536, upload-time = "2026-09-23T04:33:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/38/74/c53334d793b0c54054ffc781cdf79ccae6ee51daf347bd9cfa7e0015cece/hypothesis-6.168.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50a60b57b485dff68b56f99e4506b7f1b52994f801ee1aaa4cb9125a90bf7834", size = 1297122, upload-time = "2026-09-23T04:34:17.235Z" }, + { url = "https://files.pythonhosted.org/packages/43/e7/b115a1faea6d40bddce6342da703b7536d803d2384fa50d277dc55170c64/hypothesis-6.168.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:68017d433ca5c558c34e3308a6653fc63077749af8bf86c10850cb6bb3cb559f", size = 1334055, upload-time = "2026-09-23T04:35:37.298Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/f272620d304dd67ff7815509745e2fb8d2bf58e4e1da4b7842a83f1de2da/hypothesis-6.168.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ff92099a0185ba14bf4bc2111299b456074596199148ae84590eee4d480fac2e", size = 681804, upload-time = "2026-09-23T04:35:35.374Z" }, + { url = "https://files.pythonhosted.org/packages/54/e9/45595951f8bc9bd03f032325e7b176ca990a0c86bf2e09d481e8ee5a5cab/hypothesis-6.168.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:e9510e33467d586a7700d56b9a6ee65b44a06832c1cab3d270369d7c53381276", size = 791046, upload-time = "2026-09-23T04:35:55.724Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3e/718431d0ab72c4c07454f71040b4de298a63e765f20088557dc1bef0a48b/hypothesis-6.168.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:99970e3a75f11dde0414d3d6724bd8b7a2bd1deeb9a509ec9236c91d41a5cff0", size = 782982, upload-time = "2026-09-23T04:34:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/d7/05/c60db6df1456a70a3c589e42acaa446c22f56ed342c1b8dd04b8e49df8fd/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5ea78773de5bcc25266e37eb086472e90a85fc81b8d4a9ca8afc15858ee15fb", size = 1120961, upload-time = "2026-09-23T04:33:43.494Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fe/d943250395750ecc3b4ec95b1ab3c8119f6cbad08c1ba9169ffee7d89049/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13f502ce2423e46dfcd2af38950a86ecf97084d94e721f7f13dd5a70afb36e0a", size = 1143877, upload-time = "2026-09-23T04:34:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9d/8c1f8e11f20fd81ce40caed7ad6d6a9776e24775070e87f3aa9ba3683272/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b4bcaa8b0e753fc8921011d5d10c8a7c3caf814c6a968bc140a08917973fdc", size = 1146503, upload-time = "2026-09-23T04:34:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/71/d8/ac2e9fa653d2f05a13724837007e857d376d81fdf728543ab85f6c7f7350/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ed811908d38ca8e05c20ad25ecb61edc1e20a519667d4b668be49dd09e6bf3e", size = 1189216, upload-time = "2026-09-23T04:34:36.271Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7b/640952448781c7df5098880e2e2d6e7702b3a1c0badd4c0ea5fca6d6ab53/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9239c868418088d58ae18d041f9f424bd7a75dd143f209c35fde38770b65f74f", size = 1166919, upload-time = "2026-09-23T04:35:16.498Z" }, + { url = "https://files.pythonhosted.org/packages/b6/de/0d46b330a28f7614850fb1a0564cf88af7c0b29cbd791e9d04ade7f66f13/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:5cac279798ecc92d3301ec4421c0458e22627f8a79fdde8a6c98ab1d4b8811f6", size = 1126652, upload-time = "2026-09-23T04:35:49.432Z" }, + { url = "https://files.pythonhosted.org/packages/92/36/6adeca1724ed4966095406ffe7308fcff338cf9f5b7443d5ea34a01ab6e1/hypothesis-6.168.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bc660dc14d633641e22e407417d35bf2925331273ed71bc6ab5536dfca5c72d", size = 1155687, upload-time = "2026-09-23T04:35:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/bd/00/e9b7c7e4a0a75d1621be95a126999352e01a16d68b0a59b396695c7b4991/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:157a29e4d6bcb77ae732cb83d6546beef47265ecac88408fdd5d5b2bd325685d", size = 1296471, upload-time = "2026-09-23T04:34:12.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/52cb320e9e07cc67f718506617b5900344370394e39f4affada317aab313/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:b125aac89594ee2a76687b724f057b2423ea83dfee6b8ff59f54289d6eef19a1", size = 1421838, upload-time = "2026-09-23T04:34:20.451Z" }, + { url = "https://files.pythonhosted.org/packages/9b/93/969c33a87cd2865beffebe491294b8115e7e7421c6840f79a67486535bfc/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:4b960c9d31b85dbc6a25888897c02bbea5f498844932841612925184fc78d9eb", size = 1373994, upload-time = "2026-09-23T04:34:00.608Z" }, + { url = "https://files.pythonhosted.org/packages/34/c9/5fe4ade4bd23baa2e4cd27668bb8ac10e840ce19fbcfc74116ea529cbaa7/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:d4d76f35874471fe9b8e1bb696fb532363ef3d9588161a06d30520f638f15a39", size = 1278216, upload-time = "2026-09-23T04:34:07.253Z" }, + { url = "https://files.pythonhosted.org/packages/a1/17/30b9a1ad975e90700dea74ce8b08ab07b9e58c4d83918422bc8841942c1e/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:d5147c5f7497d109eed0267c498adaf5cad6e4beeaca6fad6057f2007bae70a7", size = 1297647, upload-time = "2026-09-23T04:35:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/e0/2f/233a9d5adafad6e187ab8f4d9275592cf120b56e63ea1cfb02213c01021e/hypothesis-6.168.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:a909f89758e184c4f86418598de297a2ced5b922979c0f98c5d73ff6bafe38e5", size = 1333790, upload-time = "2026-09-23T04:34:42.828Z" }, + { url = "https://files.pythonhosted.org/packages/10/d7/4c9d4d597f70316d781cd99b600adae090a6f5d675487196e10510a23afe/hypothesis-6.168.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:66b9fb6f02840f8ffa2a4e6359aff6ecc1b2c6630c8a2066771044fa22956c7a", size = 675206, upload-time = "2026-09-23T04:35:14.592Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a8/f9fc0c0dbb9e80238fc7145cb4ce52e0897ea417a23217022911855bccda/hypothesis-6.168.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:9246eaa80044c33a182be661cbed7e6a7314c61359ef501ea9e9efd90d50ca3b", size = 681503, upload-time = "2026-09-23T04:34:11.223Z" }, + { url = "https://files.pythonhosted.org/packages/11/b4/962ae350c10860be37c9abf98c0ef1ef23e4baca3ee7ad77208941e370e5/hypothesis-6.168.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:52e0c804cbf697d4760400c84444a91cbd5dfe7ae0ef1bb3228f74fb5547eb9e", size = 679199, upload-time = "2026-09-23T04:34:03.472Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -39,6 +255,106 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -57,6 +373,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -82,6 +488,171 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + [[package]] name = "ruff" version = "0.16.9" @@ -106,3 +677,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, ] + +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +]