Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,11 @@ 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.<side>.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.
- `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. Its request says so, and what the view looks for there. Requests for the wall, the gap in front of it and the space overhead stop at either end mark. Past a limit end there is none of them to show, yet that stretch still counts as unseen, so a check that needs it there asks for no view at all and stays UNSURE for a person.

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).
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). Supplying every band request settles every check the band requests name in `checks` (a check can need more than one band). A `past_end` request names the checks whose unseen area lies past its end; once the walk goes on, the next answer asks for their views. A check no request names can't be settled by views, whatever its `unsure_cause`: a person judges it.

A clearance whose distance is a placeholder (under the public rules `pool_clearance` and `drive_clearance`) asks for no view: its number would set how far out the ground must be shown (issue #75). It still reads the coverage above and stays UNSURE until that is seen, and the summary names it for a person. A value the private rules set counts as real, so under them its requests come back.

## Reading a result

Expand All @@ -125,8 +127,10 @@ Ground behind a scanned wall that is also on the house side of every scanned wal
| `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.
A check's `subject` is the id of what decided it (`objects[1] ac`, `ground[0] drive`, `facing[2]`), for code. Its `reason`, `summary` and every request's `message` are shown to the homeowner and name things by what they are and roughly where: "the AC unit about 14 ft right of the meter".

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. `unsure_cause: unobserved` says the deciding area was not seen, not that photos will settle it: views settle an unobserved check only when a request in `missing_evidence` names it in `checks`. An unobserved check no request names (a placeholder distance, the wall past a limit end, or anything past an unexplored end beyond cable reach, where no `past_end` request asks to walk on) needs a person, as the summary says. 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.

## Rules

`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 `<repo root>/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`.
`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 in `policy.notice`: "Demo rules: public values and placeholders (pool 10 ft, drive 5 ft), not Base's." `summary` is for the homeowner and leaves the notice out (issue #74). `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 `<repo root>/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`.
22 changes: 15 additions & 7 deletions server/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import itertools
import json
import math
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -396,11 +397,17 @@ def farthest_out(self, region: Geometry) -> float:
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
self,
region: Geometry,
s_lo: float,
s_hi: float,
settled: Callable[[Geometry], bool],
) -> 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
"""The ground view (s_lo, s_hi, out_ft) with the smallest depth after which `settled`
holds for the ground left unseen, found by bisection on exactly the geometry the checks
use, so capturing it settles the check in one round. `settled` is the check's own test
of `region` (what it needs seen): a bound on the unseen area left would stop the search
with a speck inside the check's radius. 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
Expand All @@ -410,8 +417,7 @@ def view_to_cover(
area = polygonal(region)

def covers(a: float, b: float, depth: float) -> bool:
unseen = polygonal(self.unobserved_ground_given([*ground, (a, b, depth)]))
return shapely.intersection(area, unseen, grid_size=1e-9).area <= 1e-9
return settled(polygonal(self.unobserved_ground_given([*ground, (a, b, depth)])))

tol = COVERAGE_TOLERANCE_FT
# A region's nearest wall need not be the one it lies in front of (at an inside corner,
Expand Down Expand Up @@ -452,7 +458,9 @@ def coverable_span(self) -> tuple[float, float]:
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)."""
it; only walking on does (a past_end request). Only the ground is asked for past a limit
end: a check whose view of the wall reaches past one asks for none (issue #78, see
solver._missing_json), so the wall band keeps that stretch here to tell."""
key = f"coverable-{band}"
if key not in self._cache:
lo, hi = self.coverable_span()
Expand Down
4 changes: 2 additions & 2 deletions server/schemas/result.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"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.",
"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. Supplying every band request settles every check the band requests name in checks. A past_end request names the checks whose unseen area lies past its end; once the walk goes on, the next answer asks for their views.",
"type": "array",
"items": {
"type": "object",
Expand Down Expand Up @@ -155,7 +155,7 @@
"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)."
"description": "Only for unsure. margin: measured inside the error band, a person must judge. unobserved: the deciding area was not seen. More views settle it only when a missing_evidence request names the check in its checks (a past_end request names those whose unseen area lies past that end, and the next answer asks for their views); a check no request names needs a person, for example a clearance whose distance is a placeholder, or one that needs the wall past a limit end or anything past an unexplored end beyond cable reach. 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."},
Expand Down
Loading