homemaker-layout/src/homemaker_layout/graph.py

596 lines
22 KiB
Python
Raw Normal View History

"""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.
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
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.
"""
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
# --------------------------------------------------------------------------- #
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())
# --------------------------------------------------------------------------- #
# Space-count detection + failure stacking (ProgrammeDriven.pm:154-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``.
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 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:
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 = 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
# --------------------------------------------------------------------------- #
# 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