Fix stair-fit parity: entrance corners + weighted has_circulation (homemaker-py-w1e/q70)
Two root causes found for ca/cb corpus parity failures: 1. _avg_path_len_from used unweighted BFS (hop count) but Perl's Graph::average_path_length uses weighted Dijkstra with centroid-to-centroid edge distances. This caused wrong edge removal in has_circulation, giving wrong stack corner counts (2 instead of 3 for lr in ca9e80c5). 2. Entrance corner logic used _public_access (any street boundary) but Perl's Entrances() picks the best entrance route — a stair only gets entrance corners if no higher-priority non-stair C leaf has public access. Also includes homemaker-py-hgg storey/building checks previously uncommitted: stair fit, circ connectivity, roof-garden, public-access tracking, has_circulation, corners_in_use, stack_corners_in_use, check_space_counts with failure stacking. All 4 debug corpus prefixes: ratio=1.000000. 39 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8e762b80d8
commit
fc10466966
6 changed files with 1022 additions and 35 deletions
|
|
@ -1,6 +1,9 @@
|
|||
{"id":"homemaker-py-40i","title":"Investigate cf0b8a77e8b2325f ~18% raw_value discrepancy (py lower than oracle)","description":"For prefix cf0b8a77e8b2325f: oracle=1.079112e-03 py=9.133243e-04 ratio=0.8464 (python is ~18% too low). debug_nfails shows py n_fails=5 oracle n_fails=5 (same failures), stair_fits=[1.3145] in python, building_factor=0.1104 (vs oracle's implied ~0.1303). The discrepancy is in raw_value (py=11837 vs oracle implied ~13975) or possibly building_factor. Need to check: (1) per-leaf quality values (crinkliness, area_outside, access) via debug_quality.txt; (2) whether the stair corners differ (cf/rl: py=[2,3] perl=[2,3] — SAME, so corners ok); (3) any quality term not yet ported or computed differently. Run debug_quality.py and compare per-leaf contributions.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T18:08:22Z","created_by":"Bruno Postle","updated_at":"2026-06-13T19:05:10Z","started_at":"2026-06-13T18:12:23Z","closed_at":"2026-06-13T19:05:10Z","close_reason":"Root cause found: Python stair_fit misses entrance-door corners. Perl check_stair_fit calls $level-\u003eEntrances($graph) and adds those corners to corners_in_use before calling Stair_Fit. For cf0b8a77/rl(C): stack=[2,3] + entrance=[1,2] → final=[2,3,1] (3 corners, one_turn, going_b=8, sf=0.9751, sf_factor=1.163, bf=0.1305). Python uses only geometry.corners_in_use=[2,3] (2 corners, two_turn, going_b=3, sf=1.3145, sf_factor=0.985, bf=0.1104). Fix tracked in homemaker-py-w1e.","dependencies":[{"issue_id":"homemaker-py-40i","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-13T19:08:30Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-w1e","title":"Port Perl entrance-corner logic into Python stair-fit (ca/cb parity)","description":"Perl's check_stair_fit (Leaf.pm:104-142) adds entrance edge corners to corners_in_use before computing stair_fit. Python's process_storey does not. For ca9e80c5c1502f10 and cb93a2d2de7f5d37 the oracle stair leaf 'lr' has corners [1,2,3] (from perl_bf.pl) but debug_corners.py Perl-compatible trace gives [2,3] — the extra corner 1 comes from Entrances(graph)-\u003eBoundary_Id logic. Need to: (1) port dom.Entrances() — returns {leaf_id: boundary_id} for the best-entrance leaf at ground level (Entrances() returns {} if level\u003e0); (2) port leaf.Boundary_Id(side) — returns the node sharing that edge; (3) in fitness.process_storey, after stack_corners_in_use, add entrance-edge corners before computing stair_fit. Acceptance: ca/cb ratio ≈ 1.0 (currently 1.33).","status":"closed","priority":1,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-13T18:08:11Z","created_by":"Bruno Postle","updated_at":"2026-06-13T19:53:40Z","started_at":"2026-06-13T19:09:35Z","closed_at":"2026-06-13T19:53:40Z","close_reason":"Fixed: entrance corner logic via _entrance_bid_for_stair (mirrors Perl Entrances), plus root cause: _avg_path_len_from now uses weighted Dijkstra (centroid distances) matching Perl graph.average_path_length — fixes has_circulation edge removal order. All 4 debug prefixes ratio=1.000, 39 tests pass.","dependencies":[{"issue_id":"homemaker-py-w1e","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-13T19:08:29Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-q70","title":"Fix corners_in_use _ib(None,None) bug: triple-at-idx=3 always passes in Perl","description":"In graph.py corners_in_use(), the _ib helper returns False when pa=None or pb=None. Perl's is_between_2d(point, undef, undef) returns True (distance_2d(undef,x)=0 so abs(0-0-0)\u003c1e-6). At triple-check idx=3, c1=corners[4]=None and c2=None, so _ib(w, None, None) must return True to match Perl — meaning the triple always succeeds at idx=3. Fix: add 'if pa is None and pb is None: return True' before the existing 'if pa is None or pb is None: return False'. This is already applied to graph.py. Needs: run 35-file corpus parity test to confirm aa0dcab98927d2c9 passes (corners [0,1,3] → stair_fit=0.878 → sf_factor≈0.570 ≈ oracle).","status":"open","priority":1,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-06-13T18:07:54Z","created_by":"Bruno Postle","updated_at":"2026-06-13T18:07:54Z","dependencies":[{"issue_id":"homemaker-py-q70","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-13T19:08:28Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-gp2","title":"Disable occlusion/daylight in Urb oracle (env flag); re-baseline scores","description":"Strategy decision (Bruno, 2026-06-12): occlusion/daylight is orthogonal to whether a better, scalable optimisation system can be built — disable it in Urb rather than port it. Patch Urb behind an env flag (e.g. URB_NO_OCCLUSION=1): quality_daylight returns 1 for outdoor spaces too, and Crinkliness/Area_Outside pins the CIEsky_vertical illumination factor to 1 (simple crinkliness = unweighted external wall area / floor area). Keep the occlusion object plumbing — it carries the Walls/boundaries cache crinkliness needs (ProgrammeDriven.pm:97). Then re-baseline everything once at this clean boundary: corpus .score files, the DESIGN.md $4.5 gains table, accept_innerloop.py gate bars. Also measure oracle s/dom with the flag on — occlusion sampling may be a real slice of the ~1 s/dom cost. The native Python fitness then ships with simple crinkliness only; full occlusion rebuild is deferred post-Phase-5 (homemaker-py-2g5).","acceptance_criteria":"Env-flagged Urb patch; flag on: corpus re-scored, gate bars re-derived, oracle s/dom re-measured; urb-evolve confirmed to respect the flag for the Phase-2 benchmark","status":"closed","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-12T07:27:30Z","created_by":"Bruno Postle","updated_at":"2026-06-12T09:31:40Z","closed_at":"2026-06-12T09:31:40Z","close_reason":"URB_NO_OCCLUSION=1 patch in Urb (Leaf.pm quality_daylight -\u003e 1, Dom.pm Area_Outside illumination pinned; flag-off byte-identical, verified). Corpus re-baselined: 35/35 scores shift, one expected crinkliness failure-set change, 0.92 s/dom batched (x1.08). New reference gains recorded in DESIGN §4.7 and accept_innerloop bars (x1.63/x1.70/x1.68, deterministic seed). urb-evolve respects flag by construction. NOTE: Urb working-tree changes left uncommitted in /home/bruno/src/urb for Bruno's review.","dependency_count":0,"dependent_count":3,"comment_count":0}
|
||||
{"id":"homemaker-py-uxz","title":"Native fitness validation: 35-file corpus parity vs oracle; retire oracle (Phase 3 gate)","description":"DESIGN.md §7 Phase 3 gate. Validate the assembled native fitness against urb-fitness.pl across all 35 programme-house .dom files: scores within float tolerance AND identical failure sets. Swap behind the same interface as oracle.score so inner loop and search driver are unchanged; keep the oracle available as validation reference but stop using it in search. Then re-run topology search at scale (separate issue).","acceptance_criteria":"35/35 files: score parity within tolerance, failure sets identical; search runs end-to-end on native fitness with measured speedup vs oracle","status":"open","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:27Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:27Z","dependencies":[{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-3y7","type":"blocks","created_at":"2026-06-12T00:39:40Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gnw","type":"blocks","created_at":"2026-06-12T00:39:41Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:44Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-12T00:39:43Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":3,"comment_count":0}
|
||||
{"id":"homemaker-py-hgg","title":"Native fitness: storey/building checks + missing-space failure stacking","description":"DESIGN.md §6. Port ProgrammeDriven/Storey/Building checks: space-count matching with MISSING-SPACE FAILURE STACKING (2 base failures + 1 per size/width/proportion/adjacency/level requirement, up to ~7 — ProgrammeDriven.pm:192-212; reshaping must preserve this hierarchy), adjacency/level/requires_below checks, staircase fit/volume/min-max, public access, circulation \u0026 outside ratios, min internal area (1.2x programme sum), storey limit/minimum, structural failures (edge too long \u003e8 m both variants, unsupported covered outside, covered outside above ground, level not connected, inaccessible usable space), preprocess_building s-\u003eO conversion, and the 0.5^n penalty over value/cost.","acceptance_criteria":"Failure sets and final scores match the oracle on sample files; failure-stacking counts identical","status":"open","priority":1,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:26Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:26Z","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-uxz","title":"Native fitness validation: 35-file corpus parity vs oracle; retire oracle (Phase 3 gate)","description":"DESIGN.md §7 Phase 3 gate. Validate the assembled native fitness against urb-fitness.pl across all 35 programme-house .dom files: scores within float tolerance AND identical failure sets. Swap behind the same interface as oracle.score so inner loop and search driver are unchanged; keep the oracle available as validation reference but stop using it in search. Then re-run topology search at scale (separate issue).","acceptance_criteria":"35/35 files: score parity within tolerance, failure sets identical; search runs end-to-end on native fitness with measured speedup vs oracle","status":"open","priority":1,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:27Z","created_by":"Bruno Postle","updated_at":"2026-06-11T23:38:27Z","dependencies":[{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-3y7","type":"blocks","created_at":"2026-06-12T00:39:40Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-40i","type":"blocks","created_at":"2026-06-13T19:08:33Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gnw","type":"blocks","created_at":"2026-06-12T00:39:41Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:44Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-hgg","type":"blocks","created_at":"2026-06-12T00:39:43Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-q70","type":"blocks","created_at":"2026-06-13T19:08:31Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"homemaker-py-uxz","depends_on_id":"homemaker-py-w1e","type":"blocks","created_at":"2026-06-13T19:08:32Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":7,"dependent_count":3,"comment_count":0}
|
||||
{"id":"homemaker-py-hgg","title":"Native fitness: storey/building checks + missing-space failure stacking","description":"DESIGN.md §6. Port ProgrammeDriven/Storey/Building checks: space-count matching with MISSING-SPACE FAILURE STACKING (2 base failures + 1 per size/width/proportion/adjacency/level requirement, up to ~7 — ProgrammeDriven.pm:192-212; reshaping must preserve this hierarchy), adjacency/level/requires_below checks, staircase fit/volume/min-max, public access, circulation \u0026 outside ratios, min internal area (1.2x programme sum), storey limit/minimum, structural failures (edge too long \u003e8 m both variants, unsupported covered outside, covered outside above ground, level not connected, inaccessible usable space), preprocess_building s-\u003eO conversion, and the 0.5^n penalty over value/cost.","acceptance_criteria":"Failure sets and final scores match the oracle on sample files; failure-stacking counts identical","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:26Z","created_by":"Bruno Postle","updated_at":"2026-06-13T18:11:27Z","started_at":"2026-06-13T08:59:14Z","closed_at":"2026-06-13T18:11:27Z","close_reason":"Implementation complete: storey/building checks, failure stacking, staircase logic, public access, circulation ratios, structural checks all ported. 39 tests pass.","dependency_count":0,"dependent_count":4,"comment_count":0}
|
||||
{"id":"homemaker-py-gnw","title":"Native fitness: leaf quality terms + cost model","description":"DESIGN.md §6. Port Leaf.pm quality terms (size, width, proportion, perpendicular, access) with programme-driven parameter lookup (get_space_params fallback chain, generic c/o/s handling, width_inside [4.0,1.0] default), gaussian scoring, FAIL_THRESHOLD=0.1. Also the COST DENOMINATOR — fitness is value/cost: per-leaf area costs, interior/exterior wall edge costs, boundary costs, value rates (Leaf.pm:194-251, Storey.pm:122-147). Cost couples to geometry too.","acceptance_criteria":"Per-leaf quality factors and per-storey cost/value match Perl (float tolerance) on sample corpus files with DEBUG output diffed","notes":"Crinkliness scope (2026-06-12): port SIMPLE crinkliness only — external wall area / floor area with the CIEsky illumination factor pinned to 1 (boundary-overlap geometry from Dom-\u003eWalls stays in scope; the sky model does not). Must match the URB_NO_OCCLUSION-flagged oracle (homemaker-py-gp2), not stock Urb. quality_daylight = 1 for all spaces.","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:24Z","created_by":"Bruno Postle","updated_at":"2026-06-13T07:00:07Z","started_at":"2026-06-12T21:07:06Z","closed_at":"2026-06-13T07:00:07Z","close_reason":"0-mismatch parity: 35 files, 407 leaves, 2849 factors","dependencies":[{"issue_id":"homemaker-py-gnw","depends_on_id":"homemaker-py-gp2","type":"blocks","created_at":"2026-06-12T08:27:46Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-3y7","title":"Native fitness: adjacency/connectivity graph build + Merge_Divided semantics","description":"DESIGN.md §6 port scope, §7 Phase 3 (native fitness gates topology search at scale — §4.6). Port the door_width (1.2 m) adjacency graph (Urb Dom Graph), Merge_Divided, and the TWO-PHASE build: adjacency/level/vertical checks run on the UNMERGED tree, graphs rebuilt after Merge_Divided for storey processing (ProgrammeDriven.pm:83-103). Port faithfully — including has_vertical_connection's no-spatial-overlap stub (ProgrammeDriven.pm:399-423) unless the fidelity decision (§8.1) says otherwise; record the decision.","acceptance_criteria":"Graph edges/widths and merged structure match Perl on the 35-file corpus; vertical-connectivity fidelity decision recorded","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:38:23Z","created_by":"Bruno Postle","updated_at":"2026-06-12T20:55:31Z","started_at":"2026-06-12T13:13:09Z","closed_at":"2026-06-12T20:55:31Z","close_reason":"Graph edges/widths match Perl on all 35 corpus files (2 bugs fixed: empty-string boundary excluded by Python 'in' operator substring check, and upper-storey rotation not delegating to below-link). Merge_Divided ported. Vertical-connectivity fidelity decision recorded in graph.py module docstring (faithful stub, no spatial overlap). Tests in test_graph.py.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"id":"homemaker-py-1p0","title":"Geometry inner loop: full-objective equal-offset ratio optimiser","description":"DESIGN.md §5.1, §7 Phase 1. Productionise experiments/optimize_fullfitness.py into homemaker: optimise(topology, x0=None) -\u003e (geometry, fitness). DOF = equal-offset division ratios of free branches (solver.free_branches, lowest-storey cut ownership), clipped to [eps, 1-eps]. Objective = full oracle fitness (never a proxy — §4.2 falsified). Must support warm-start x0 (§5.6) and a population/batch evaluation mode so each iteration scores via one batched oracle call (§4.6).","acceptance_criteria":"Reproduces or exceeds §4.5 gains (x1.24–x1.67, no new failures) on 2f45907, candidate-002, c964435; works as a library call on any corpus .dom","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-11T23:36:58Z","created_by":"Bruno Postle","updated_at":"2026-06-12T08:46:31Z","started_at":"2026-06-12T00:14:19Z","closed_at":"2026-06-12T08:46:31Z","close_reason":"innerloop.optimise() lands: batched CMA-ES sigma ladder (0.05/0.15, IPOP popsize doubling, deterministic seeding) over equal-offset free-branch ratios vs full oracle fitness; warm-start x0 supported. Acceptance vs unprojected originals: x1.65/x1.66/x1.58 against bars x1.24/x1.67/x1.59, no new failures, 46 oracle calls vs NM's 200. Two near-bar results accepted as reproduced-within-noise (1% tol) — draw spread brackets the single-NM-draw bars; approved by Bruno 2026-06-12. Gotchas: equal-offset projection of legacy unequal cuts loses fitness/adds failures (midpoint projection used); pycma seed=0 means clock-seeded.","dependencies":[{"issue_id":"homemaker-py-1p0","depends_on_id":"homemaker-py-av5","type":"blocks","created_at":"2026-06-12T00:39:33Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
|
||||
|
|
@ -19,5 +22,5 @@
|
|||
{"_type":"memory","key":"urb-oracle-nondeterminism-urb-fitness-pl-output-varies","value":"Urb oracle nondeterminism: urb-fitness.pl output varies run-to-run from Perl hash-order randomisation — .fails line ORDER shuffles (compare sorted, use oracle.Score.fail_lines) and the score float can flip by ~1 ULP (compare with math.isclose rel_tol=1e-12, never ==). Not a batching artifact; affects single runs too. Matters for the Phase 3 native-fitness parity gate (homemaker-py-uxz)."}
|
||||
{"_type":"memory","key":"user-preference-bruno-this-is-a-fedora-system","value":"User preference (Bruno): this is a Fedora system — NEVER install Python packages via pip without asking first; always ask whether to install the rpm via dnf (e.g. python3-cma) before considering pip. Applies to any dependency additions."}
|
||||
{"_type":"memory","key":"strategy-decision-2026-06-12-bruno-occlusion-daylight","value":"Strategy decision 2026-06-12 (Bruno): occlusion/daylight is ORTHOGONAL to building a scalable optimiser. Disable it in Urb (env flag, homemaker-py-gp2) rather than port it; native fitness uses simple crinkliness (illumination factor = 1); rebuild occlusion in Python only after optimisation is fully native (homemaker-py-2g5, now P4). Consequence: all scores change when the flag flips — re-baseline corpus/.score, DESIGN \\$4.5 gains, gate bars at one clean boundary AFTER homemaker-py-1p0 closes; Phase-2 urb-evolve benchmark must run with the same flag."}
|
||||
{"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
|
||||
{"_type":"memory","key":"urb-fitness-bug-found-fixed-2026-06-12","value":"Urb fitness bug found+fixed 2026-06-12 (patch in /home/bruno/src/urb, uncommitted): ProgrammeDriven.pm ratio_o/ratio_type grepped case-insensitively over the ratios hash and took the FIRST key — nondeterministic (x4.5 score swings) for designs with mixed-case type classes (both 'c' circulation and 'C' covered). Fixed to SUM the class (matches Is_Circulation//Is_Outside semantics); 35/35 corpus scores unchanged. CRITICAL for homemaker-py-3y7/gnw: the native port must implement class-SUM ratios. Building.pm has the same unpatched pattern (site-driven path, not used by our oracle). Also: the memetic search reward-hacked this bug before the fix — search results predating it are noise artifacts."}
|
||||
{"_type":"memory","key":"correction-to-urb-fitness-bug-memory-bruno-2026","value":"CORRECTION to urb-fitness-bug memory (Bruno, 2026-06-12): 'C' is NOT a 'covered' type — Is_Covered is a geometric predicate (indoor space above). Urb's generic types are canonically UPPERCASE: C=circulation, O=outside, S=sahn (get_space_types qw/C O S/; corpus is 100% uppercase, never 'c'/'o' leaves). The mixed-case designs that fired the latent ratio_type first-match bug were created by homemaker's own operator type pool emitting lowercase 'c'/'o' — fixed: driver/operators now emit uppercase generics only, and class checks use t[0].lower() in 'cos'. The Urb class-sum patch stays as defensive hardening (zero impact on canonical designs). Native port (3y7/gnw): treat type classes case-insensitively, generics canonically uppercase."}
|
||||
|
|
|
|||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -66,4 +66,13 @@ _Add a brief overview of your project architecture_
|
|||
|
||||
## Conventions & Patterns
|
||||
|
||||
_Add your project-specific conventions here_
|
||||
### Oracle (urb-fitness.pl)
|
||||
|
||||
`urb-fitness.pl` is in PATH. To score a `.dom` file you **must `cd` to the directory containing the `.dom` file first** — the script resolves `patterns.config`, `costs.config`, and writes `.score`/`.fails` relative to `cwd`:
|
||||
|
||||
```bash
|
||||
cd /home/bruno/src/urb/examples/programme-house
|
||||
urb-fitness.pl cf0b8a77e8b2325f92a7e7d150184a55.dom
|
||||
```
|
||||
|
||||
The score is written to `<file>.dom.score` and failures to `<file>.dom.fails`; the numeric score is also printed to stderr.
|
||||
|
|
|
|||
|
|
@ -156,6 +156,8 @@ class Fitness:
|
|||
self._conf = conf or {}
|
||||
self._cost = cost or {}
|
||||
self.spaces: dict = self._conf.get("spaces") or {}
|
||||
self._programme_cache: dict | None = None
|
||||
self._load_programme(self._conf)
|
||||
|
||||
def conf(self, key: str):
|
||||
v = self._conf.get(key)
|
||||
|
|
@ -486,3 +488,558 @@ class Fitness:
|
|||
def plot_cost(self, root: Node) -> float:
|
||||
"""The 'initial cost' term: plot rate x lowest-root area."""
|
||||
return self.cost("plot") * geometry.area(root)
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Stair geometry (Urb::Misc::Stairs + Urb::Dom::Stair_Fit)
|
||||
# ----------------------------------------------------------------------- #
|
||||
|
||||
@staticmethod
|
||||
def _risers_number(height: float, max_riser: float) -> int:
|
||||
"""Number of risers; mirrors ``risers_number`` in ``Urb::Misc::Stairs``."""
|
||||
n = height / max_riser
|
||||
return n if int(n) == n else 1 + int(n)
|
||||
|
||||
@staticmethod
|
||||
def _ideal_going(riser: float) -> float:
|
||||
"""Ideal going in metres; mirrors ``ideal_going`` in ``Urb::Misc::Stairs``."""
|
||||
going = 0.625 - 2 * riser
|
||||
if going < 0.22:
|
||||
return 0.22
|
||||
if int(going * 200) == going * 200:
|
||||
return going
|
||||
return 0.005 + int(going * 200) / 200
|
||||
|
||||
@staticmethod
|
||||
def _three_turn(risers: int, going_a: int) -> int:
|
||||
r = int((risers + 1) / 2) - 5 - int(going_a)
|
||||
return max(0, r)
|
||||
|
||||
@staticmethod
|
||||
def _two_turn(risers: int, going_a: int) -> int:
|
||||
if risers % 2 == 1:
|
||||
r = int(risers / 2) - 3 - int(going_a / 2)
|
||||
else:
|
||||
r = int(risers / 2) - 3 - int((going_a + 1) / 2)
|
||||
return max(0, r)
|
||||
|
||||
@staticmethod
|
||||
def _one_turn(risers: int, going_a: int) -> int:
|
||||
r = risers - 4 - int(going_a)
|
||||
return max(0, r)
|
||||
|
||||
@staticmethod
|
||||
def _zero_turn(risers: int, going_a: int) -> int:
|
||||
if going_a + 2 > risers:
|
||||
return 0
|
||||
return risers - 1
|
||||
|
||||
def _stair_fit(self, leaf: Node, corners: list[int]) -> float:
|
||||
"""Stair fit score for one circulation leaf; mirrors ``Urb::Dom::Stair_Fit``."""
|
||||
root = dom_mod._level_root(leaf)
|
||||
while root.below is not None:
|
||||
root = root.below
|
||||
max_riser = getattr(root, "stair_riser", None) or 0.21
|
||||
width = getattr(root, "stair_width", None) or 1.25
|
||||
|
||||
height = _height(leaf)
|
||||
risers = self._risers_number(height, max_riser)
|
||||
going = self._ideal_going(height / risers)
|
||||
base = geometry.edge_length(leaf, corners[0])
|
||||
length = geometry.edge_length(leaf, corners[0] + 1)
|
||||
|
||||
going_a = int((base - 2 * width) / going)
|
||||
n = len(corners)
|
||||
if n == 1:
|
||||
going_b = self._three_turn(risers, going_a)
|
||||
elif n == 2:
|
||||
going_b = self._two_turn(risers, going_a)
|
||||
elif n == 3:
|
||||
going_b = self._one_turn(risers, going_a)
|
||||
else:
|
||||
going_b = self._zero_turn(risers, going_a)
|
||||
|
||||
return length / (width * 2 + going * going_b)
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Building-level ratio helpers (Dom.pm:Ratios/Areas/Area_Internal)
|
||||
# ----------------------------------------------------------------------- #
|
||||
|
||||
@staticmethod
|
||||
def _areas(root: Node) -> tuple[float, dict[str, float]]:
|
||||
"""Total usable area and per-type area dict; mirrors ``Urb::Dom::Areas``."""
|
||||
area_all = 0.0
|
||||
areas: dict[str, float] = {}
|
||||
for lvl in dom_mod.levels(root):
|
||||
for leaf in lvl.leaves():
|
||||
if not dom_mod.is_usable(leaf):
|
||||
continue
|
||||
a = geometry.area(leaf)
|
||||
area_all += a
|
||||
t = leaf.type or ""
|
||||
areas[t] = areas.get(t, 0.0) + a
|
||||
return area_all, areas
|
||||
|
||||
@staticmethod
|
||||
def _area_internal(root: Node) -> float:
|
||||
"""Non-outside usable area; mirrors ``Urb::Dom::Area_Internal``."""
|
||||
total = 0.0
|
||||
for lvl in dom_mod.levels(root):
|
||||
for leaf in lvl.leaves():
|
||||
if dom_mod.is_outside(leaf):
|
||||
continue
|
||||
total += geometry.area(leaf)
|
||||
return total
|
||||
|
||||
def _ratios(self, root: Node) -> dict[str, float]:
|
||||
"""Per-type proportions; mirrors ``Urb::Dom::Ratios``."""
|
||||
area_all, areas = self._areas(root)
|
||||
if area_all == 0.0:
|
||||
return {}
|
||||
return {t: a / area_all for t, a in areas.items()}
|
||||
|
||||
def ratio_o(self, ratios: dict[str, float]) -> float:
|
||||
"""Outside/sahn proportion gaussian; mirrors ``ProgrammeDriven::ratio_o``."""
|
||||
proportion_o = sum(v for k, v in ratios.items() if k and k[0].lower() in ("o", "s"))
|
||||
return gaussian(proportion_o, 1.0, *self.conf("ratio_outside"))
|
||||
|
||||
def ratio_type(self, ratios: dict[str, float], code: str, ratio: float, sigma: float) -> float:
|
||||
"""Type-class proportion gaussian; mirrors ``ProgrammeDriven::ratio_type``."""
|
||||
proportion_type = sum(v for k, v in ratios.items() if k and k[0].lower() == code[0].lower())
|
||||
proportion_non_o = 1.0 - sum(v for k, v in ratios.items() if k and k[0].lower() in ("o", "s"))
|
||||
if proportion_non_o <= 0.0:
|
||||
proportion_non_o = 1.0
|
||||
return gaussian(proportion_type / proportion_non_o, 1.0, ratio, sigma)
|
||||
|
||||
def quality_staircase_volume(self, *stair_fits: float) -> float:
|
||||
"""Best-stair gaussian; mirrors ``ProgrammeDriven::quality_staircase_volume``."""
|
||||
factor = 0.09
|
||||
for sf in stair_fits:
|
||||
if sf < 1:
|
||||
f2 = gaussian(sf, 1.2, 1.0, 0.1)
|
||||
else:
|
||||
f2 = gaussian(sf, 1.2, 1.0, 0.5)
|
||||
if f2 > factor:
|
||||
factor = f2
|
||||
return factor
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Public access / boundary length helpers
|
||||
# ----------------------------------------------------------------------- #
|
||||
|
||||
@staticmethod
|
||||
def _access_external(leaf: Node) -> list[str]:
|
||||
"""External boundary ids ('a'-'d') for each edge of leaf."""
|
||||
_EXT = frozenset("abcd")
|
||||
result = []
|
||||
for edge in range(4):
|
||||
bid = geometry.boundary_id(leaf, edge)
|
||||
if bid in _EXT:
|
||||
result.append(bid)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _perimeter_type(root: Node, bid: str) -> str:
|
||||
"""Type string from root perimeter dict ('' if not set)."""
|
||||
p = root.perimeter
|
||||
if p is None:
|
||||
return ""
|
||||
return p.get(bid) or ""
|
||||
|
||||
def _public_access(self, leaf: Node, root: Node) -> str | None:
|
||||
"""Return external boundary id if leaf has public street access; mirrors
|
||||
``Urb::Dom::Public_Access``. Returns None if no public access."""
|
||||
if dom_mod.level_of(leaf) != 0:
|
||||
return None
|
||||
if leaf.divided:
|
||||
return None
|
||||
for bid in self._access_external(leaf):
|
||||
if self._perimeter_type(root, bid).lower() != "private":
|
||||
return bid
|
||||
return None
|
||||
|
||||
def _entrance_bid_for_stair(
|
||||
self,
|
||||
stair_leaf: Node,
|
||||
level_root: Node,
|
||||
G: nx.Graph,
|
||||
graph_circ: list,
|
||||
all_lvls: list,
|
||||
root: Node,
|
||||
) -> str | None:
|
||||
"""Return boundary id if stair_leaf is the building entrance; else None.
|
||||
|
||||
Mirrors the stair-entrance selection in Urb::Dom::Entrances: a stair C
|
||||
leaf wins (priority 3) only when no non-stair C leaf has a higher-priority
|
||||
entrance (priority 4 direct, 4.5 via outdoor). Via-outdoor stair entries
|
||||
(priority 3.5) map to a leaf id, not a boundary, so they never produce
|
||||
entrance corners in Perl either.
|
||||
"""
|
||||
from . import graph as graph_mod
|
||||
|
||||
stair_bid = self._public_access(stair_leaf, root)
|
||||
if stair_bid is None:
|
||||
return None
|
||||
for other in level_root.leaves():
|
||||
if other is stair_leaf:
|
||||
continue
|
||||
if not other.type or other.type[0].lower() != "c":
|
||||
continue
|
||||
other_corners = graph_mod.stack_corners_in_use(other, graph_circ, all_lvls)
|
||||
if dom_mod.is_covered(other) and other_corners:
|
||||
continue # also a stair — same priority, skip
|
||||
if self._public_access(other, root) is not None:
|
||||
return None
|
||||
for nb in G.neighbors(other):
|
||||
if nb.type and nb.type[0].lower() == "o" and self._public_access(nb, root) is not None:
|
||||
return None
|
||||
return stair_bid
|
||||
|
||||
def _public_access_outside(self, leaf: Node, G: nx.Graph, root: Node) -> bool:
|
||||
"""True if leaf is an outside street-edge node with an lck neighbour;
|
||||
mirrors ``Urb::Dom::Public_Access_Outside``."""
|
||||
if leaf.divided:
|
||||
return False
|
||||
if not dom_mod.is_outside(leaf):
|
||||
return False
|
||||
if self._public_access(leaf, root) is None:
|
||||
return False
|
||||
for nb in G.neighbors(leaf):
|
||||
if nb.type and nb.type[0].lower() in ("l", "c", "k"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _public_length(self, leaf: Node, root: Node) -> float:
|
||||
"""Non-private external boundary metres; mirrors ``Urb::Dom::Public_Length``."""
|
||||
if dom_mod.level_of(leaf) != 0:
|
||||
return 0.0
|
||||
total = 0.0
|
||||
for edge in range(4):
|
||||
bid = geometry.boundary_id(leaf, edge)
|
||||
if bid not in frozenset("abcd"):
|
||||
continue
|
||||
if self._perimeter_type(root, bid).lower() == "private":
|
||||
continue
|
||||
total += geometry.edge_length(leaf, edge)
|
||||
return total
|
||||
|
||||
def _private_length(self, leaf: Node, root: Node) -> float:
|
||||
"""Private external boundary metres; mirrors ``Urb::Dom::Private_Length``."""
|
||||
if dom_mod.level_of(leaf) != 0:
|
||||
return 0.0
|
||||
total = 0.0
|
||||
for edge in range(4):
|
||||
bid = geometry.boundary_id(leaf, edge)
|
||||
if bid not in frozenset("abcd"):
|
||||
continue
|
||||
if self._perimeter_type(root, bid).lower() != "private":
|
||||
continue
|
||||
total += geometry.edge_length(leaf, edge)
|
||||
return total
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Extended process_storey (adds circ, stair, tracking)
|
||||
# ----------------------------------------------------------------------- #
|
||||
|
||||
def process_storey(
|
||||
self,
|
||||
level_root: Node,
|
||||
G: nx.Graph,
|
||||
level_id: int,
|
||||
fail,
|
||||
graph_circ: list[nx.Graph] | None = None,
|
||||
tracking: dict | None = None,
|
||||
lvls: list[Node] | None = None,
|
||||
root: Node | None = None,
|
||||
) -> StoreyEval:
|
||||
"""Per-storey cost, value and leaf evaluations on the MERGED tree.
|
||||
|
||||
Optional ``graph_circ``, ``tracking``, ``lvls``, ``root`` activate the
|
||||
homemaker-py-hgg storey checks (stair fit, circulation connectivity,
|
||||
roof-garden, public-access tracking). When omitted the method behaves
|
||||
as in homemaker-py-gnw (leaf quality + costs only).
|
||||
"""
|
||||
from . import graph as graph_mod
|
||||
|
||||
groups = geometry.boundary_groups(level_root)
|
||||
cost = 0.0
|
||||
value = 0.0
|
||||
leaves_eval: list[LeafEval] = []
|
||||
has_outdoor_space = False
|
||||
|
||||
for leaf in level_root.leaves():
|
||||
if dom_mod.is_outside(leaf) and dom_mod.is_covered(leaf) and level_id:
|
||||
if not dom_mod.is_supported(leaf):
|
||||
fail(f"{level_id}/{leaf.id} unsupported covered outside")
|
||||
fail(f"{level_id}/{leaf.id} covered outside above ground")
|
||||
|
||||
cost += self.leaf_cost(leaf)
|
||||
if not dom_mod.is_usable(leaf):
|
||||
continue
|
||||
|
||||
if dom_mod.is_outside(leaf):
|
||||
has_outdoor_space = True
|
||||
|
||||
quality, factors = self.evaluate_leaf(leaf, G, level_id, groups, fail)
|
||||
rate = self.value_rate(leaf)
|
||||
value += quality * rate * geometry.area(leaf)
|
||||
leaves_eval.append(
|
||||
LeafEval(
|
||||
level=level_id,
|
||||
id=leaf.id,
|
||||
type=leaf.type or "",
|
||||
area=geometry.area(leaf),
|
||||
rate=rate,
|
||||
quality=quality,
|
||||
factors=factors,
|
||||
)
|
||||
)
|
||||
|
||||
if graph_circ is not None and tracking is not None and lvls is not None and root is not None:
|
||||
# Stair fit — ground floor circulation/covered only
|
||||
stair_fit = 0.0
|
||||
if level_id == 0 and leaf.type and leaf.type[0].lower() == "c" and dom_mod.is_covered(leaf):
|
||||
all_lvls = lvls
|
||||
corners = graph_mod.stack_corners_in_use(leaf, graph_circ, all_lvls)
|
||||
n_corners = len(corners)
|
||||
if n_corners:
|
||||
# Mirror Perl check_stair_fit: add entrance door corners so
|
||||
# the stair loses the corner it shares with the entrance.
|
||||
entrance_bid = self._entrance_bid_for_stair(
|
||||
leaf, level_root, G, graph_circ, all_lvls, root
|
||||
)
|
||||
if entrance_bid is not None:
|
||||
for edge in range(4):
|
||||
if geometry.boundary_id(leaf, edge) == entrance_bid:
|
||||
for ec in (edge, edge + 1):
|
||||
if ec not in corners:
|
||||
corners = corners + [ec]
|
||||
stair_fit = self._stair_fit(leaf, corners)
|
||||
tracking["stair_fit"].append(stair_fit)
|
||||
|
||||
# Public access tracking
|
||||
if root is not None:
|
||||
if self._public_access_outside(leaf, G, root):
|
||||
tracking["has_public_access_outside"] = True
|
||||
if (not stair_fit
|
||||
and leaf.type and leaf.type[0].lower() == "c"
|
||||
and self._public_access(leaf, root) is not None):
|
||||
tracking["has_public_access_inside"] = True
|
||||
|
||||
pub = self._public_length(leaf, root)
|
||||
tracking["public_length_all"] = tracking.get("public_length_all", 0.0) + pub
|
||||
if dom_mod.is_outside(leaf):
|
||||
tracking["public_length_outside"] = tracking.get("public_length_outside", 0.0) + pub
|
||||
priv = self._private_length(leaf, root)
|
||||
tracking["private_length_all"] = tracking.get("private_length_all", 0.0) + priv
|
||||
if dom_mod.is_outside(leaf):
|
||||
tracking["private_length_outside"] = tracking.get("private_length_outside", 0.0) + priv
|
||||
|
||||
for a, b in G.edges():
|
||||
cost += self.edge_cost(G, a, b, fail)
|
||||
for leaf in level_root.leaves():
|
||||
cost += self.outside_edge_cost(leaf, fail)
|
||||
|
||||
if graph_circ is not None:
|
||||
# Connected_Circulation check on a copy of the circ graph
|
||||
gc_copy = graph_circ[level_id].copy() if level_id < len(graph_circ) else nx.Graph()
|
||||
if not graph_mod.connected_circulation(gc_copy):
|
||||
fail(f"level {level_id} not connected")
|
||||
|
||||
conf_fg = self.conf("force_roof_garden")
|
||||
if conf_fg and not has_outdoor_space:
|
||||
fail(f"level {level_id} no outside space")
|
||||
|
||||
return StoreyEval(cost=cost, value=value, leaves=leaves_eval)
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Building-level evaluation
|
||||
# ----------------------------------------------------------------------- #
|
||||
|
||||
def evaluate_building(self, root: Node, tracking: dict) -> float:
|
||||
"""Building factor; mirrors ``evaluate_building_program_driven``."""
|
||||
from . import graph as graph_mod
|
||||
|
||||
ratios = self._ratios(root)
|
||||
|
||||
factor = 1.0
|
||||
factor *= self.ratio_o(ratios)
|
||||
|
||||
circ_ratio = self.conf("ratio_circulation")
|
||||
factor *= self.ratio_type(ratios, "c", circ_ratio[0], circ_ratio[1])
|
||||
|
||||
min_required = 0.0
|
||||
for req in (self._programme or {}).values():
|
||||
if req.code and req.code[0].lower() in ("c", "o", "s"):
|
||||
continue
|
||||
if req.size > 0:
|
||||
min_required += req.size * req.count
|
||||
min_required *= 1.2
|
||||
actual_internal = self._area_internal(root)
|
||||
if actual_internal < min_required and min_required > 0:
|
||||
f2 = gaussian(actual_internal, 1.0, min_required, min_required * 0.15)
|
||||
factor *= f2
|
||||
|
||||
# Public/private ratios (optional config)
|
||||
pub_all = tracking.get("public_length_all", 0.0)
|
||||
pub_ratio = tracking.get("public_length_outside", 0.0) / pub_all if pub_all else 0.0
|
||||
conf_po = self.conf("ratio_public_outside")
|
||||
if conf_po and isinstance(conf_po, list):
|
||||
factor *= gaussian(pub_ratio, 1.0, conf_po[0], conf_po[1])
|
||||
|
||||
priv_all = tracking.get("private_length_all", 0.0)
|
||||
priv_ratio = tracking.get("private_length_outside", 0.0) / priv_all if priv_all else 0.0
|
||||
conf_pr = self.conf("ratio_private_outside")
|
||||
if conf_pr and isinstance(conf_pr, list):
|
||||
factor *= gaussian(priv_ratio, 1.0, conf_pr[0], conf_pr[1])
|
||||
|
||||
# Staircase volume (multi-level only)
|
||||
lvls = dom_mod.levels(root)
|
||||
if len(lvls) > 1:
|
||||
sf_factor = self.quality_staircase_volume(*tracking.get("stair_fit", []))
|
||||
if sf_factor < FAIL_THRESHOLD:
|
||||
tracking["_failures"].append("staircase volume")
|
||||
factor *= sf_factor
|
||||
|
||||
stair_min = self.conf("staircase_min") or 1
|
||||
stair_max = self.conf("staircase_max") or 1
|
||||
stair_count = len(tracking.get("stair_fit", []))
|
||||
|
||||
if stair_count < stair_min:
|
||||
tracking["_failures"].append(
|
||||
f"too few stairs ({stair_count}, min {stair_min})"
|
||||
)
|
||||
if stair_count > stair_max:
|
||||
tracking["_failures"].append(
|
||||
f"too many stairs ({stair_count}, max {stair_max})"
|
||||
)
|
||||
|
||||
# Storey limit / minimum
|
||||
n_storeys = len(lvls)
|
||||
storey_limit = self.conf("storey_limit") or 4
|
||||
storey_min = self.conf("storey_minimum") or 2
|
||||
if n_storeys - 1 >= storey_limit:
|
||||
tracking["_failures"].append("storey limit")
|
||||
if n_storeys < storey_min:
|
||||
tracking["_failures"].append("storey minimum")
|
||||
|
||||
# Public access
|
||||
if not (tracking.get("has_public_access_outside") or tracking.get("has_public_access_inside")):
|
||||
tracking["_failures"].append("no outside public access")
|
||||
|
||||
return factor
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Full pipeline
|
||||
# ----------------------------------------------------------------------- #
|
||||
|
||||
def evaluate(self, root: Node) -> float:
|
||||
"""Full programme-driven fitness; mirrors ``ProgrammeDriven::_apply``.
|
||||
|
||||
Returns ``value / cost`` (the final score as in Urb).
|
||||
"""
|
||||
from . import graph as graph_mod
|
||||
from .programme import load_programme
|
||||
|
||||
geometry.clear_cache()
|
||||
|
||||
failures: list[str] = []
|
||||
tracking: dict = {
|
||||
"has_public_access_outside": False,
|
||||
"has_public_access_inside": False,
|
||||
"public_length_all": 0.0,
|
||||
"public_length_outside": 0.0,
|
||||
"private_length_all": 0.0,
|
||||
"private_length_outside": 0.0,
|
||||
"stair_fit": [],
|
||||
"_failures": failures,
|
||||
}
|
||||
|
||||
programme = self._programme or {}
|
||||
|
||||
# --- Phase 1: UNMERGED tree checks ---
|
||||
check_fails, missing = graph_mod.check_space_counts(root, programme)
|
||||
failures.extend(check_fails)
|
||||
|
||||
self.preprocess_building(root)
|
||||
_, graph_circ_pre = graph_mod.build_graphs_with_circ(
|
||||
root, self.conf("door_width") or 1.2, failures.append
|
||||
)
|
||||
|
||||
graph_base_pre = graph_mod.build_graphs(root, self.conf("door_width") or 1.2)
|
||||
|
||||
failures.extend(graph_mod.check_adjacency(root, programme, graph_base_pre, missing))
|
||||
failures.extend(graph_mod.check_level_constraints(root, programme, missing))
|
||||
failures.extend(graph_mod.check_vertical_connectivity(root, programme, missing))
|
||||
|
||||
# --- Phase 2: MERGED tree ---
|
||||
dom_mod.merge_divided(root)
|
||||
geometry.clear_cache() # mirror Perl Merge_Divided → Clean_Cache
|
||||
|
||||
_, graph_circ = graph_mod.build_graphs_with_circ(
|
||||
root, self.conf("door_width") or 1.2, failures.append
|
||||
)
|
||||
graph_base = graph_mod.build_graphs(root, self.conf("door_width") or 1.2)
|
||||
|
||||
cost = self.plot_cost(root)
|
||||
value = 0.0
|
||||
lvls = dom_mod.levels(root)
|
||||
|
||||
for li, lvl in enumerate(lvls):
|
||||
se = self.process_storey(
|
||||
lvl, graph_base[li], li, failures.append,
|
||||
graph_circ=graph_circ,
|
||||
tracking=tracking,
|
||||
lvls=lvls,
|
||||
root=root,
|
||||
)
|
||||
cost += se.cost
|
||||
value += se.value
|
||||
|
||||
building_factor = self.evaluate_building(root, tracking)
|
||||
value *= building_factor
|
||||
|
||||
# 0.5^n failure penalty (programme-driven mode, not 0.1^n)
|
||||
n_fails = len(failures)
|
||||
value *= 0.5 ** n_fails
|
||||
|
||||
if cost == 0.0:
|
||||
return 0.0
|
||||
return value / cost
|
||||
|
||||
@property
|
||||
def _programme(self) -> dict | None:
|
||||
"""Programme requirements parsed from config, or None."""
|
||||
return self._programme_cache
|
||||
|
||||
def _load_programme(self, conf: dict) -> None:
|
||||
"""Populate ``_programme_cache`` from spaces section of conf dict."""
|
||||
from .programme import SpaceReq
|
||||
_DW = (4.0, 1.0)
|
||||
_DP = (1.5, 0.5)
|
||||
spaces = conf.get("spaces") or {}
|
||||
if not spaces:
|
||||
self._programme_cache = None
|
||||
return
|
||||
reqs: dict = {}
|
||||
for code, c in spaces.items():
|
||||
sz = c.get("size") or [0.0, 1.0]
|
||||
w = c.get("width") or _DW
|
||||
pr = c.get("proportion") or _DP
|
||||
reqs[code] = SpaceReq(
|
||||
code=code,
|
||||
name=c.get("name", ""),
|
||||
size=float(sz[0]),
|
||||
size_sigma=float(sz[1]),
|
||||
width=float(w[0]),
|
||||
width_sigma=float(w[1]),
|
||||
proportion=float(pr[0]),
|
||||
proportion_sigma=float(pr[1]),
|
||||
adjacency=list(c.get("adjacency") or []),
|
||||
level=c.get("level"),
|
||||
requires_below=c.get("requires_below"),
|
||||
count=int(c.get("count") or 1),
|
||||
has_size="size" in c,
|
||||
has_width="width" in c,
|
||||
has_proportion="proportion" in c,
|
||||
)
|
||||
self._programme_cache = reqs
|
||||
|
|
|
|||
|
|
@ -275,14 +275,67 @@ def boundary_pair_overlap(contributors: list[tuple[Node, int]], a: Node, b: Node
|
|||
return _edge_overlap(a, edge_a, b, edge_b)
|
||||
|
||||
|
||||
def is_between_2d(point: Point | None, pa: Point, pb: Point) -> bool:
|
||||
"""True if point lies on segment [pa, pb]; mirrors ``Urb::Math::is_between_2d``.
|
||||
|
||||
Returns False if point is None (matches Perl undef-in-array behaviour where
|
||||
``distance_2d(undef, ...)`` returns 0, so the check only passes when the
|
||||
segment length itself is < 0.000001).
|
||||
"""
|
||||
if point is None:
|
||||
la = 0.0
|
||||
lb = 0.0
|
||||
else:
|
||||
la = _dist(pa, point)
|
||||
lb = _dist(pb, point)
|
||||
length = _dist(pa, pb)
|
||||
return abs(length - la - lb) < 0.000001
|
||||
|
||||
|
||||
def _edge_overlap_coords(
|
||||
a: Node, edge_a: int, b: Node, edge_b: int
|
||||
) -> list[Point] | None:
|
||||
"""Endpoints of the shared wall segment; ``[[x0,y0],[x1,y1]]`` or None.
|
||||
|
||||
Projects b's edge onto a's edge direction to find the overlap interval and
|
||||
converts back to 2D. Used to populate ``coordinates`` in ``leaf_graph``
|
||||
for stair-corner detection (``Corners_In_Use``).
|
||||
"""
|
||||
p_a0 = coordinate(a, edge_a)
|
||||
p_a1 = coordinate(a, (edge_a + 1) % 4)
|
||||
p_b0 = coordinate(b, edge_b)
|
||||
p_b1 = coordinate(b, (edge_b + 1) % 4)
|
||||
dx = p_a1[0] - p_a0[0]
|
||||
dy = p_a1[1] - p_a0[1]
|
||||
len_sq = dx * dx + dy * dy
|
||||
if len_sq < 1e-12:
|
||||
return None
|
||||
inv = 1.0 / math.sqrt(len_sq)
|
||||
ux, uy = dx * inv, dy * inv
|
||||
len_a = math.sqrt(len_sq)
|
||||
t_b0 = (p_b0[0] - p_a0[0]) * ux + (p_b0[1] - p_a0[1]) * uy
|
||||
t_b1 = (p_b1[0] - p_a0[0]) * ux + (p_b1[1] - p_a0[1]) * uy
|
||||
if t_b0 > t_b1:
|
||||
t_b0, t_b1 = t_b1, t_b0
|
||||
t_start = max(0.0, t_b0)
|
||||
t_end = min(len_a, t_b1)
|
||||
if t_start >= t_end - 1e-9:
|
||||
return None
|
||||
return [
|
||||
[p_a0[0] + t_start * ux, p_a0[1] + t_start * uy],
|
||||
[p_a0[0] + t_end * ux, p_a0[1] + t_end * uy],
|
||||
]
|
||||
|
||||
|
||||
def leaf_graph(level_root: Node, door_width: float = 1.2): # -> nx.Graph
|
||||
"""Leaf-adjacency graph for one storey; mirrors ``Urb::Quad::Graph``.
|
||||
|
||||
Returns a ``networkx.Graph`` whose nodes are leaf ``Node`` objects and whose
|
||||
edges carry ``width`` (shared boundary metres) and ``weight`` (centroid
|
||||
distance metres). Edges with width < ``door_width`` (Urb default 1.2 m)
|
||||
are excluded. External plot-perimeter boundaries ('a','b','c','d') are
|
||||
never edges. A single-leaf storey gets one isolated vertex.
|
||||
edges carry ``width`` (shared boundary metres), ``weight`` (centroid
|
||||
distance metres), and ``coordinates`` ([[x0,y0],[x1,y1]] wall endpoints or
|
||||
None). Edges with width < ``door_width`` (Urb default 1.2 m) are excluded.
|
||||
External plot-perimeter boundaries ('a','b','c','d') are never edges. A
|
||||
single-leaf storey gets one isolated vertex.
|
||||
"""
|
||||
import networkx as nx
|
||||
|
||||
|
|
@ -304,6 +357,7 @@ def leaf_graph(level_root: Node, door_width: float = 1.2): # -> nx.Graph
|
|||
if width >= door_width:
|
||||
if not G.has_edge(a, b) or G[a][b]["width"] < width:
|
||||
dist = _dist(centroid(a), centroid(b))
|
||||
G.add_edge(a, b, weight=dist, width=width)
|
||||
coords = _edge_overlap_coords(a, edge_a, b, edge_b)
|
||||
G.add_edge(a, b, weight=dist, width=width, coordinates=coords)
|
||||
|
||||
return G
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ FIDELITY DECISION — ``has_vertical_connection`` (DESIGN.md §8.1):
|
|||
counts as "connected", regardless of spatial overlap. This is a known
|
||||
simplification in the Perl; it is preserved here for oracle parity.
|
||||
Reshape in Phase 4 if needed.
|
||||
|
||||
PERL CLONE QUIRK — ``has_circulation`` (Base.pm:228-241):
|
||||
Perl's ``Graph::clone()`` only copies vertices that are part of at least one
|
||||
edge. Isolated vertices (single-leaf levels or unconnected nodes) are lost.
|
||||
An empty graph returns ``is_connected = False``. ``has_circulation`` replicates
|
||||
this by removing isolated vertices before the usability/edge-type filtering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -35,12 +41,344 @@ def build_graphs(root: Node, door_width: float = DOOR_WIDTH) -> list[nx.Graph]:
|
|||
|
||||
This is called twice in the two-phase pattern: once before
|
||||
``dom.merge_divided`` for adjacency/level/vertical checks, and once after
|
||||
for storey processing. ``Has_Circulation`` filtering (which produces the
|
||||
"inaccessible usable space" failure) is implemented in homemaker-py-hgg.
|
||||
for storey processing.
|
||||
"""
|
||||
return [geometry.leaf_graph(lvl, door_width) for lvl in levels(root)]
|
||||
|
||||
|
||||
def build_graphs_with_circ(
|
||||
root: Node,
|
||||
door_width: float,
|
||||
fail,
|
||||
) -> tuple[list[nx.Graph], list[nx.Graph]]:
|
||||
"""Build ``(graph_base, graph_circ)`` pairs; mirrors ``setup_storey_graphs``
|
||||
in ``Base.pm:217-241``.
|
||||
|
||||
``graph_base[i]`` is the unfiltered adjacency graph for level i.
|
||||
``graph_circ[i]`` is a copy filtered by ``has_circulation``; emits
|
||||
"N inaccessible usable space" via ``fail`` if a level is disconnected after
|
||||
filtering.
|
||||
|
||||
Perl clone quirk: ``has_circulation`` removes isolated vertices first, so a
|
||||
level with no adjacency edges always fires the "inaccessible" failure.
|
||||
"""
|
||||
lvls = levels(root)
|
||||
graph_base: list[nx.Graph] = []
|
||||
graph_circ: list[nx.Graph] = []
|
||||
for i, lvl in enumerate(lvls):
|
||||
g = geometry.leaf_graph(lvl, door_width)
|
||||
graph_base.append(g)
|
||||
gc = g.copy()
|
||||
if not has_circulation(gc):
|
||||
fail(f"{i} inaccessible usable space")
|
||||
graph_circ.append(gc)
|
||||
return graph_base, graph_circ
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Has_Circulation (Base.pm:487-594)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _avg_path_len_from(G: nx.Graph, node: Node) -> float:
|
||||
"""Average weighted shortest-path length from node to all reachable other nodes.
|
||||
|
||||
Mirrors Perl's ``$graph->average_path_length($node, undef)`` which uses
|
||||
Dijkstra with edge weights (centroid-to-centroid distances, stored as 'weight').
|
||||
"""
|
||||
try:
|
||||
lengths = dict(nx.single_source_dijkstra_path_length(G, node, weight="weight"))
|
||||
vals = [v for v in lengths.values() if v > 0]
|
||||
return sum(vals) / len(vals) if vals else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def has_circulation(G: nx.Graph) -> bool:
|
||||
"""Port of ``Urb::Dom::Has_Circulation`` (modifies G in place).
|
||||
|
||||
Replicates the Perl clone quirk: isolated vertices (degree 0) are removed
|
||||
first since Perl's ``Graph::clone`` only copies vertices in edges. After
|
||||
that, removes non-usable nodes, trims bedroom/toilet cross-connections, then
|
||||
trims excess circulation and outdoor connections using centrality ordering.
|
||||
Returns True iff the remaining graph is connected.
|
||||
"""
|
||||
# Perl clone loses isolated vertices → remove them first
|
||||
isolated = [v for v in list(G.nodes()) if G.degree(v) == 0]
|
||||
G.remove_nodes_from(isolated)
|
||||
|
||||
# Remove non-usable nodes (outside above outside etc.)
|
||||
non_usable = [v for v in list(G.nodes()) if not dom.is_usable(v)]
|
||||
G.remove_nodes_from(non_usable)
|
||||
|
||||
# Remove b → [lkbt] edges (bedrooms only connect to circulation/outside)
|
||||
for v in list(G.nodes()):
|
||||
if not (v.type and v.type[0].lower() == "b"):
|
||||
continue
|
||||
to_remove = [
|
||||
nb for nb in list(G.neighbors(v))
|
||||
if nb.type and nb.type[0].lower() in ("l", "k", "b", "t")
|
||||
]
|
||||
G.remove_edges_from((v, nb) for nb in to_remove)
|
||||
|
||||
# Remove t → [olkt] edges (toilets only connect to bedrooms/circulation)
|
||||
for v in list(G.nodes()):
|
||||
if not (v.type and v.type[0].lower() == "t"):
|
||||
continue
|
||||
to_remove = [
|
||||
nb for nb in list(G.neighbors(v))
|
||||
if nb.type and nb.type[0].lower() in ("o", "l", "k", "t")
|
||||
]
|
||||
G.remove_edges_from((v, nb) for nb in to_remove)
|
||||
|
||||
# btlk nodes: keep only one circulation neighbour
|
||||
for v in list(G.nodes()):
|
||||
if not (v.type and v.type[0].lower() in ("b", "t", "l", "k")):
|
||||
continue
|
||||
circ_nbs = [nb for nb in list(G.neighbors(v)) if dom.is_circulation(nb)]
|
||||
if len(circ_nbs) <= 1:
|
||||
continue
|
||||
circ_nbs.sort(key=lambda nb: _avg_path_len_from(G, nb))
|
||||
# b/t: keep least popular (last), remove most popular (first)
|
||||
# l/k: keep most popular (first), remove least popular (last)
|
||||
t0 = v.type[0].lower()
|
||||
while len(circ_nbs) > 1:
|
||||
if t0 in ("b", "t"):
|
||||
G.remove_edge(v, circ_nbs.pop(0))
|
||||
else:
|
||||
G.remove_edge(v, circ_nbs.pop())
|
||||
|
||||
# Clone the current state and run Connected_Outside to get outdoor components
|
||||
outside_graph = G.copy()
|
||||
_connected_outside_inplace(outside_graph)
|
||||
outside_components = list(nx.connected_components(outside_graph)) if len(outside_graph.nodes()) > 0 else []
|
||||
|
||||
# blkc nodes: keep only one outdoor neighbour per outdoor component
|
||||
for v in list(G.nodes()):
|
||||
if not (v.type and v.type[0].lower() in ("b", "l", "k", "c")):
|
||||
continue
|
||||
out_nbs = [
|
||||
nb for nb in list(G.neighbors(v))
|
||||
if dom.is_outside(nb) and dom.is_usable(nb)
|
||||
]
|
||||
if len(out_nbs) <= 1:
|
||||
continue
|
||||
out_nbs.sort(key=lambda nb: _avg_path_len_from(G, nb))
|
||||
|
||||
for component in outside_components:
|
||||
component_nbs = [nb for nb in out_nbs if nb in component]
|
||||
if len(component_nbs) <= 1:
|
||||
continue
|
||||
t0 = v.type[0].lower()
|
||||
while len(component_nbs) > 1:
|
||||
if t0 == "b":
|
||||
nb = component_nbs.pop(0)
|
||||
else:
|
||||
nb = component_nbs.pop()
|
||||
if G.has_edge(v, nb):
|
||||
G.remove_edge(v, nb)
|
||||
|
||||
if len(G.nodes()) == 0:
|
||||
return False
|
||||
return nx.is_connected(G)
|
||||
|
||||
|
||||
def _connected_outside_inplace(G: nx.Graph) -> None:
|
||||
"""Remove all non-outside/non-usable vertices; mirrors ``Connected_Outside``."""
|
||||
to_remove = [v for v in list(G.nodes()) if not (dom.is_outside(v) and dom.is_usable(v))]
|
||||
G.remove_nodes_from(to_remove)
|
||||
|
||||
|
||||
def connected_circulation(G: nx.Graph) -> bool:
|
||||
"""True iff circulation nodes are non-empty and connected; mirrors
|
||||
``Urb::Dom::Connected_Circulation`` (Storey.pm:106).
|
||||
|
||||
Removes all non-circulation vertices from G in place before checking.
|
||||
Perl's ``Graph::is_connected`` returns False for an empty graph — replicated
|
||||
here so "level N not connected" fires when there are no circulation nodes.
|
||||
"""
|
||||
to_remove = [v for v in list(G.nodes()) if not dom.is_circulation(v)]
|
||||
G.remove_nodes_from(to_remove)
|
||||
if len(G.nodes()) == 0:
|
||||
return False # Perl Graph::is_connected returns false for empty graph
|
||||
return nx.is_connected(G)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Stair-corner detection (Quad.pm:1490-1544, Dom.pm:648-668)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _corners_of(leaf: Node) -> list[list[float] | None]:
|
||||
"""4 corner coordinates of leaf (index 0-3), with None at index 4 to
|
||||
replicate Perl's undef-array-element behaviour in ``Corners_In_Use``."""
|
||||
return [geometry.coordinate(leaf, i) for i in range(4)] + [None]
|
||||
|
||||
|
||||
def corners_in_use(
|
||||
leaf: Node, G: nx.Graph, neighbors: list[Node]
|
||||
) -> list[int]:
|
||||
"""Return the minimum set of consecutive corner indices needed to contain
|
||||
all shared walls; mirrors ``Urb::Quad::Corners_In_Use`` (Quad.pm:1490).
|
||||
|
||||
Returns raw consecutive indices — may include values > 3 (e.g. [3,4] or
|
||||
[3,4,5]); the caller (``stack_corners_in_use``) normalises with % 4 after
|
||||
rotation remapping.
|
||||
|
||||
Perl's ``corners[4]`` is undef. ``is_between_2d(point, undef, undef)``
|
||||
always returns True in Perl (distance_2d(undef,x)=0 so abs(0-0-0)<1e-6).
|
||||
This means the triple check at idx=3 always succeeds in Perl, so [3,4,5]
|
||||
is always returned when no shorter span works — equivalent to [0,1,3] after
|
||||
rotation-normalisation. ``_ib`` replicates that: both-None → True,
|
||||
one-None → False.
|
||||
"""
|
||||
walls: list[list] = []
|
||||
for nb in neighbors:
|
||||
if G.has_edge(leaf, nb):
|
||||
coords = G[leaf][nb].get("coordinates")
|
||||
if coords is not None:
|
||||
walls.append(coords)
|
||||
|
||||
corners = _corners_of(leaf) # len==5, corners[4]=None
|
||||
|
||||
ib = geometry.is_between_2d # (point, pa, pb) — handles point=None
|
||||
|
||||
def _ib(point, pa, pb):
|
||||
if pa is None and pb is None:
|
||||
return True # Perl: is_between_2d(point,undef,undef) always True
|
||||
if pa is None or pb is None:
|
||||
return False
|
||||
return ib(point, pa, pb)
|
||||
|
||||
# Try single corner
|
||||
for idx in range(4):
|
||||
c = corners[idx]
|
||||
if all(ib(c, w[0], w[1]) for w in walls):
|
||||
return [idx]
|
||||
|
||||
# Try pair (idx, idx+1); corners[4]=None → _ib returns False
|
||||
for idx in range(4):
|
||||
c0, c1 = corners[idx], corners[idx + 1]
|
||||
ok = True
|
||||
for w in walls:
|
||||
if ib(c0, w[0], w[1]):
|
||||
continue
|
||||
if ib(c1, w[0], w[1]):
|
||||
continue
|
||||
if _ib(w[0], c0, c1):
|
||||
continue
|
||||
if _ib(w[1], c0, c1):
|
||||
continue
|
||||
ok = False
|
||||
break
|
||||
if ok:
|
||||
return [idx, idx + 1] # raw; may be [3,4]
|
||||
|
||||
# Try triple (idx, idx+1, idx+2); corners[4] and corners[5]=None → _ib False
|
||||
for idx in range(4):
|
||||
c0 = corners[idx]
|
||||
c1 = corners[idx + 1]
|
||||
c2 = corners[idx + 2] if idx + 2 < len(corners) else None
|
||||
ok = True
|
||||
for w in walls:
|
||||
if ib(c0, w[0], w[1]):
|
||||
continue
|
||||
if ib(c1, w[0], w[1]):
|
||||
continue
|
||||
if ib(c2, w[0], w[1]):
|
||||
continue
|
||||
if _ib(w[0], c0, c1):
|
||||
continue
|
||||
if _ib(w[1], c0, c1):
|
||||
continue
|
||||
if _ib(w[0], c1, c2):
|
||||
continue
|
||||
if _ib(w[1], c1, c2):
|
||||
continue
|
||||
ok = False
|
||||
break
|
||||
if ok:
|
||||
return [idx, idx + 1, idx + 2] # raw; may be [3,4,5]
|
||||
|
||||
return [0, 1, 2, 3]
|
||||
|
||||
|
||||
def _stack_levels_above(leaf: Node) -> list[Node]:
|
||||
"""Same-path nodes on all levels above leaf; mirrors ``Levels_Above`` on a leaf."""
|
||||
result: list[Node] = []
|
||||
n = leaf
|
||||
while True:
|
||||
above = dom._above_node(n)
|
||||
if above is None:
|
||||
break
|
||||
result.append(above)
|
||||
n = above
|
||||
return result
|
||||
|
||||
|
||||
def _ground_rotation(node: Node) -> int:
|
||||
"""Rotation of the lowest below node; mirrors Perl's ``Rotation()`` method.
|
||||
|
||||
Perl's ``Rotation`` returns ``$self->Below->Rotation`` when Below is
|
||||
defined, so upper-storey nodes always report the ground-floor rotation.
|
||||
Using the raw ``node.rotation`` (stored per-level) would give wrong
|
||||
rotation corrections in ``stack_corners_in_use``.
|
||||
"""
|
||||
while node.below is not None:
|
||||
node = node.below
|
||||
return node.rotation
|
||||
|
||||
|
||||
def stack_corners_in_use(
|
||||
leaf: Node,
|
||||
graph_circ_list: list[nx.Graph],
|
||||
all_levels: list[Node],
|
||||
) -> list[int]:
|
||||
"""Minimum set of corners in use for the vertical stair stack; mirrors
|
||||
``Urb::Dom::Stack_Corners_In_Use``.
|
||||
|
||||
Returns [] if the stack does not span all levels above leaf, or if any
|
||||
level's node is not circulation type.
|
||||
"""
|
||||
if not (leaf.type and leaf.type[0].lower() == "c"):
|
||||
return []
|
||||
|
||||
stack = [leaf] + _stack_levels_above(leaf)
|
||||
|
||||
# The stack must span ALL levels (leaf's level + all above)
|
||||
li = _level_index(leaf, all_levels)
|
||||
levels_above_count = len(all_levels) - li - 1
|
||||
if len(stack) <= levels_above_count:
|
||||
return []
|
||||
|
||||
# All stack nodes must be circulation
|
||||
if not all(n.type and n.type[0].lower() == "c" for n in stack):
|
||||
return []
|
||||
|
||||
leaf_rot = _ground_rotation(leaf)
|
||||
all_corners: set[int] = set()
|
||||
for level_offset, node in enumerate(stack):
|
||||
level_idx = li + level_offset
|
||||
if level_idx >= len(graph_circ_list):
|
||||
break
|
||||
G = graph_circ_list[level_idx]
|
||||
nbs = list(G.neighbors(node)) if G.has_node(node) else []
|
||||
in_use = corners_in_use(node, G, nbs)
|
||||
# Map to ground-floor rotation frame using ground rotation (Perl
|
||||
# Rotation() follows Below chain, so upper nodes use ground rotation)
|
||||
node_rot = _ground_rotation(node)
|
||||
for c in in_use:
|
||||
all_corners.add((c - node_rot + leaf_rot) % 4)
|
||||
|
||||
return sorted(all_corners)
|
||||
|
||||
|
||||
def _level_index(n: Node, lvls: list[Node]) -> int:
|
||||
"""Index of n's storey in ``lvls`` (0 = ground floor)."""
|
||||
lr = n
|
||||
while lr.parent is not None:
|
||||
lr = lr.parent
|
||||
return lvls.index(lr)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Adjacency helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -85,43 +423,62 @@ def has_vertical_connection(leaf: Node, target_code: str, lvls: list[Node]) -> b
|
|||
return any(bl.type and bl.type.lower().startswith(tc) for bl in below_root.leaves())
|
||||
|
||||
|
||||
def _level_index(n: Node, lvls: list[Node]) -> int:
|
||||
"""Index of n's storey in ``lvls`` (0 = ground floor)."""
|
||||
lr = n
|
||||
while lr.parent is not None:
|
||||
lr = lr.parent
|
||||
return lvls.index(lr)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Space-count detection (missing space list only; stacking is in homemaker-py-hgg)
|
||||
# Space-count detection + failure stacking (ProgrammeDriven.pm:154-215)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def find_missing_spaces(root: Node, targets: dict[str, SpaceReq]) -> list[str]:
|
||||
"""Return ids of missing required spaces; mirrors the count-detection
|
||||
part of ``check_space_counts`` in ``ProgrammeDriven.pm:156-215``.
|
||||
def check_space_counts(
|
||||
root: Node,
|
||||
targets: dict[str, SpaceReq],
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Check design has exactly the required spaces; mirrors
|
||||
``check_space_counts`` in ``ProgrammeDriven.pm:156-215``.
|
||||
|
||||
Only returns the missing-code list used by other checks to suppress false
|
||||
adjacency/level/vertical failures. The 2-base + per-quality failure
|
||||
stacking (up to ~7 failures per missing space) is implemented in
|
||||
homemaker-py-hgg.
|
||||
Returns ``(failures, missing_ids)`` where:
|
||||
- ``failures`` is the stacked failure list (2 base + per-quality per missing
|
||||
space, up to ~7 per missing space; also "too many" for excess spaces).
|
||||
- ``missing_ids`` is the list of virtual space ids used to suppress false
|
||||
adjacency/level/vertical failures for absent spaces.
|
||||
"""
|
||||
count: dict[str, int] = {}
|
||||
# Count spaces by type (case-sensitive, as in Perl exact-match for unique)
|
||||
count: dict[str, list[str]] = {}
|
||||
for lvl in levels(root):
|
||||
for leaf in lvl.leaves():
|
||||
if leaf.type:
|
||||
key = leaf.type.lower()
|
||||
count[key] = count.get(key, 0) + 1
|
||||
count.setdefault(leaf.type, []).append(leaf.id)
|
||||
|
||||
failures: list[str] = []
|
||||
missing: list[str] = []
|
||||
|
||||
for code, req in targets.items():
|
||||
if code[0].lower() in ("c", "o", "s"):
|
||||
continue
|
||||
actual = count.get(code.lower(), 0)
|
||||
if actual < req.count:
|
||||
for i in range(req.count - actual):
|
||||
missing.append(code if req.count == 1 else f"{code}#{i + 1}")
|
||||
return missing
|
||||
|
||||
actual = len(count.get(code, []))
|
||||
expected = req.count
|
||||
|
||||
if actual < expected:
|
||||
n_missing = expected - actual
|
||||
for i in range(1, n_missing + 1):
|
||||
mid = code if expected == 1 else f"{code}#{i}"
|
||||
# 2 base failures
|
||||
failures.append(f"missing required space: {mid}")
|
||||
failures.append(f"missing required space: {mid} (critical)")
|
||||
missing.append(mid)
|
||||
# Per-quality failures (1 each for explicitly configured params)
|
||||
if req.has_size:
|
||||
failures.append(f"missing {mid}: would need size check")
|
||||
if req.has_width:
|
||||
failures.append(f"missing {mid}: would need width check")
|
||||
if req.has_proportion:
|
||||
failures.append(f"missing {mid}: would need proportion check")
|
||||
|
||||
elif actual > expected:
|
||||
failures.append(
|
||||
f"too many spaces: {code} (found {actual}, expected {expected})"
|
||||
)
|
||||
|
||||
return failures, missing
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ class SpaceReq:
|
|||
level: int | None = None
|
||||
requires_below: str | None = None
|
||||
count: int = 1
|
||||
# Whether each quality param was explicitly in the config (not a default)
|
||||
has_size: bool = False
|
||||
has_width: bool = False
|
||||
has_proportion: bool = False
|
||||
|
||||
|
||||
def _pair(d: dict, key: str, default: tuple[float, float]) -> tuple[float, float]:
|
||||
|
|
@ -60,5 +64,8 @@ def load_programme(path: str) -> dict[str, SpaceReq]:
|
|||
level=c.get("level"),
|
||||
requires_below=c.get("requires_below"),
|
||||
count=int(c.get("count") or 1),
|
||||
has_size="size" in c,
|
||||
has_width="width" in c,
|
||||
has_proportion="proportion" in c,
|
||||
)
|
||||
return out
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue