"""Native port of Urb's programme-driven fitness: leaf quality terms + cost model. Scope (homemaker-py-gnw): per-leaf quality factors (perpendicular, proportion, size, width, crinkliness, daylight, access), the programme-driven parameter lookup chain (``get_space_params``), value rates, and the cost denominator (per-leaf area costs, interior/exterior wall edge costs, boundary costs). Storey/building checks, staircases, failure stacking and final assembly are homemaker-py-hgg; corpus-parity validation is homemaker-py-uxz. Source of truth: ``Urb::Dom::Fitness::{Base,Leaf,Storey,ProgrammeDriven}``. DESCOPE (DESIGN.md §6, decision 2026-06-12): this ports *simple* crinkliness — the CIEsky illumination factor is pinned to 1, exactly what Urb computes under ``URB_NO_OCCLUSION=1``. ``quality_daylight`` is likewise pinned to 1. Parity targets the *flagged* oracle, never stock Urb. Call ``dom.merge_divided(root)`` and rebuild graphs before ``process_storey`` — storey processing runs on the MERGED tree (two-phase pattern, see graph.py). """ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path import networkx as nx import yaml from . import dom as dom_mod from . import geometry from .dom import Node FAIL_THRESHOLD = 0.1 # Urb::Dom::Fitness::Base # Per-leaf quality factors that emit a failure when they drop below # FAIL_THRESHOLD (evaluate_leaf, in emission order). The graded objective # (DESIGN.md §11.4) reads each failing factor's value as a continuous proximity # to satisfaction — it does NOT change the scalar fitness or the fail count, only # supplies a tie/secondary signal to the outer comparator (driver.py). _GRADED_FACTORS = ("perpendicular", "proportion", "size", "width", "crinkliness", "access") def _leaf_grade(factors: dict[str, float]) -> float: """Proximity credit for one leaf's *failing* quality factors. Each factor below FAIL_THRESHOLD contributes ``f / FAIL_THRESHOLD`` ∈ [0, 1): deeper failures score ~0, near-threshold failures score ~1. Summing this over all failing factors gives a continuous proximity signal. Passing factors contribute nothing — the signal lives entirely in the failing set — and structural/binary fails (missing, adjacency, edge-too-long, …) contribute 0, so the measure can never reward dropping a required room (§6 preserved). Intended as an outer-comparator secondary key, but REJECTED as such (DESIGN.md §11.4): within a fixed fail-tier the scalar fitness is not flat, so this added no benefit. Kept for reproducibility / possible reuse as a diversity signal. """ g = 0.0 for name in _GRADED_FACTORS: fv = factors.get(name, 1.0) if fv < FAIL_THRESHOLD: g += fv / FAIL_THRESHOLD return g # Urb::Dom::Fitness::Base $CONF — keep values byte-identical to the Perl # expressions (5.0/6 etc. evaluate to the same IEEE doubles in both languages). CONF_DEFAULTS: dict = { "value_inside": 300.0, "value_circulation": 50.0, "value_outside": 100.0, "value_supported": 300.0, "storey_limit": 4, "storey_minimum": 2, "latitude": 53.3814, "door_width": 1.2, "plot_ratio": [2.00, 0.50], "ratio_outside": [0.33, 0.15], "ratio_circulation": [0.00, 0.20], "uncrinkliness": [5.0 / 6, 1.1 / 3], "uncrinkliness_circulation": [5.0 / 6, 1.1 / 3], "size_circulation": [0.0, 14.0], "size_inside": [16.0, 3.5], "proportion_outside": [1.5, 50], "proportion_circulation": [1.5, 0.5], "proportion_inside": [1.5, 0.5], "width_outside": [3.0, 0.3], "width_circulation": [2.4, 0.2], "width_inside": [4.0, 1.0], "perpendicular_inside": 0.3, "perpendicular_outside": 10.0, "allow_sahn_circulation": 0, "force_roof_garden": 1, "evaluate_room_types": 1, } # Urb::Dom::Fitness::Base $COST COST_DEFAULTS: dict = { "plot": 10.0, "outside_covered_supported": 210.0, "outside_covered": 110.0, "outside_supported": 110.0, "outside": 10.0, "inside": 200.0, "interior_wall": 200.0 / 3, "exterior_wall": 100.0, "boundary": 50.0 / 3, "boundary_wall": 400.0 / 3, } # ProgrammeDriven::default_params ultimate fallbacks _PARAM_FALLBACKS = { "size": [16.0, 3.5], "width": [4.0, 1.0], "proportion": [1.5, 0.5], } _E = 2.718281828 # Urb::Math::gaussian uses this truncated e, not math.e def gaussian(x: float, a: float, b: float, c: float) -> float: """Bit-faithful port of ``Urb::Math::gaussian`` (note the truncated e).""" return a * (_E ** (0 - ((x - b) ** 2 / (2 * c * c)))) def load_config(directory: str | Path) -> tuple[dict, dict]: """Load (patterns, costs) config for a corpus directory, mirroring ``urb-fitness.pl``: project-level ``../.config`` first, then the local file's keys override it.""" directory = Path(directory) conf: dict = {} cost: dict = {} for target, name in ((conf, "patterns.config"), (cost, "costs.config")): for p in (directory.parent / name, directory / name): if p.is_file(): with open(p) as fh: target.update(yaml.safe_load(fh) or {}) return conf, cost @dataclass class LeafEval: level: int id: str type: str area: float rate: float quality: float factors: dict[str, float] = field(default_factory=dict) @dataclass class StoreyEval: cost: float value: float leaves: list[LeafEval] = field(default_factory=list) def _t0(n: Node) -> str: """First char of the type, lowercased ('' if untyped) — Urb's /^x/i tests.""" return n.type[0].lower() if n.type else "" def _height(n: Node) -> float: """Floor-to-floor height of n's level; mirrors ``Urb::Quad::Height``.""" h = dom_mod._level_root(n).height return h if h is not None else 3.0 def _perimeter(n: Node) -> dict: """Perimeter dict from the lowest level root (``Urb::Quad::Perimeter``).""" lr = dom_mod._level_root(n) while lr.below is not None: lr = lr.below return lr.perimeter or {} class Fitness: """Programme-driven leaf quality + cost evaluation. ``conf`` is the parsed patterns.config mapping (including ``spaces``); ``cost`` the costs.config mapping. Lookup falls back to the Base.pm defaults, as ``Urb::Dom::Fitness::Base::Conf/Cost`` do. """ def __init__(self, conf: dict | None = None, cost: dict | None = None): 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) # erc.3 leaf-sharing (DESIGN.md §13.3): default OFF. When on, a leaf sized # to k×target counts as k same-code rooms (count check + size centring). self._leaf_sharing = bool(self.conf("leaf_sharing")) self._max_share = int(self.conf("leaf_share_max") or 4) def conf(self, key: str): v = self._conf.get(key) if v is not None: return v return CONF_DEFAULTS.get(key) def cost(self, key: str) -> float: v = self._cost.get(key) if v is not None: return v return COST_DEFAULTS.get(key, 0.0) def preprocess_building(self, root: Node) -> None: """Sahn-to-Outside type conversion (``Building.pm::preprocess_building``). Run BEFORE graph build and merge_divided — it changes merge outcomes.""" if self.conf("allow_sahn_circulation"): return for lvl in dom_mod.levels(root): for leaf in lvl.leaves(): if _t0(leaf) == "s": leaf.type = "O" # ------------------------------------------------------------------ # # Programme-driven parameter lookup (ProgrammeDriven.pm:29-69) # ------------------------------------------------------------------ # def get_space_params(self, code: str, param: str) -> list[float]: c0 = code[0].lower() if code else "" if c0 == "c": v = self.conf(f"{param}_circulation") if v is not None: return v if c0 in ("o", "s"): v = self.conf(f"{param}_outside") if v is not None: return v sp = self.spaces.get(code) # exact-key match, as in Perl if sp is not None and param in sp: return sp[param] if param == "width" and sp is not None: # Derive a sane width from size and proportion rather than # falling back to width_inside [4.0, 1.0], which is impossible # for small programme spaces (e.g. a 3 m² WC). size = sp.get("size") or self.conf("size_inside") or _PARAM_FALLBACKS["size"] proportion = sp.get("proportion") or self.conf("proportion_inside") or _PARAM_FALLBACKS["proportion"] target = (size[0] / proportion[0]) ** 0.5 sigma = max(0.1, target * size[1] / (2.0 * size[0])) return [target, sigma] v = self.conf(f"{param}_inside") if v is not None: return v return _PARAM_FALLBACKS.get(param) # ------------------------------------------------------------------ # # Quality terms (Leaf.pm) # ------------------------------------------------------------------ # def quality_perpendicular(self, leaf: Node) -> float: sigma = self.conf( "perpendicular_outside" if dom_mod.is_outside(leaf) else "perpendicular_inside" ) score = 1.0 for i in range(4): # 1.570796: Urb::Dom::Perpendicular hard-codes this, not pi/2 score *= gaussian(geometry.angle(leaf, i), 1.0, 1.570796, sigma) return score def quality_proportion(self, leaf: Node) -> float: t0 = _t0(leaf) if t0 in ("o", "s"): params = self.conf("proportion_outside") elif t0 == "c": params = self.conf("proportion_circulation") else: params = self.get_space_params(leaf.type, "proportion") aspect = geometry.aspect(leaf) if aspect < params[0]: return 1.0 return gaussian(aspect, 1.0, params[0], params[1]) def quality_size(self, leaf: Node) -> float: t0 = _t0(leaf) if t0 in ("o", "s"): return 1.0 if t0 == "c": params = self.conf("size_circulation") else: params = self.get_space_params(leaf.type, "size") target, sigma = params[0], params[1] if self._leaf_sharing and t0 != "c" and target > 0: # erc.3: a shared leaf holds k same-code rooms; centre the Gaussian on # k×target (k = leaf's explicit, type-guarded share) and scale sigma by # k so the *fractional* size tolerance is preserved. An undersize # shared leaf now lands a (light) size fail here instead of a (heavy) # missing fail in the count check — the §13.3 leak fix. from . import graph as _graph k = _graph.leaf_share(leaf, self._max_share) if k > 1: target, sigma = target * k, sigma * k return gaussian(geometry.area(leaf), 1.0, target, sigma) def quality_width(self, leaf: Node) -> float: t0 = _t0(leaf) if ( t0 in ("o", "s") and not dom_mod.is_covered(leaf) and not dom_mod.is_supported(leaf) and dom_mod.level_of(leaf) ): return 1.0 if t0 in ("o", "s"): params = self.conf("width_outside") elif t0 == "c": params = self.conf("width_circulation") else: params = self.get_space_params(leaf.type, "width") width = geometry.length_narrowest(leaf) if width > params[0]: return 1.0 return gaussian(width, 1.0, params[0], params[1]) # --- simple crinkliness (URB_NO_OCCLUSION: illumination factor = 1) --- # def area_outside(self, leaf: Node, G: nx.Graph, groups: dict) -> float: """Illuminated external wall area; ``Urb::Dom::Area_Outside`` with the CIEsky illumination factor pinned to 1 (simple crinkliness).""" length = 0.0 for nb in G.neighbors(leaf): if not dom_mod.is_outside(nb) or dom_mod.is_covered(nb): continue # Faithful loop over all internal boundaries: Overlap() is > 0 # only on a boundary both quads actually share an edge of. for contributors in groups.values(): if geometry.boundary_pair_overlap(contributors, leaf, nb) > 0: length += G[leaf][nb]["width"] perimeter = _perimeter(leaf) for e in range(4): bid = geometry.boundary_id(leaf, e) if bid not in geometry._EXTERNAL: continue ptype = (perimeter.get(bid) or "").lower() if ptype in ("private", "fortified"): continue length += geometry.edge_length(leaf, e) return length * _height(leaf) def crinkliness(self, leaf: Node, G: nx.Graph, groups: dict) -> float: area = geometry.area(leaf) if not area: return 9999999999 return self.area_outside(leaf, G, groups) / area def quality_uncrinkliness(self, leaf: Node, G: nx.Graph, groups: dict) -> float: if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf): return 1.0 key = "uncrinkliness_circulation" if dom_mod.is_circulation(leaf) else "uncrinkliness" distance, sigma = self.conf(key) crink = self.crinkliness(leaf, G, groups) if not crink: return 0.0 return gaussian(1 / crink, 1.0, distance, sigma) # --- access --- # def neighbour_types(self, leaf: Node, G: nx.Graph) -> list[str]: return sorted(nb.type or "" for nb in G.neighbors(leaf) if dom_mod.is_usable(nb)) def access(self, leaf: Node, G: nx.Graph) -> list[str]: """Useful circulation/access neighbour types; ``Urb::Dom::Access``.""" types = self.neighbour_types(leaf, G) if _t0(leaf) == "k": return [t for t in types if t and t[0].lower() in ("l", "c", "s")] if dom_mod.is_outside(leaf) or dom_mod.is_circulation(leaf): return types return [t for t in types if t and t[0].lower() in ("c", "s")] # ------------------------------------------------------------------ # # Leaf evaluation (Leaf.pm::evaluate_leaf) # ------------------------------------------------------------------ # def evaluate_leaf( self, leaf: Node, G: nx.Graph, level_id: int, groups: dict, fail ) -> tuple[float, dict[str, float]]: """Return (quality, per-factor dict); appends failures via ``fail``. Factor order and fail strings mirror ``evaluate_leaf`` exactly. """ lid = leaf.id factors: dict[str, float] = {} quality = 1.0 f = self.quality_perpendicular(leaf) if f < FAIL_THRESHOLD: fail(f"{level_id}/{lid} perpendicular") factors["perpendicular"] = f quality *= f f = self.quality_proportion(leaf) if f < FAIL_THRESHOLD: fail(f"{level_id}/{lid} proportion") factors["proportion"] = f quality *= f f = self.quality_size(leaf) if f < FAIL_THRESHOLD: fail(f"{level_id}/{lid} size") factors["size"] = f quality *= f f = self.quality_width(leaf) if f < FAIL_THRESHOLD: fail(f"{level_id}/{lid} width") factors["width"] = f quality *= f f = self.quality_uncrinkliness(leaf, G, groups) if f < FAIL_THRESHOLD: fail(f"{level_id}/{lid} crinkliness") factors["crinkliness"] = f quality *= f # Daylight pinned to 1 — URB_NO_OCCLUSION semantics (DESIGN.md §6). factors["daylight"] = 1.0 if len(self.access(leaf, G)) > 0: f = 1.0 elif not dom_mod.level_of(leaf) and dom_mod.is_outside(leaf): f = 1.0 else: f = 0.01 fail(f"{level_id}/{lid} access") factors["access"] = f quality *= f return quality, factors # ------------------------------------------------------------------ # # Value rates and costs (Leaf.pm:146-251, Storey.pm:122-147) # ------------------------------------------------------------------ # def value_rate(self, leaf: Node) -> float: t0 = _t0(leaf) if t0 in ("o", "s") and dom_mod.level_of(leaf) == 0: return self.conf("value_outside") if t0 in ("o", "s"): return self.conf("value_supported") if t0 == "c": return self.conf("value_circulation") return self.conf("value_inside") def leaf_cost(self, leaf: Node) -> float: if dom_mod.is_outside(leaf): covered = dom_mod.is_covered(leaf) supported = dom_mod.is_supported(leaf) if covered and supported: rate = self.cost("outside_covered_supported") elif covered: rate = self.cost("outside_covered") elif supported: rate = self.cost("outside_supported") else: rate = self.cost("outside") else: rate = self.cost("inside") return rate * geometry.area(leaf) def edge_cost(self, G: nx.Graph, a: Node, b: Node, fail) -> float: """Interior/exterior wall cost for one graph edge (``Storey.pm::calculate_edge_cost``).""" height = _height(a) a_out, b_out = dom_mod.is_outside(a), dom_mod.is_outside(b) if a_out and b_out: rate = 0.0 elif not a_out and not b_out: rate = self.cost("interior_wall") else: rate = self.cost("exterior_wall") width = G[a][b]["width"] if width > 8.0 and rate > 0.0: fail(f"{dom_mod.level_of(a)}/{a.id} {b.id} edge too long") return rate * width * height def outside_edge_cost(self, leaf: Node, fail) -> float: """Plot-boundary cost for a leaf's external edges (``Leaf.pm::calculate_outside_edge_cost``).""" rate = self.cost("boundary") if dom_mod.is_outside(leaf) else self.cost("boundary_wall") length = 0.0 for e in range(4): if geometry.boundary_id(leaf, e) not in geometry._EXTERNAL: continue edge_len = geometry.edge_length(leaf, e) length += edge_len if dom_mod.is_outside(leaf): continue if edge_len > 8.0: fail(f"{dom_mod.level_of(leaf)}/{leaf.id} outside edge too long") return rate * length * _height(leaf) # ------------------------------------------------------------------ # # Storey processing (Storey.pm::process_storey — cost/value/leaf scope) # ------------------------------------------------------------------ # def process_storey(self, level_root: Node, G: nx.Graph, level_id: int, fail) -> StoreyEval: """Per-storey cost, value and leaf evaluations on the MERGED tree. Covers the cost/value accumulation and per-leaf checks of ``process_storey``; circulation connectivity, roof-garden, stair fit and tracking-driven building checks are homemaker-py-hgg. """ groups = geometry.boundary_groups(level_root) cost = 0.0 value = 0.0 leaves_eval: list[LeafEval] = [] 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 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, ) ) 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) return StoreyEval(cost=cost, value=value, leaves=leaves_eval) 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 # If the stair itself has via-outdoor access (Entrances priority 3.5), Perl's # Entrances maps it to a leaf id, not a boundary id. Boundary_Id(edge) eq # leaf_id never matches → no entrance corners added. Return None here so # Python matches that behaviour. for nb in G.neighbors(stair_leaf): 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). """ score, _, _ = self._evaluate_full(root) return score def score_with_fails(self, root: Node) -> tuple[float, tuple[str, ...]]: """Same as ``evaluate`` but also returns the sorted failure strings.""" score, fails, _ = self._evaluate_full(root) return score, fails def score_with_grade( self, root: Node ) -> tuple[float, tuple[str, ...], float]: """``score_with_fails`` plus the graded proximity scalar (§11.4). The grade is a continuous secondary signal for the outer comparator only; it leaves ``score`` and the fail count untouched (and so the inner-loop 0.5^n cliff protection, §5.4, intact). """ return self._evaluate_full(root, want_grade=True) def _evaluate_full( self, root: Node, want_grade: bool = False ) -> tuple[float, tuple[str, ...], float]: from . import graph as graph_mod 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, self._leaf_sharing, self._max_share) 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 grade = 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 if want_grade: # §11.4 outer-comparator signal only; off by default for le in se.leaves: grade += _leaf_grade(le.factors) building_factor = self.evaluate_building(root, tracking) value *= building_factor # 0.5^n failure penalty (programme-driven mode, not 0.1^n) value *= 0.5 ** len(failures) score = value / cost if cost != 0.0 else 0.0 return score, tuple(sorted(failures)), grade @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