239 lines
8.6 KiB
Python
239 lines
8.6 KiB
Python
|
|
"""Leaf-adjacency graph build and pre-merge checks for programme-driven fitness.
|
||
|
|
|
||
|
|
TWO-PHASE PATTERN (ProgrammeDriven.pm:83-103):
|
||
|
|
1. ``build_graphs(root)`` on the UNMERGED tree → graph_base
|
||
|
|
2. Run adjacency / level / vertical checks using graph_base and the unmerged tree.
|
||
|
|
3. ``dom.merge_divided(root)`` — mutates the tree in place.
|
||
|
|
4. ``build_graphs(root)`` again on the MERGED tree for storey processing.
|
||
|
|
|
||
|
|
FIDELITY DECISION — ``has_vertical_connection`` (DESIGN.md §8.1):
|
||
|
|
Ported faithfully including the no-spatial-overlap stub from
|
||
|
|
ProgrammeDriven.pm:399-423. Any leaf of the target type on the level below
|
||
|
|
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.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import networkx as nx
|
||
|
|
|
||
|
|
from . import dom, geometry
|
||
|
|
from .dom import Node, levels
|
||
|
|
from .programme import SpaceReq
|
||
|
|
|
||
|
|
DOOR_WIDTH = 1.2 # Urb::Dom::Fitness::Base default_params door_width
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Graph build
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
def build_graphs(root: Node, door_width: float = DOOR_WIDTH) -> list[nx.Graph]:
|
||
|
|
"""Return one ``nx.Graph`` per storey (lowest first); mirrors
|
||
|
|
``setup_storey_graphs`` in ``Urb::Dom::Fitness::Base``.
|
||
|
|
|
||
|
|
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.
|
||
|
|
"""
|
||
|
|
return [geometry.leaf_graph(lvl, door_width) for lvl in levels(root)]
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Adjacency helpers
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
def has_adjacency(leaf: Node, target_code: str, G: nx.Graph) -> bool:
|
||
|
|
"""True if ``leaf`` (or its nearest graphed ancestor) has a neighbour whose
|
||
|
|
type matches ``^target_code`` (case-insensitive prefix); mirrors
|
||
|
|
``ProgrammeDriven.pm::has_adjacency``.
|
||
|
|
|
||
|
|
Walking up to the nearest graphed ancestor handles merged nodes that no
|
||
|
|
longer appear as individual vertices in a post-merge graph.
|
||
|
|
"""
|
||
|
|
node: Node | None = leaf
|
||
|
|
while node is not None and not G.has_node(node):
|
||
|
|
node = node.parent
|
||
|
|
if node is None:
|
||
|
|
return False
|
||
|
|
tc = target_code.lower()
|
||
|
|
for nb in G.neighbors(node):
|
||
|
|
if nb.type and nb.type.lower().startswith(tc):
|
||
|
|
return True
|
||
|
|
# neighbour might be a merged branch — check its leaves
|
||
|
|
for nl in (nb.leaves() if nb.divided else []):
|
||
|
|
if nl.type and nl.type.lower().startswith(tc):
|
||
|
|
return True
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
def has_vertical_connection(leaf: Node, target_code: str, lvls: list[Node]) -> bool:
|
||
|
|
"""True if any leaf on the level directly below has type matching
|
||
|
|
``^target_code`` (case-insensitive); mirrors
|
||
|
|
``ProgrammeDriven.pm::has_vertical_connection``.
|
||
|
|
|
||
|
|
FAITHFUL STUB — no spatial-overlap check (ProgrammeDriven.pm:399-423 bug).
|
||
|
|
See module docstring for the fidelity decision.
|
||
|
|
"""
|
||
|
|
li = _level_index(leaf, lvls)
|
||
|
|
if li == 0:
|
||
|
|
return False
|
||
|
|
below_root = lvls[li - 1]
|
||
|
|
tc = target_code.lower()
|
||
|
|
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)
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
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``.
|
||
|
|
|
||
|
|
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.
|
||
|
|
"""
|
||
|
|
count: dict[str, int] = {}
|
||
|
|
for lvl in levels(root):
|
||
|
|
for leaf in lvl.leaves():
|
||
|
|
if leaf.type:
|
||
|
|
key = leaf.type.lower()
|
||
|
|
count[key] = count.get(key, 0) + 1
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Pre-merge checks
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
def check_adjacency(
|
||
|
|
root: Node,
|
||
|
|
targets: dict[str, SpaceReq],
|
||
|
|
graph_base: list[nx.Graph],
|
||
|
|
missing: list[str],
|
||
|
|
) -> list[str]:
|
||
|
|
"""Adjacency check failures; mirrors
|
||
|
|
``check_adjacency_requirements`` in ``ProgrammeDriven.pm:218-278``.
|
||
|
|
|
||
|
|
Run on the UNMERGED tree with the pre-merge ``graph_base``.
|
||
|
|
"""
|
||
|
|
lvls = levels(root)
|
||
|
|
missing_set = set(missing)
|
||
|
|
failures: list[str] = []
|
||
|
|
seen: set[tuple] = set() # dedup per (leaf.id, code, adj_code) like Perl
|
||
|
|
|
||
|
|
for code, req in targets.items():
|
||
|
|
if not req.adjacency:
|
||
|
|
continue
|
||
|
|
any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set)
|
||
|
|
if any_missing:
|
||
|
|
for adj_code in req.adjacency:
|
||
|
|
failures.append(f"missing {code}: would need adjacency to {adj_code}")
|
||
|
|
continue
|
||
|
|
|
||
|
|
for lvl in lvls:
|
||
|
|
li = lvls.index(lvl)
|
||
|
|
for leaf in lvl.leaves():
|
||
|
|
if leaf.type != code:
|
||
|
|
continue
|
||
|
|
G = graph_base[li]
|
||
|
|
for adj_code in req.adjacency:
|
||
|
|
key = (leaf.id, *sorted((code, adj_code)))
|
||
|
|
if key in seen:
|
||
|
|
continue
|
||
|
|
seen.add(key)
|
||
|
|
if not has_adjacency(leaf, adj_code, G):
|
||
|
|
failures.append(
|
||
|
|
f"{li}/{leaf.id} ({code}) not adjacent to {adj_code}"
|
||
|
|
)
|
||
|
|
return failures
|
||
|
|
|
||
|
|
|
||
|
|
def check_level_constraints(
|
||
|
|
root: Node,
|
||
|
|
targets: dict[str, SpaceReq],
|
||
|
|
missing: list[str],
|
||
|
|
) -> list[str]:
|
||
|
|
"""Level constraint failures; mirrors
|
||
|
|
``check_level_constraints`` in ``ProgrammeDriven.pm:319-358``.
|
||
|
|
"""
|
||
|
|
lvls = levels(root)
|
||
|
|
missing_set = set(missing)
|
||
|
|
failures: list[str] = []
|
||
|
|
|
||
|
|
for code, req in targets.items():
|
||
|
|
if req.level is None:
|
||
|
|
continue
|
||
|
|
any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set)
|
||
|
|
if any_missing:
|
||
|
|
failures.append(f"missing {code}: would need to be on level {req.level}")
|
||
|
|
continue
|
||
|
|
|
||
|
|
for lvl in lvls:
|
||
|
|
li = lvls.index(lvl)
|
||
|
|
for leaf in lvl.leaves():
|
||
|
|
if leaf.type != code:
|
||
|
|
continue
|
||
|
|
if li != req.level:
|
||
|
|
failures.append(
|
||
|
|
f"{code} on wrong level (level {li}, expected {req.level})"
|
||
|
|
)
|
||
|
|
return failures
|
||
|
|
|
||
|
|
|
||
|
|
def check_vertical_connectivity(
|
||
|
|
root: Node,
|
||
|
|
targets: dict[str, SpaceReq],
|
||
|
|
missing: list[str],
|
||
|
|
) -> list[str]:
|
||
|
|
"""Vertical connectivity failures; mirrors
|
||
|
|
``check_vertical_connectivity_requirements`` in ``ProgrammeDriven.pm:360-397``.
|
||
|
|
|
||
|
|
Uses the faithful no-overlap stub; see ``has_vertical_connection``.
|
||
|
|
"""
|
||
|
|
lvls = levels(root)
|
||
|
|
missing_set = set(missing)
|
||
|
|
failures: list[str] = []
|
||
|
|
|
||
|
|
for code, req in targets.items():
|
||
|
|
if req.requires_below is None:
|
||
|
|
continue
|
||
|
|
any_missing = any(m == code or m.startswith(f"{code}#") for m in missing_set)
|
||
|
|
if any_missing:
|
||
|
|
failures.append(
|
||
|
|
f"missing {code}: would need connection to {req.requires_below} below"
|
||
|
|
)
|
||
|
|
continue
|
||
|
|
|
||
|
|
for lvl in lvls:
|
||
|
|
for leaf in lvl.leaves():
|
||
|
|
if leaf.type != code:
|
||
|
|
continue
|
||
|
|
if not has_vertical_connection(leaf, req.requires_below, lvls):
|
||
|
|
failures.append(
|
||
|
|
f"{code} not connected to {req.requires_below} below"
|
||
|
|
)
|
||
|
|
return failures
|