Owner's ruling. Measured over the twelve baseline layouts as realised value per m2 (rate x quality, not the rate alone): as shipped before this room 67.0 terrace 294.6 violates, 4.39x value_supported=100 only room 67.0 terrace 98.2 violates, 1.46x geometric mean only room 132.9 terrace 296.8 violates, 2.23x both room 132.9 terrace 98.9 satisfies The 4.4x is roughly 2.2x aggregation and 2.0x rate, so neither half alone is enough. That is why 39.18's geometric-mean aggregation moves from default-OFF to default-ON here rather than waiting on its own A/B: it is not an optional improvement, it is half of a ruling. value_supported 300 -> 100, and set to value_outside rather than to a number that makes the inequality come out -- back-solving from the corpus's measured mean room quality would rot the moment either changed. Outdoor space is worth the same to an occupant whatever level it sits on; the real difference between a ground garden and a roof terrace is what it takes to BUILD, and cost already says that (outside 10.0 vs outside_supported 110.0). Value describes worth, cost describes structure, and the level belongs in the second. Changed in CONF_DEFAULTS and the four corpus patterns.config files, which all declared 300.0 explicitly. NOT changed in harbor-house-l0 (a shape-curve test fixture) or y51-sweep-* (historical fixtures that exist to reproduce past measurements) -- repricing those would destroy what they are for. Neither change can move a fail, structurally rather than luckily: value rates never enter fail emission, and evaluate_leaf emits each fail from its factor before anything is combined. Verified corpus-wide: identical fail sets, scores +11% to +169% (and -5% once, on a layout that is mostly terrace). tests/test_terrace_value_ruling.py pins the ruling as an invariant of the objective, and asserts that reverting the aggregation breaks it again, so neither half can be quietly dropped. The 500k cold-start baseline (39.12) is superseded -- this changes what "good" means. The layouts stay valid and their fail counts are unchanged, but a fresh corpus run is needed before any new number is compared with them. Still untouched: circulation returns 0.07 per unit cost against a room's 0.66, by far the worst thing a building can contain. That is homemaker-py-hxi. Refs homemaker-py-ecx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ84Feep79Hhm3E4zZJmnB
2267 lines
102 KiB
Python
2267 lines
102 KiB
Python
"""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
|
||
|
||
import functools
|
||
import math
|
||
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 . import programme as _programme
|
||
from .dom import Node
|
||
|
||
FAIL_THRESHOLD = 0.1 # Urb::Dom::Fitness::Base
|
||
_MISSING = object() # tells an absent config key from one declared null
|
||
|
||
# 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
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Hard/soft fail tiering (homemaker-py-2g7.3, DESIGN.md §37)
|
||
# --------------------------------------------------------------------------- #
|
||
# HARD: the design lacks a required structural provision (a space, a level
|
||
# placement, a connectivity path, a stair, weather-tight cover) that no amount
|
||
# of ratio-only (shape) optimisation within the CURRENT topology can supply —
|
||
# fixing it needs a topology mutation (add/remove/retype/reconnect a node).
|
||
# These are graph.py's structural check_* fails plus the count/coverage fails
|
||
# fitness.py emits at the storey/building level (stairs, storey limits, public
|
||
# access, covered-outside support).
|
||
#
|
||
# SOFT: a continuous per-leaf/edge shape or quality metric — evaluate_leaf's
|
||
# perpendicular/proportion/size/width/crinkliness/access factors, wall/edge
|
||
# length caps, stair-fit volume — that the inner-loop ratio solve can, in
|
||
# principle, improve without changing the tree. "access" sits here (not with
|
||
# graph.py's structural adjacency checks) because it is computed exactly like
|
||
# proportion/crinkliness — a per-leaf continuous factor thresholded in
|
||
# evaluate_leaf — and _GRADED_FACTORS already groups it with the shape family.
|
||
#
|
||
# New fail strings MUST be added to one of these tuples — classify_fail_tier
|
||
# raises on anything unrecognised rather than silently defaulting a tier
|
||
# (homemaker-py-2g7.3 acceptance criteria).
|
||
_HARD_FAIL_MARKERS = (
|
||
"missing required space",
|
||
"too many spaces",
|
||
"would need", # missing-space cascade placeholders (size/width/proportion/
|
||
# adjacency/level/connection-below checks for an absent space)
|
||
"not adjacent to",
|
||
"on wrong level",
|
||
"not connected to", # vertical/stair connectivity to the level below
|
||
"not connected", # level circulation connectivity
|
||
"inaccessible usable space", # has_circulation disconnected a level (graph.py)
|
||
"no outside space",
|
||
"unsupported covered outside",
|
||
"covered outside above ground",
|
||
"too few stairs",
|
||
"too many stairs",
|
||
"storey limit",
|
||
"storey minimum",
|
||
"no outside public access",
|
||
)
|
||
|
||
_SOFT_FAIL_MARKERS = (
|
||
" perpendicular",
|
||
" proportion",
|
||
" size",
|
||
" width",
|
||
" crinkliness",
|
||
" access",
|
||
"edge too long",
|
||
"staircase volume",
|
||
)
|
||
|
||
|
||
# homemaker-py-2v1 (DESIGN.md §39.8) — the fails that punish severing a level's
|
||
# circulation. These are the ONLY counter-pressure against a structural x6 gain:
|
||
# deleting a circulation leaf merges it into its sibling, converting corridor
|
||
# into habitable area, and value_inside/value_circulation is 300/50.
|
||
_CONNECTIVITY_FAIL_MARKERS = ("not connected", "inaccessible usable space")
|
||
|
||
|
||
def is_connectivity_fail(fail: str) -> bool:
|
||
"""True for a level-connectivity failure (``level N not connected`` /
|
||
``N inaccessible usable space``)."""
|
||
return any(m in fail for m in _CONNECTIVITY_FAIL_MARKERS)
|
||
|
||
|
||
def connectivity_weight_for(value_inside: float, value_circulation: float) -> float:
|
||
"""Smallest integer weight at which severing circulation is net-NEGATIVE.
|
||
|
||
Merging a circulation leaf into a habitable sibling multiplies value by
|
||
``value_inside / value_circulation`` (x6 at the defaults). One failure costs
|
||
x0.5. So the penalty only outweighs the gain once
|
||
``0.5**w < value_circulation / value_inside``, i.e.
|
||
``w > log(vc/vi) / log(0.5)`` — 2.58 at the defaults, hence 3.
|
||
|
||
Derived from the value rates rather than hard-coded, so the two stay in step
|
||
if either rate is ever retuned.
|
||
"""
|
||
if value_inside <= 0 or value_circulation <= 0:
|
||
return 1.0
|
||
ratio = value_circulation / value_inside
|
||
if ratio >= 1.0: # circulation already worth as much
|
||
return 1.0
|
||
return float(math.ceil(math.log(ratio) / math.log(0.5)))
|
||
|
||
|
||
def classify_fail_tier(fail: str) -> str:
|
||
"""Return ``"hard"`` or ``"soft"`` for one failure string.
|
||
|
||
Checks ``_HARD_FAIL_MARKERS`` before ``_SOFT_FAIL_MARKERS`` so cascade
|
||
placeholders like "missing k1: would need size check" (a missing-space
|
||
consequence, HARD) aren't caught by the generic " size" SOFT marker.
|
||
Raises ``ValueError`` for a fail string matching neither list.
|
||
"""
|
||
for marker in _HARD_FAIL_MARKERS:
|
||
if marker in fail:
|
||
return "hard"
|
||
for marker in _SOFT_FAIL_MARKERS:
|
||
if marker in fail:
|
||
return "soft"
|
||
raise ValueError(
|
||
f"unclassified fail string (add a tier marker in fitness.py): {fail!r}"
|
||
)
|
||
|
||
|
||
def tier_counts(fails) -> tuple[int, int]:
|
||
"""Return ``(n_hard, n_soft)`` for an iterable of failure strings."""
|
||
n_hard = n_soft = 0
|
||
for f in fails:
|
||
if classify_fail_tier(f) == "hard":
|
||
n_hard += 1
|
||
else:
|
||
n_soft += 1
|
||
return n_hard, n_soft
|
||
|
||
|
||
# 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,
|
||
# homemaker-py-ecx (DESIGN.md §39.19). Was 300.0 -- the same rate as
|
||
# `value_inside`, which made a roof terrace worth as much per square metre
|
||
# as a real internal room, and (after quality, which barely touches an
|
||
# outside leaf) 4.4x as much in practice. The owner's ruling is that a
|
||
# terrace must not be worth more per area than a room.
|
||
#
|
||
# Set to `value_outside`, not to some number that makes an inequality come
|
||
# out: outdoor space is worth the same to an occupant whatever level it sits
|
||
# on, and the real difference between a ground garden and a roof terrace is
|
||
# what it takes to BUILD -- which `cost` already says, `outside` 10.0
|
||
# against `outside_supported` 110.0. Value describes worth, cost describes
|
||
# structure; the level belongs in the second.
|
||
"value_supported": 100.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],
|
||
# PROVENANCE (DESIGN.md §39.16). This is Christopher Alexander, A Pattern
|
||
# Language 159, "Light on Two Sides of Every Room" -- not an arbitrary
|
||
# constant. The factor is evaluated at `1/crink = A/(L*h)`: floor area per
|
||
# metre of ILLUMINATED wall, over storey height. It is NOT room depth, and
|
||
# reading it as depth has caused two published errors -- it equals depth
|
||
# only for a room lit on one side.
|
||
#
|
||
# 5/6 x h = 2.5 m at h=3 is therefore 2.5 m of room depth PER WINDOW WALL:
|
||
# a shallow room (a corridor along an outside wall) may be lit on one side,
|
||
# a deeper one needs a corner or windows on opposite walls. At the fail
|
||
# edge (+2.146 sigma) the allowance is 4.86 m per wall -- so 4.86 m
|
||
# single-aspect, or 9.72 m between two opposite windows.
|
||
#
|
||
# Independently corroborated twice: the gaussian's own FAIL_THRESHOLD
|
||
# crossing and §38.3's frontage bound `L >= A/(1.6202*h)`, derived
|
||
# separately, agree at 1.6202. Of the numbers in this table it is the
|
||
# best-supported, and the last that should be retuned.
|
||
"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))))
|
||
|
||
|
||
@functools.lru_cache(maxsize=None)
|
||
def _crink_at_fail_threshold(distance: float, sigma: float) -> float:
|
||
"""The crinkliness at which the stock gaussian crosses ``FAIL_THRESHOLD``
|
||
on the COMPACT (too-little-exposure) side (homemaker-py-9gj).
|
||
|
||
``quality_uncrinkliness`` evaluates the gaussian at ``x = 1/crink``, so
|
||
solving ``gaussian(x, 1, distance, sigma) == FAIL_THRESHOLD`` for the root
|
||
above ``distance`` and inverting gives the crinkliness below which a leaf
|
||
fails. Uses ``_E`` rather than ``math.e`` so the crossing agrees with the
|
||
truncated constant the gaussian itself is evaluated with.
|
||
"""
|
||
d = math.sqrt(2.0 * sigma * sigma
|
||
* math.log(1.0 / FAIL_THRESHOLD) / math.log(_E))
|
||
return 1.0 / (distance + d)
|
||
|
||
|
||
def _gaussian_product(target_a: float, sigma_a: float,
|
||
target_b: float, sigma_b: float) -> tuple[float, float]:
|
||
"""Precision-weighted combination of two Gaussian (target, sigma) pairs
|
||
(homemaker-py-1s3): the product of two Gaussian curves evaluated at the
|
||
same point is itself proportional to a Gaussian with precisions (1/sigma^2)
|
||
ADDING and target the precision-weighted average — always an INTERMEDIATE
|
||
target (never simply the stricter of the two) with a NARROWER spread than
|
||
either input, unlike a naive max-target/min-sigma combination.
|
||
|
||
Used by ``quality_width``/``quality_proportion`` to combine a co-located
|
||
leaf's two codes' shape targets. A/B-measured (DESIGN.md §33) as the best
|
||
of three tried: beats both the naive max-target/min-sigma hack (health-
|
||
centre +24.5% worse) and the max-of-two MIXTURE combination below
|
||
(health-centre +20.4% worse) — this precision-weighted single compromise
|
||
peak was the only one to improve both example programmes."""
|
||
prec_a, prec_b = 1.0 / (sigma_a * sigma_a), 1.0 / (sigma_b * sigma_b)
|
||
prec_c = prec_a + prec_b
|
||
target_c = (target_a * prec_a + target_b * prec_b) / prec_c
|
||
return target_c, (1.0 / prec_c) ** 0.5
|
||
|
||
|
||
def _clipped_gaussian(x: float, target: float, sigma: float, good_side: str) -> float:
|
||
"""The 'flat 1.0 once the target is met, gaussian decay short of it' shape
|
||
both ``quality_width`` (wider than target is good) and
|
||
``quality_proportion`` (squarer/lower aspect than target is good) use.
|
||
|
||
Also the building block of a MIXTURE alternative to ``_gaussian_product``
|
||
that was tried and measured worse (homemaker-py-1s3, DESIGN.md §33):
|
||
evaluate this once per served code and combine with ``max()`` instead of
|
||
computing one combined (target, sigma) — the leaf scores well if the
|
||
realised geometry ends up close to EITHER code's target rather than one
|
||
narrow compromise peak, echoing the per-leaf usage collapse §26 path (a)
|
||
uses at the whole-leaf-type level. Appealing in principle (no forced
|
||
compromise) but empirically worse on the tightly-packed health-centre
|
||
programme (+20.4%, vs -13.9% for the precision-weighted product currently
|
||
used) — plausibly because ``max()`` lets a leaf score 1.0 by satisfying
|
||
only the WEAKER of two codes' targets, under-constraining the search."""
|
||
if (good_side == "above" and x > target) or (good_side == "below" and x < target):
|
||
return 1.0
|
||
return gaussian(x, 1.0, target, sigma)
|
||
|
||
|
||
def load_config(directory: str | Path,
|
||
overrides: dict | None = None) -> tuple[dict, dict]:
|
||
"""Load (patterns, costs) config for a corpus directory, mirroring
|
||
``urb-fitness.pl``: project-level ``../<name>.config`` first, then the
|
||
local file's keys override it.
|
||
|
||
``overrides`` (homemaker-py-x3b) are merged into the patterns conf last, so a
|
||
caller can switch on run-level knobs (e.g. ``leaf_sharing``) without editing
|
||
any ``patterns.config`` on disk — keeping the §13.3 example programmes
|
||
reproducible while the CLI/driver drives sharing programmatically."""
|
||
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 {})
|
||
if overrides:
|
||
conf.update(overrides)
|
||
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 _generic_class(n: Node) -> str:
|
||
"""Generic-type class of a leaf: ``"c"`` circulation, ``"o"`` outside, or
|
||
``""`` for a programme room (§39.4).
|
||
|
||
Replaces the old ``_t0(leaf)`` first-character dispatch in the quality terms
|
||
and value rate. (``_t0`` itself is gone: the SEMANTIC prefixes it served —
|
||
``k`` kitchen, ``l`` living, … — became the declared ``usage:`` key in
|
||
§39.7, so no first-character test remains anywhere.) ``S`` (sahn)
|
||
belongs to both generic sets but takes the outside parameter families, so it
|
||
maps to ``"o"`` — exactly as ``get_space_params`` dispatches it.
|
||
"""
|
||
if n.type in dom_mod.GENERIC_OUTSIDE:
|
||
return "o"
|
||
if n.type == "C":
|
||
return "c"
|
||
return ""
|
||
|
||
|
||
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)
|
||
# erc.hph §13.7/§13.8: scale the edge-too-long cap by a shared leaf's
|
||
# share k so an aggregate (k-room) leaf is not penalised for long walls —
|
||
# the §13.3 leak on a different measure. The §13.8 A/B verdict was
|
||
# positive and monotone-harmless, so the default is ON for leaf-sharing
|
||
# runs (mirrors the pll/interior_outside default flips). An explicit
|
||
# share_edge_cap=False still reproduces the pre-flip control arm.
|
||
cap = self.conf("share_edge_cap")
|
||
self._share_edge_cap = self._leaf_sharing if cap is None else bool(cap)
|
||
# 9o5 type superposition (DESIGN.md §13/homemaker-py-9o5): default OFF.
|
||
# When on, interchangeable codes (similar requirements) form equivalence
|
||
# classes; each candidate's fitness re-types (collapses) every superposed
|
||
# leaf to its best in-class usage before scoring, so search optimises the
|
||
# condensed objective directly and the relaxation gap is removed.
|
||
self._superpose = bool(self.conf("superpose"))
|
||
# homemaker-py-qi6 graded circulation-connectivity signal (DESIGN.md §18):
|
||
# default OFF. When on, the graded proximity scalar (want_grade) is the
|
||
# per-level largest-circ-component fraction instead of the §11.4 leaf
|
||
# quality-proximity — a secondary comparator key giving the outer search
|
||
# a gradient the binary "level N not connected" fail lacks. Leaves the
|
||
# scalar fitness and fail count untouched, exactly like §11.4.
|
||
self._conn_grade = bool(self.conf("conn_grade"))
|
||
from .programme import CLASS_CAP as _CLASS_CAP
|
||
self._class_cap = int(self.conf("superpose_class_cap") or _CLASS_CAP)
|
||
self._interchange_classes: list | None = None # lazily derived
|
||
# homemaker-py-qpk: IN-SEARCH global collapse. Default OFF. When on,
|
||
# collapse_global (the 94g finish-time cell<->room relabel) runs INSIDE
|
||
# _evaluate_full every eval instead of once at the end, so search
|
||
# optimises the collapsed objective directly (mirrors the 9o5 per-eval
|
||
# collapse above, at GLOBAL scope). Carries the 9o5/xi7 landscape-
|
||
# flattening risk amplified to the whole building; gated behind its own
|
||
# A/B (DESIGN.md §17 follow-on, homemaker-py-qpk) — do not default on
|
||
# without a positive result.
|
||
self._collapse_insearch = bool(self.conf("collapse_insearch"))
|
||
adj = self.conf("collapse_insearch_adjacency")
|
||
self._collapse_insearch_adjacency = True if adj is None else bool(adj)
|
||
# Fewer Jacobi passes than the finish-time default (6): a per-eval cost,
|
||
# not a one-shot polish — profile before raising.
|
||
self._collapse_insearch_iters = int(self.conf("collapse_insearch_iters") or 3)
|
||
# homemaker-py-1s3 §26 path b: multi-use leaves as a PERMANENT design
|
||
# goal (no per-eval collapse, unlike superpose above). Default OFF.
|
||
# When on, a leaf carrying a live, valid co_type counts toward BOTH
|
||
# codes' requirements simultaneously (graph.leaf_codes), and its size
|
||
# target combines both codes' area (quality_size).
|
||
self._multi_use = bool(self.conf("multi_use"))
|
||
self._colocate_pairs: list | None = None # lazily derived
|
||
# homemaker-py-ssz (DESIGN.md §38.1): how quality_uncrinkliness treats a
|
||
# leaf with no daylit wall. "urb" (default) = stock hard 0.0, byte-
|
||
# identical to every prior run. "floor"/"compact_ok"/"exempt_circulation"
|
||
# are the three candidate repairs — see quality_uncrinkliness.
|
||
# homemaker-py-2v1 (§39.8), EXPERIMENTAL: 1.0 (default) is the flat rule,
|
||
# byte-identical to every prior run. "auto" derives the smallest weight
|
||
# that makes severing circulation net-negative; a number sets it explicitly.
|
||
cw = self.conf("connectivity_weight")
|
||
if cw is None:
|
||
self._connectivity_weight = 1.0
|
||
elif isinstance(cw, str) and cw.lower() == "auto":
|
||
self._connectivity_weight = connectivity_weight_for(
|
||
float(self.conf("value_inside")),
|
||
float(self.conf("value_circulation")))
|
||
else:
|
||
self._connectivity_weight = float(cw)
|
||
self._crinkliness_mode = str(self.conf("crinkliness_mode") or "urb")
|
||
if self._crinkliness_mode not in (
|
||
"urb", "floor", "compact_ok", "exempt_circulation"):
|
||
raise ValueError(
|
||
f"unknown crinkliness_mode: {self._crinkliness_mode!r}")
|
||
# The floored value stays BELOW FAIL_THRESHOLD, so a buried leaf still
|
||
# emits its crinkliness failure and the fail count is unchanged — only
|
||
# the value gradient is restored. Raising this above FAIL_THRESHOLD
|
||
# would silently delete a whole fail category.
|
||
self._crinkliness_floor = float(
|
||
self.conf("crinkliness_floor") or 0.01)
|
||
# homemaker-py-9gj (DESIGN.md §39.13): how the FAILING compact tail of
|
||
# the crinkliness gaussian is scaled. "gaussian" (default) is stock,
|
||
# byte-identical to every prior run. "ramp" replaces the tail — and
|
||
# only the tail, only below FAIL_THRESHOLD, only on the compact side —
|
||
# with a straight line in crinkliness, because the stock exponent grows
|
||
# like 1/crink^2 and underflows the whole tail to a numerically
|
||
# indistinguishable zero. Nothing at or above FAIL_THRESHOLD moves, so
|
||
# the fail set is byte-identical either way and no calibration changes.
|
||
self._crinkliness_tail = str(self.conf("crinkliness_tail") or "gaussian")
|
||
if self._crinkliness_tail not in ("gaussian", "ramp"):
|
||
raise ValueError(
|
||
f"unknown crinkliness_tail: {self._crinkliness_tail!r}")
|
||
# homemaker-py-9gj (DESIGN.md §39.14): what the factor rewards ABOVE the
|
||
# fail threshold, orthogonal to `crinkliness_tail` below it.
|
||
# "gaussian" (default) is stock: two-sided, so a room with MORE daylit
|
||
# wall than target is penalised for it. "daylight" clips that side to
|
||
# 1.0 -- daylight is a sufficiency requirement, and the envelope a
|
||
# well-lit room costs is already charged by `exterior_wall` and
|
||
# `boundary_wall` in the cost model, so penalising it again in value
|
||
# bills the same wall twice.
|
||
# homemaker-py-ecx (DESIGN.md §39.18/§39.19): how a leaf's quality
|
||
# factors are combined. "geometric_mean" is now the DEFAULT and
|
||
# "product" is the old stock behaviour, kept for comparison.
|
||
# The geometric mean divides out
|
||
# how many questions the leaf was ASKED, because quality is a product
|
||
# and an outside leaf is exempt from size, crinkliness and access while
|
||
# a room is judged on all three -- so exemption alone buys a higher
|
||
# quality, and quality multiplies the value rate. Fails are emitted per
|
||
# factor inside evaluate_leaf, before any combining, so the fail set
|
||
# cannot move either way.
|
||
#
|
||
# Made default ON with the §39.19 value_supported change, because the
|
||
# owner's ruling -- a terrace must not be worth more per square metre
|
||
# than a real internal room -- needs BOTH. Measured on the corpus:
|
||
# stock 4.39x, rate alone 1.46x, aggregation alone 2.23x, together
|
||
# 98.9 against 132.9 per m2, which is the ruling satisfied.
|
||
self._quality_aggregate = str(
|
||
self.conf("quality_aggregate") or "geometric_mean")
|
||
if self._quality_aggregate not in ("product", "geometric_mean"):
|
||
raise ValueError(
|
||
f"unknown quality_aggregate: {self._quality_aggregate!r}")
|
||
self._crinkliness_shape = str(self.conf("crinkliness_shape") or "gaussian")
|
||
if self._crinkliness_shape not in ("gaussian", "daylight"):
|
||
raise ValueError(
|
||
f"unknown crinkliness_shape: {self._crinkliness_shape!r}")
|
||
if self._crinkliness_shape == "daylight" and self._crinkliness_mode != "urb":
|
||
raise ValueError(
|
||
"crinkliness_shape='daylight' is incompatible with "
|
||
f"crinkliness_mode={self._crinkliness_mode!r} (§38.1's modes are "
|
||
"superseded; use one or the other, not both)")
|
||
if self._crinkliness_tail == "ramp" and self._crinkliness_mode != "urb":
|
||
# Both rewrite the same tail; composing them would give a shape
|
||
# neither was measured under.
|
||
raise ValueError(
|
||
"crinkliness_tail='ramp' is incompatible with "
|
||
f"crinkliness_mode={self._crinkliness_mode!r} (§38.1's modes are "
|
||
"superseded; use one or the other, not both)")
|
||
|
||
def usages(self) -> dict[str, str]:
|
||
"""``{room code: usage}`` for this programme (homemaker-py-sel).
|
||
|
||
Keyed by CODE, never stamped on a leaf: a retype changes the code and
|
||
the usage follows automatically, exactly as size/width/adjacency do.
|
||
Generic ``C``/``O``/``S`` are absent — they are not programme rooms and
|
||
their behaviour comes from the generic type rule (§39.4).
|
||
"""
|
||
return {code: req.usage for code, req in (self._programme or {}).items()}
|
||
|
||
def usage_of(self, leaf: Node) -> str:
|
||
"""Access-requirement class of one leaf, ``""`` for a generic type."""
|
||
req = (self._programme or {}).get(leaf.type)
|
||
return req.usage if req else ""
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Type superposition + collapse (homemaker-py-9o5)
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def interchange_classes(self) -> list:
|
||
"""Interchange equivalence classes (size>=2), derived once from the
|
||
programme and cached. Empty list when superposition has nothing to act
|
||
on, in which case the collapse is a no-op and scoring matches baseline."""
|
||
if self._interchange_classes is None:
|
||
from . import programme as _pr
|
||
reqs = self._programme or {}
|
||
self._interchange_classes = (
|
||
_pr.derive_interchange_classes(reqs) if reqs else []
|
||
)
|
||
return self._interchange_classes
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Multi-use leaves (homemaker-py-1s3, §26 path b)
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def colocate_pairs(self) -> list:
|
||
"""Valid co-location pairs, derived once from the programme and
|
||
cached. Empty list when nothing is declared, in which case
|
||
``multi_use`` is a no-op and scoring matches baseline."""
|
||
if self._colocate_pairs is None:
|
||
from . import programme as _pr
|
||
reqs = self._programme or {}
|
||
self._colocate_pairs = (
|
||
_pr.derive_colocate_pairs(reqs) if reqs else []
|
||
)
|
||
return self._colocate_pairs
|
||
|
||
def _leaf_co_type(self, leaf: Node) -> "str | None":
|
||
"""The leaf's live, valid co_type under ``multi_use``, else ``None``."""
|
||
if not self._multi_use or not leaf.co_type or not leaf.type:
|
||
return None
|
||
if frozenset((leaf.type, leaf.co_type)) in self.colocate_pairs():
|
||
return leaf.co_type
|
||
return None
|
||
|
||
def _usage_quality(self, leaf: Node, usage: str) -> float:
|
||
"""The usage-DEPENDENT part of a leaf's quality (size x width x
|
||
proportion) as if it were typed ``usage``. The remaining factors
|
||
(perpendicular, crinkliness, access) and value rate are usage-invariant
|
||
within a class, so this is the separable per-leaf collapse objective."""
|
||
orig = leaf.type
|
||
orig_share_type = leaf.share_type
|
||
leaf.type = usage
|
||
if usage != orig:
|
||
# homemaker-py-iio: a stale share (share>1, share_type left over
|
||
# from a code this leaf no longer holds) must not spuriously
|
||
# reactivate just because THIS hypothetical usage probe happens to
|
||
# match the old share_type -- graph.leaf_share reads leaf.type,
|
||
# which we have just overridden, so it would otherwise compare the
|
||
# stale share_type against the candidate usage instead of the
|
||
# leaf's real committed type. Only the leaf's OWN current type
|
||
# (usage == orig) may legitimately carry a live share.
|
||
leaf.share_type = None
|
||
try:
|
||
return (
|
||
self.quality_size(leaf)
|
||
* self.quality_width(leaf)
|
||
* self.quality_proportion(leaf)
|
||
)
|
||
finally:
|
||
leaf.type = orig
|
||
leaf.share_type = orig_share_type
|
||
|
||
def _best_assignment(self, quality: list[list[float]]) -> list[tuple[int, int]]:
|
||
"""Maximum-total-quality matching of ``min(rows, cols)`` leaf->slot
|
||
pairs. Brute-forces <= C! permutations when the smaller side is within
|
||
the class cap (exact and tiny); otherwise solves the equivalent
|
||
linear-sum assignment (Hungarian) — both give the optimum because the
|
||
objective is separable per leaf (§3 cost note)."""
|
||
rows = len(quality)
|
||
cols = len(quality[0]) if rows else 0
|
||
if rows == 0 or cols == 0:
|
||
return []
|
||
if min(rows, cols) <= self._class_cap:
|
||
import itertools
|
||
best: list[tuple[int, int]] = []
|
||
best_score = float("-inf")
|
||
if rows <= cols:
|
||
for sel in itertools.permutations(range(cols), rows):
|
||
s = sum(quality[r][sel[r]] for r in range(rows))
|
||
if s > best_score:
|
||
best_score = s
|
||
best = [(r, sel[r]) for r in range(rows)]
|
||
else:
|
||
for sel in itertools.permutations(range(rows), cols):
|
||
s = sum(quality[sel[c]][c] for c in range(cols))
|
||
if s > best_score:
|
||
best_score = s
|
||
best = [(sel[c], c) for c in range(cols)]
|
||
return best
|
||
from scipy.optimize import linear_sum_assignment
|
||
import numpy as np
|
||
ri, ci = linear_sum_assignment(-np.array(quality))
|
||
return list(zip(ri.tolist(), ci.tolist()))
|
||
|
||
def collapse_superposition(self, root: Node) -> None:
|
||
"""Re-type each superposed leaf to its best in-class usage (the per-eval
|
||
COLLAPSE, homemaker-py-9o5 §1). Runs on the UNMERGED tree before any
|
||
check, so counts/adjacency/quality downstream see the condensed types.
|
||
|
||
Per class: SUPPLY = leaves currently typed into the class; DEMAND = the
|
||
class codes expanded by their required counts. The optimal supply->demand
|
||
matching assigns each demand slot to the leaf that fits it best; surplus
|
||
supply leaves keep their type (a genuine over-supply that scoring still
|
||
penalises), unmet demand slots stay absent (a genuine missing room)."""
|
||
classes = self.interchange_classes()
|
||
if not classes:
|
||
return
|
||
prog = self._programme or {}
|
||
by_type: dict[str, list[Node]] = {}
|
||
for lvl in dom_mod.levels(root):
|
||
for leaf in lvl.leaves():
|
||
if leaf.type:
|
||
by_type.setdefault(leaf.type, []).append(leaf)
|
||
|
||
for cls in classes:
|
||
supply = [lf for code in cls for lf in by_type.get(code, [])]
|
||
if not supply:
|
||
continue
|
||
slots: list[str] = []
|
||
for code in sorted(cls):
|
||
cnt = prog[code].count if code in prog else 0
|
||
slots.extend([code] * max(0, cnt))
|
||
if not slots:
|
||
continue
|
||
# Weight each leaf's usage quality by its area: the condensed value is
|
||
# sum(quality * value_rate * area), and value_rate is constant within a
|
||
# class (all in-class codes are inside rooms), so area is the per-leaf
|
||
# weight that makes the matching maximise value, not just mean quality.
|
||
quality = [
|
||
[self._usage_quality(lf, s) * geometry.area(lf) for s in slots]
|
||
for lf in supply
|
||
]
|
||
for r, c in self._best_assignment(quality):
|
||
supply[r].type = slots[c]
|
||
|
||
# Forbidden-pairing penalty for the global collapse cost matrix: large and
|
||
# finite (Hungarian cannot take -inf) yet far below any real value, so the
|
||
# optimal matching never uses a level-mismatched pair unless it is forced.
|
||
_COLLAPSE_FORBID = -1e12
|
||
# Weight of one avoided fail (a satisfied adjacency or a passing
|
||
# size/width/proportion factor) in the collapse objective — far above the
|
||
# continuous quality span (~max area) so fail count dominates and raw
|
||
# quality only breaks ties; far below the forbid penalty so level holds.
|
||
_COLLAPSE_FAIL_W = 1e6
|
||
|
||
def _collapse_value(
|
||
self,
|
||
lf: Node,
|
||
code: str,
|
||
lvl: int,
|
||
prog: dict,
|
||
objective: str,
|
||
forbid: float,
|
||
fail_w: float,
|
||
) -> float:
|
||
"""Base (non-adjacency) collapse value of relabelling ``lf`` (on storey
|
||
``lvl``) to ``code``: the ``_COLLAPSE_FORBID`` penalty on a level
|
||
mismatch, else quality_size*width*proportion*area, plus ``fail_w`` per
|
||
passing factor under the ``"threshold"`` objective. Shared by the
|
||
collapse_global assignment matrix and the 2-opt polish below so both
|
||
score a (leaf, code) pair identically."""
|
||
req = prog[code]
|
||
if req.level is not None and req.level != lvl:
|
||
return forbid
|
||
orig = lf.type
|
||
orig_share_type = lf.share_type
|
||
lf.type = code
|
||
if code != orig:
|
||
# homemaker-py-iio: see _usage_quality -- a stale share/share_type
|
||
# left over from a code this leaf no longer holds must not
|
||
# spuriously reactivate just because this hypothetical candidate
|
||
# code happens to match it (graph.leaf_share reads leaf.type,
|
||
# which is overridden to the candidate here). Only the leaf's own
|
||
# current type (code == orig) may legitimately carry a live share.
|
||
lf.share_type = None
|
||
try:
|
||
qs = self.quality_size(lf)
|
||
qw = self.quality_width(lf)
|
||
qp = self.quality_proportion(lf)
|
||
finally:
|
||
lf.type = orig
|
||
lf.share_type = orig_share_type
|
||
val = qs * qw * qp * geometry.area(lf)
|
||
if objective == "threshold":
|
||
passes = (
|
||
(qs >= FAIL_THRESHOLD) + (qw >= FAIL_THRESHOLD) + (qp >= FAIL_THRESHOLD)
|
||
)
|
||
val += fail_w * passes
|
||
return val
|
||
|
||
def _two_opt_adjacency_polish(
|
||
self,
|
||
supply: list[Node],
|
||
levels_of: list[int],
|
||
graphs: list,
|
||
code_adj: dict[str, list[str]],
|
||
prog: dict,
|
||
objective: str,
|
||
forbid: float,
|
||
fail_w: float,
|
||
max_passes: int = 20,
|
||
) -> None:
|
||
"""homemaker-py-9wi: a local-search pass beyond collapse_global's Jacobi
|
||
adjacency relaxation. Jacobi re-solves a LINEAR assignment each round
|
||
holding neighbours' labels fixed from the previous round -- exact per
|
||
round, but the true objective is quadratic (a satisfied adjacency
|
||
depends on a PAIR of labels), so synchronous Jacobi can plateau short
|
||
of the joint optimum. This adds 2-opt: for every same-level pair of
|
||
supply leaves, try swapping their CURRENT labels and keep the swap
|
||
only if it strictly increases the total reward (own quality/threshold
|
||
value + fail_w per satisfied adjacency) summed over the two leaves and
|
||
every leaf adjacent to either -- the only cells a label swap between
|
||
i and j can change. Repeats to a fixpoint (or ``max_passes``).
|
||
|
||
Same-level-only pairing keeps the hard level constraint for free: both
|
||
codes already matched their own leaf's level before the swap, and the
|
||
two leaves share a level, so the swap is valid on both sides. A swap
|
||
is applied only when it is a STRICT improvement, so this can only
|
||
reduce, never increase, the fail count -- monotone by construction,
|
||
like the Hungarian solve it refines."""
|
||
from . import graph as graph_mod
|
||
|
||
idx_of_leaf = {id(lf): i for i, lf in enumerate(supply)}
|
||
|
||
def reward(idx: int) -> float:
|
||
lf = supply[idx]
|
||
code = lf.type
|
||
val = self._collapse_value(
|
||
lf, code, levels_of[idx], prog, objective, forbid, fail_w
|
||
)
|
||
if val <= forbid:
|
||
return val
|
||
G = graphs[levels_of[idx]]
|
||
sat = sum(1 for ac in code_adj.get(code, ()) if graph_mod.has_adjacency(lf, ac, G))
|
||
return val + fail_w * sat
|
||
|
||
def affected(i: int, j: int) -> set[int]:
|
||
aff = {i, j}
|
||
for k in (i, j):
|
||
lf = supply[k]
|
||
G = graphs[levels_of[k]]
|
||
if G.has_node(lf):
|
||
for nb in G.neighbors(lf):
|
||
nidx = idx_of_leaf.get(id(nb))
|
||
if nidx is not None:
|
||
aff.add(nidx)
|
||
return aff
|
||
|
||
by_level: dict[int, list[int]] = {}
|
||
for idx, lvl in enumerate(levels_of):
|
||
by_level.setdefault(lvl, []).append(idx)
|
||
|
||
changed = True
|
||
passes = 0
|
||
while changed and passes < max_passes:
|
||
changed = False
|
||
passes += 1
|
||
for idxs in by_level.values():
|
||
for a in range(len(idxs)):
|
||
for b in range(a + 1, len(idxs)):
|
||
i, j = idxs[a], idxs[b]
|
||
ci, cj = supply[i].type, supply[j].type
|
||
if ci == cj:
|
||
continue
|
||
aff = affected(i, j)
|
||
before = sum(reward(k) for k in aff)
|
||
supply[i].type, supply[j].type = cj, ci
|
||
after = sum(reward(k) for k in aff)
|
||
if after > before + 1e-9:
|
||
changed = True
|
||
else:
|
||
supply[i].type, supply[j].type = ci, cj
|
||
|
||
def collapse_global(
|
||
self,
|
||
root: Node,
|
||
adjacency: bool = True,
|
||
objective: str = "threshold",
|
||
preserve_public_access: bool = True,
|
||
iters: int = 6,
|
||
local_search: bool = False,
|
||
local_search_passes: int = 20,
|
||
) -> None:
|
||
"""Finish-time GLOBAL cell->room collapse (homemaker-py-94g): relabel
|
||
every inside-room leaf across the whole building to the required room it
|
||
fits best, via one optimal assignment over the full leaf set — the 9o5
|
||
per-class collapse generalised to N inside leaves <-> M required rooms.
|
||
|
||
SUPPLY = leaves whose type is an assignable programme room code; DEMAND =
|
||
every such code expanded by its required count, tagged with its required
|
||
level. Assignable codes EXCLUDE any starting c/o/s: check_space_counts
|
||
(graph.py) skips those as circulation/outside/sahn — including room codes
|
||
that collide with the convention (cr1, st1, st2) — so those leaves form
|
||
the circulation/structure skeleton and must not be relabelled. Surplus
|
||
leaves keep their type (genuine over-supply); unmet demand stays absent
|
||
(genuine missing room).
|
||
|
||
HARD LEVEL constraint: a leaf may only take a room whose required level
|
||
matches its storey (a -1e12 forbid penalty), so the collapse never adds a
|
||
wrong-level fail. ADJACENCY (when ``adjacency``): the objective adds a
|
||
bonus for each of a code's required adjacencies satisfied at a leaf given
|
||
the CURRENT labelling. Because geometry is fixed at finish time, each
|
||
leaf's graph neighbours are fixed and only labels move, so the problem is
|
||
a labelling relaxation: warm-started from the evolved labels, each pass is
|
||
a linear assignment over quality + adjacency-bonus computed from the
|
||
previous pass, iterated to a fixpoint (Jacobi/WFC-style). Maximising
|
||
satisfied adjacencies minimises adjacency fails.
|
||
|
||
OBJECTIVE selects the per-leaf base value: ``"quality"`` maximises the
|
||
separable continuous fit sum(usage_quality * area) collapse_superposition
|
||
uses; ``"threshold"`` maximises the COUNT of size/width/proportion factors
|
||
that PASS (>= FAIL_THRESHOLD), with continuous fit only as a tiebreak.
|
||
Continuous quality can trade one leaf just over threshold for another just
|
||
under (a fail SHUFFLE); the threshold objective optimises the fail count
|
||
directly. Under both, a satisfied adjacency and a passing factor carry the
|
||
same weight (_COLLAPSE_FAIL_W = one avoided fail), so the collapse
|
||
minimises (adjacency + size/width/proportion) fails jointly.
|
||
|
||
LOCAL_SEARCH (homemaker-py-9wi, default False HERE): after the Jacobi
|
||
loop above reaches its fixpoint, run a 2-opt polish
|
||
(_two_opt_adjacency_polish) that tries swapping the labels of every
|
||
same-level pair of supply leaves and keeps a swap only if it strictly
|
||
improves the total reward. Jacobi re-solves a LINEAR assignment each
|
||
round holding neighbours' labels fixed from the previous round, so it
|
||
can plateau short of the true quadratic-assignment optimum (a satisfied
|
||
adjacency depends on a PAIR of labels, not one); 2-opt reaches past that
|
||
plateau. Monotone by construction (only strictly-improving swaps are
|
||
kept) and cheap (<1s on the largest file) as a ONE-SHOT finish-time
|
||
polish — but this method is also called on the UNMERGED tree every
|
||
fitness eval when collapse_insearch (qpk) is on, so the method-level
|
||
default stays False to keep that hot path cheap. A 46-file A/B sweep
|
||
across harbor-house (12) and programme-house (34) found 0 regressions
|
||
and 2 improvements (evolved-anneal-3M.dom 21->19, a82f07068e4408fdd0d5e
|
||
3dc469a8dee.dom 3->2 fails) for the finish-time, one-shot use, so
|
||
homemaker-py-cdl turned it on by default there: homemaker-collapse
|
||
--local-search and homemaker-evolve --collapse-local-search both
|
||
default True and pass it through explicitly.
|
||
|
||
PRESERVE_PUBLIC_ACCESS pins the room leaf that solely provides the
|
||
building's street access (an l/k neighbour of a public outside leaf, with
|
||
no circulation fallback) so the collapse cannot drop the building-level
|
||
"no outside public access" check — the one recurring regression the
|
||
per-leaf objective cannot see (it is existential and building-scoped).
|
||
|
||
One-shot finish-time pass on a committed layout, not a per-eval re-type."""
|
||
from collections import Counter
|
||
from . import graph as graph_mod
|
||
|
||
# Defensive (homemaker-py-cvw): geometry._cache is id()-keyed and only
|
||
# safe when cold or exclusively populated by this tree; a stale entry
|
||
# from a gc'd tree at a recycled address could otherwise alias in.
|
||
geometry.clear_cache()
|
||
|
||
# homemaker-py-r5a: drop any stale share/share_type BEFORE this pass
|
||
# relabels anything, so a leaf relabelled back to the code its stale
|
||
# stamp names cannot resurrect a multiplicity credit (see
|
||
# dom.canonicalize_shares).
|
||
dom_mod.canonicalize_shares(root)
|
||
|
||
prog = self._programme or {}
|
||
if not prog:
|
||
return
|
||
room_codes = {c for c in prog if not dom_mod.is_generic(c)}
|
||
if not room_codes:
|
||
return
|
||
lvls = dom_mod.levels(root)
|
||
graphs = (
|
||
graph_mod.build_graphs(root, self.conf("door_width") or 1.2)
|
||
if (adjacency or preserve_public_access)
|
||
else None
|
||
)
|
||
|
||
pinned = (
|
||
self._public_access_pins(root, graphs, lvls, room_codes)
|
||
if (preserve_public_access and graphs is not None)
|
||
else set()
|
||
)
|
||
supply = [
|
||
lf
|
||
for lvl in lvls
|
||
for lf in lvl.leaves()
|
||
if lf.type in room_codes and id(lf) not in pinned
|
||
]
|
||
if not supply:
|
||
return
|
||
# Demand = room-code counts, minus one slot per pinned leaf (its instance
|
||
# is already met by the pin, so it must not be demanded of another leaf).
|
||
slot_counts = Counter({c: max(0, prog[c].count) for c in room_codes})
|
||
if pinned:
|
||
for lvl in lvls:
|
||
for lf in lvl.leaves():
|
||
if id(lf) in pinned and slot_counts.get(lf.type, 0) > 0:
|
||
slot_counts[lf.type] -= 1
|
||
slots = [c for c in sorted(slot_counts) for _ in range(slot_counts[c])]
|
||
if not slots:
|
||
return
|
||
|
||
forbid = self._COLLAPSE_FORBID
|
||
fail_w = self._COLLAPSE_FAIL_W
|
||
levels_of = [dom_mod.level_of(lf) for lf in supply]
|
||
# Base per-cell value: forbid on level mismatch, else the separable fit.
|
||
# In "threshold" mode add fail_w per passing size/width/proportion factor
|
||
# so the matching maximises passes first, continuous fit only as tiebreak.
|
||
base: list[list[float]] = [
|
||
[
|
||
self._collapse_value(lf, code, levels_of[i], prog, objective, forbid, fail_w)
|
||
for code in slots
|
||
]
|
||
for i, lf in enumerate(supply)
|
||
]
|
||
|
||
if not adjacency:
|
||
for r, c in self._best_assignment(base):
|
||
if base[r][c] > forbid:
|
||
supply[r].type = slots[c]
|
||
return
|
||
|
||
# Adjacency relaxation on the pre-merge base graph (built above, fixed
|
||
# geometry). A satisfied adjacency is worth fail_w — one avoided fail,
|
||
# the same unit as a passing factor — so both are minimised jointly.
|
||
code_adj = {code: prog[code].adjacency for code in set(slots)}
|
||
|
||
prev_labels: list[str | None] = None # type: ignore[assignment]
|
||
for _ in range(max(1, iters)):
|
||
quality = [list(row) for row in base]
|
||
for i, lf in enumerate(supply):
|
||
G = graphs[levels_of[i]]
|
||
for j, code in enumerate(slots):
|
||
if quality[i][j] <= forbid:
|
||
continue
|
||
sat = sum(
|
||
1
|
||
for ac in code_adj[code]
|
||
if graph_mod.has_adjacency(lf, ac, G)
|
||
)
|
||
quality[i][j] += fail_w * sat
|
||
assign = self._best_assignment(quality)
|
||
new_labels: list[str | None] = [lf.type for lf in supply]
|
||
for r, c in assign:
|
||
if quality[r][c] > forbid:
|
||
new_labels[r] = slots[c]
|
||
# Apply synchronously so the next pass reads the updated neighbours.
|
||
for lf, lab in zip(supply, new_labels):
|
||
lf.type = lab
|
||
if new_labels == prev_labels:
|
||
break
|
||
prev_labels = new_labels
|
||
|
||
if local_search:
|
||
self._two_opt_adjacency_polish(
|
||
supply,
|
||
levels_of,
|
||
graphs,
|
||
code_adj,
|
||
prog,
|
||
objective,
|
||
forbid,
|
||
fail_w,
|
||
max_passes=local_search_passes,
|
||
)
|
||
|
||
def _public_access_pins(
|
||
self, root: Node, graphs: list, lvls: list, room_codes: set
|
||
) -> set[int]:
|
||
"""id()s of room leaves to hold fixed so the building keeps street access
|
||
across a collapse. If a ground circulation leaf already gives public
|
||
access it is invariant (circulation is never relabelled) — return empty.
|
||
Otherwise, for each outside leaf that provides public access solely via an
|
||
l/k ROOM neighbour (no circulation fallback), pin one such neighbour."""
|
||
for lvl in lvls:
|
||
for lf in lvl.leaves():
|
||
if (
|
||
lf.type == "C"
|
||
and self._public_access(lf, root) is not None
|
||
):
|
||
return set()
|
||
pins: set[int] = set()
|
||
for li, lvl in enumerate(lvls):
|
||
G = graphs[li]
|
||
for lf in lvl.leaves():
|
||
if not G.has_node(lf):
|
||
continue
|
||
if not self._public_access_outside(lf, G, root):
|
||
continue
|
||
nbs = list(G.neighbors(lf))
|
||
if any(nb.type == "C" for nb in nbs):
|
||
continue # circulation neighbour keeps access invariant
|
||
usages = self.usages()
|
||
for nb in nbs:
|
||
if usages.get(nb.type) in _programme.SOCIABLE_USAGES:
|
||
pins.add(id(nb))
|
||
break
|
||
return pins
|
||
|
||
def collapse_finish(self, root: Node, **kw) -> tuple[Node, int, int, bool]:
|
||
"""Keep-better finish-time collapse: apply :meth:`collapse_global` to a
|
||
copy and return it only if it does not INCREASE the fail count, else the
|
||
original — a strictly monotone polish (safety belt; collapse_global is
|
||
already monotone on the harbor-house set but not proven so in general).
|
||
|
||
Returns ``(tree, base_fails, collapsed_fails, applied)``. Both the input
|
||
and returned trees are UNMERGED — scoring is done on throwaway deepcopies
|
||
because ``score_with_fails`` merges the tree in place.
|
||
|
||
homemaker-py-sd3: ``base_fails``/``cand_fails`` are measured with
|
||
``collapse_insearch`` forced off, regardless of how ``self`` was
|
||
configured. The guard's job is to protect the CANONICAL fail count of
|
||
the written ``.dom`` — what ``homemaker-fitness`` reports on disk with
|
||
no in-search override — not this run's in-search objective. Scoring
|
||
with ``collapse_insearch`` on made the guard vacuous: ``score_with_fails``
|
||
re-applies its own ``collapse_global`` pass before counting fails, so
|
||
``base_fails`` already reflected an auto-collapsed tree and came out
|
||
equal to ``cand_fails`` regardless of what this method's own explicit
|
||
collapse actually did."""
|
||
import copy
|
||
|
||
saved_insearch = self._collapse_insearch
|
||
self._collapse_insearch = False
|
||
try:
|
||
base_fails = len(self.score_with_fails(copy.deepcopy(root))[1])
|
||
cand = copy.deepcopy(root)
|
||
self.collapse_global(cand, **kw)
|
||
cand_fails = len(self.score_with_fails(copy.deepcopy(cand))[1])
|
||
finally:
|
||
self._collapse_insearch = saved_insearch
|
||
if cand_fails <= base_fails:
|
||
return cand, base_fails, cand_fails, True
|
||
return copy.deepcopy(root), base_fails, cand_fails, False
|
||
|
||
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 leaf.type == "S":
|
||
leaf.type = "O"
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Programme-driven parameter lookup (ProgrammeDriven.pm:29-69)
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def get_space_params(self, code: str, param: str) -> list[float]:
|
||
# §39.4: only the GENERIC types take the circulation/outside parameter
|
||
# families. A programme code is looked up in ``spaces`` regardless of
|
||
# what letter it happens to start with.
|
||
# NB S (sahn) is a member of BOTH generic sets, and the parameter
|
||
# families split it the outside way: the circulation branch is exactly
|
||
# C, and S takes the *_outside params (preserved from the original
|
||
# c0 == "c" / c0 in ("o", "s") dispatch).
|
||
if code == "C":
|
||
v = self.conf(f"{param}_circulation")
|
||
if v is not None:
|
||
return v
|
||
if code in dom_mod.GENERIC_OUTSIDE:
|
||
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 = _generic_class(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")
|
||
co_type = self._leaf_co_type(leaf)
|
||
if co_type:
|
||
# 1s3: A/B-measured (DESIGN.md §33) — the precision-weighted
|
||
# combination (one intermediate, narrower target) beat both
|
||
# the naive max/min hack AND the max-of-two mixture on the
|
||
# health-centre programme; the mixture's permissiveness (any
|
||
# width/aspect satisfying the WEAKER of the two codes scores
|
||
# 1.0) under-constrains the search on tightly-packed
|
||
# programmes even though it is the more appealing model.
|
||
co_params = self.get_space_params(co_type, "proportion")
|
||
params = _gaussian_product(params[0], params[1],
|
||
co_params[0], co_params[1])
|
||
aspect = geometry.aspect(leaf)
|
||
return _clipped_gaussian(aspect, params[0], params[1], "below")
|
||
|
||
def quality_size(self, leaf: Node) -> float:
|
||
t0 = _generic_class(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 t0 != "c" and target > 0:
|
||
k = 1
|
||
if self._leaf_sharing:
|
||
# 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)
|
||
co_type = None if k > 1 else self._leaf_co_type(leaf)
|
||
if k > 1:
|
||
target, sigma = target * k, sigma * k
|
||
elif co_type:
|
||
# 1s3 §26 path b: a fused leaf's floor area serves BOTH codes'
|
||
# requirements at once — additive, the same operation as
|
||
# leaf-sharing's k×target sum (k identical terms), here with
|
||
# 2 different terms. A leaf never carries both a live share>1
|
||
# and a live co_type (construction never stamps both).
|
||
co_params = self.get_space_params(co_type, "size")
|
||
target, sigma = target + co_params[0], sigma + co_params[1]
|
||
return gaussian(geometry.area(leaf), 1.0, target, sigma)
|
||
|
||
def quality_width(self, leaf: Node) -> float:
|
||
t0 = _generic_class(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")
|
||
co_type = self._leaf_co_type(leaf)
|
||
if co_type:
|
||
# 1s3: precision-weighted, same reasoning as quality_proportion
|
||
# above.
|
||
co_params = self.get_space_params(co_type, "width")
|
||
params = _gaussian_product(params[0], params[1],
|
||
co_params[0], co_params[1])
|
||
width = geometry.length_narrowest(leaf)
|
||
return _clipped_gaussian(width, params[0], params[1], "above")
|
||
|
||
# --- 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 crinkliness_params(self, leaf: Node) -> "tuple[float, float] | None":
|
||
"""``(target, sigma)`` for this leaf's crinkliness, or ``None`` when the
|
||
space declares no minimum-exposure requirement (homemaker-py-ssz).
|
||
|
||
The COMPACT side of the crinkliness gaussian is the daylight
|
||
requirement -- too little exposed wall per unit floor -- so a space
|
||
declaring `crinkliness: none` is declaring that it does not need a
|
||
window. There is no separate daylight attribute; see DESIGN.md §38.10.
|
||
|
||
Resolution order, mirroring how size/width/proportion resolve:
|
||
a programme space's own declaration wins; a generic circulation leaf
|
||
takes `uncrinkliness_circulation`; everything else takes the global
|
||
`uncrinkliness`.
|
||
"""
|
||
req = (self._programme or {}).get(leaf.type)
|
||
if req is not None and req.has_crinkliness:
|
||
if req.crinkliness is None:
|
||
return None
|
||
return req.crinkliness, req.crinkliness_sigma
|
||
key = ("uncrinkliness_circulation" if dom_mod.is_circulation(leaf)
|
||
else "uncrinkliness")
|
||
# An explicit null/`none` in the config means "no minimum-exposure
|
||
# requirement". `conf()` cannot express that -- it collapses None to the
|
||
# default -- so the raw dict is read here, the same way `_optional_pair`
|
||
# tells an absent per-space key from a declared-empty one.
|
||
raw = self._conf.get(key, _MISSING)
|
||
if raw is None or (isinstance(raw, str) and raw.strip().lower() == "none"):
|
||
return None
|
||
params = self.conf(key)
|
||
if params is None:
|
||
return None
|
||
return params[0], params[1]
|
||
|
||
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
|
||
params = self.crinkliness_params(leaf)
|
||
crink = self.crinkliness(leaf, G, groups)
|
||
|
||
if params is None:
|
||
# No minimum-exposure requirement: this space does not need a
|
||
# window, so being buried -- the fully compact limit -- is not a
|
||
# defect. Over-exposure still is: a crinkly leaf costs envelope
|
||
# whatever it holds. So the factor is CLIPPED on the compact side,
|
||
# never switched off, and the over-exposed side keeps the global
|
||
# bound.
|
||
#
|
||
# ...unless `crinkliness_shape="daylight"`, under which the
|
||
# over-exposed side is not this factor's business at all (the cost
|
||
# model charges that wall). A space with no daylight requirement
|
||
# then has nothing left to be judged on. (§39.14)
|
||
if self._crinkliness_shape == "daylight":
|
||
return 1.0
|
||
if not crink:
|
||
return 1.0
|
||
distance, sigma = self.conf("uncrinkliness")
|
||
if 1 / crink > distance:
|
||
return 1.0
|
||
return gaussian(1 / crink, 1.0, distance, sigma)
|
||
|
||
distance, sigma = params
|
||
|
||
# homemaker-py-ssz (DESIGN.md §38.1), EXPERIMENTAL, all default OFF --
|
||
# `crinkliness_mode="urb"` reproduces the stock behaviour exactly. These
|
||
# were the first attempt at the problem and are SUPERSEDED by the
|
||
# declared per-space target above (§38.10); they are kept only so the
|
||
# §38.6/§38.8 measurements remain reproducible. Do not build on them.
|
||
mode = self._crinkliness_mode
|
||
if mode == "exempt_circulation" and dom_mod.is_circulation(leaf):
|
||
return 1.0
|
||
one_sided = mode == "compact_ok"
|
||
|
||
if not crink:
|
||
if one_sided:
|
||
return 1.0
|
||
return self._crinkliness_floor if mode == "floor" else 0.0
|
||
|
||
if self._crinkliness_shape == "daylight" and 1 / crink <= distance:
|
||
# homemaker-py-9gj (DESIGN.md §39.14, corrected by §39.16).
|
||
# `1/crink` is floor area per metre of illuminated wall over storey
|
||
# height -- room depth PER WINDOW WALL, not room depth. The target
|
||
# is Alexander 159, which states a MINIMUM (light on at least two
|
||
# sides), and a two-sided gaussian turns a minimum into a target:
|
||
# 68% of the leaves in this clipped region are lit on two or more
|
||
# sides and are being docked for satisfying the pattern well. Nor
|
||
# should this factor price the surplus envelope -- `edge_cost` and
|
||
# `outside_edge_cost` already bill that wall.
|
||
# Clipping here (rather than at the fail threshold) keeps the
|
||
# factor CONTINUOUS: the graded approach to the daylight limit
|
||
# survives, and no 10x cliff is introduced at the very boundary
|
||
# the fail multiplier already steps on. Note this is the OPPOSITE
|
||
# side from §38.1's `compact_ok`, which forgives being buried.
|
||
return 1.0
|
||
|
||
q = gaussian(1 / crink, 1.0, distance, sigma)
|
||
if one_sided and 1 / crink > distance:
|
||
return 1.0
|
||
if mode in ("floor", "compact_ok"):
|
||
return max(q, self._crinkliness_floor)
|
||
if (self._crinkliness_tail == "ramp"
|
||
and q < FAIL_THRESHOLD and 1 / crink > distance):
|
||
# homemaker-py-9gj (DESIGN.md §39.13). The stock exponent is
|
||
# (1/crink - distance)^2 / 2sigma^2, so it grows without bound as
|
||
# exposure falls: measured over the corpus baseline, the failing
|
||
# compact tail spans crink 0.12..0.59 and q 1e-300..1e-1, every
|
||
# value of which is numerically zero beside a passing leaf's ~1.
|
||
# The search therefore cannot rank two layouts that differ only in
|
||
# how exposed their under-lit rooms are. Replacing the tail with a
|
||
# straight line in crink keeps the ordering, makes it
|
||
# representable, is continuous at the threshold, and still sends a
|
||
# fully buried leaf (crink == 0, handled above) to exactly 0.
|
||
return FAIL_THRESHOLD * crink / _crink_at_fail_threshold(distance, sigma)
|
||
return q
|
||
|
||
# --- 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 self.usage_of(leaf) == "kitchen":
|
||
# a kitchen is served by circulation OR by a living space (§39.7)
|
||
usages = self.usages()
|
||
return [t for t in types
|
||
if t in dom_mod.GENERIC_CIRCULATION
|
||
or usages.get(t) == "living"]
|
||
if dom_mod.is_outside(leaf) or dom_mod.is_circulation(leaf):
|
||
return types
|
||
return [t for t in types if t in dom_mod.GENERIC_CIRCULATION]
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# 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
|
||
|
||
if self._quality_aggregate == "geometric_mean":
|
||
quality = self._aggregate_geometric(leaf, factors)
|
||
return quality, factors
|
||
|
||
def factor_is_asked(self, name: str, leaf: Node) -> bool:
|
||
"""Is this factor a real question for this leaf, or an exemption?
|
||
|
||
Exempt factors return exactly 1.0 from their `quality_*` method, which
|
||
is indistinguishable from a leaf that was asked and answered perfectly
|
||
-- so the two cases have to be told apart here.
|
||
`tests/test_fitness_aggregate.py` asserts the invariant this duplication
|
||
rests on: whenever this returns False, the factor really is 1.0.
|
||
"""
|
||
if name == "daylight":
|
||
return False # pinned to 1.0, URB_NO_OCCLUSION §6
|
||
if name == "size":
|
||
return _generic_class(leaf) not in ("o", "s")
|
||
if name == "crinkliness":
|
||
if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf):
|
||
return False # uncovered outside is lit by definition
|
||
if self.crinkliness_params(leaf) is None:
|
||
# no minimum-exposure requirement; under "daylight" nothing is
|
||
# left to ask, under "gaussian" the over-exposed side still bites
|
||
return self._crinkliness_shape != "daylight"
|
||
return True
|
||
if name == "access":
|
||
return not (not dom_mod.level_of(leaf) and dom_mod.is_outside(leaf))
|
||
return True
|
||
|
||
def _aggregate_geometric(self, leaf: Node, factors: dict[str, float]) -> float:
|
||
"""Geometric mean over the factors this leaf was actually asked.
|
||
|
||
Computed in log space so six factors near zero cannot underflow the
|
||
product before the root is taken. A zero factor stays zero -- a fully
|
||
buried leaf is worth nothing under either aggregation.
|
||
"""
|
||
asked = [v for name, v in factors.items()
|
||
if self.factor_is_asked(name, leaf)]
|
||
if not asked:
|
||
return 1.0
|
||
if any(v <= 0.0 for v in asked):
|
||
return 0.0
|
||
return math.exp(sum(math.log(v) for v in asked) / len(asked))
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Value rates and costs (Leaf.pm:146-251, Storey.pm:122-147)
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def value_rate(self, leaf: Node) -> float:
|
||
t0 = _generic_class(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_cap(self, *leaves: Node) -> float:
|
||
"""Wall-length cap before 'edge too long' fires (erc.hph/§13.7).
|
||
|
||
Default flat 8 m, as Urb. A shared leaf (share=k, type-guarded) holds k
|
||
same-code rooms, so its walls run ~k× longer purely as a leaf-sharing
|
||
representation artifact — the same leak §13.3 closed for size. Scale the
|
||
cap by the largest share among the adjoining leaves, mirroring
|
||
quality_size's k×target. Non-shared leaves keep the flat cap, so genuine
|
||
narrow/oversize pathologies stay flagged."""
|
||
cap = 8.0
|
||
if self._leaf_sharing and self._share_edge_cap:
|
||
from . import graph as _graph
|
||
k = max(_graph.leaf_share(leaf, self._max_share) for leaf in leaves)
|
||
if k > 1:
|
||
cap *= k
|
||
return cap
|
||
|
||
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 > self._edge_cap(a, b) 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")
|
||
cap = self._edge_cap(leaf)
|
||
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 > cap:
|
||
fail(f"{dom_mod.level_of(leaf)}/{leaf.id} outside edge too long")
|
||
return rate * length * _height(leaf)
|
||
|
||
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 in dom_mod.GENERIC_OUTSIDE)
|
||
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``."""
|
||
# §39.4: a generic code ("c" — the only caller) sums the GENERIC
|
||
# circulation types, not every type whose name starts with a "c". Under
|
||
# the old prefix rule a programme code like cr1 "Common Room" counted
|
||
# its whole floor area toward the building's circulation ratio.
|
||
if dom_mod.is_generic(code.upper()):
|
||
match = frozenset(dom_mod.GENERIC_CIRCULATION if code.lower() == "c"
|
||
else (code.upper(),))
|
||
proportion_type = sum(v for k, v in ratios.items() if k in match)
|
||
else:
|
||
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 in dom_mod.GENERIC_OUTSIDE)
|
||
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 other.type != "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 == "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 == "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):
|
||
# C is a generic circulation leaf; living/kitchen are DECLARED
|
||
# usages of programme rooms (§39.7). Two namespaces, two tests.
|
||
if nb.type == "C" or self.usage_of(nb) in _programme.SOCIABLE_USAGES:
|
||
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 == "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 == "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 dom_mod.is_generic(req.code):
|
||
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()
|
||
# homemaker-py-r5a: canonicalise stale share stamps before any
|
||
# relabelling pass (collapse_superposition/collapse_global) or read
|
||
# can resurrect one -- see dom.canonicalize_shares.
|
||
dom_mod.canonicalize_shares(root)
|
||
|
||
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 {}
|
||
|
||
# 9o5 COLLAPSE: re-type superposed leaves to their best in-class usage
|
||
# before any check (no-op unless superposition is on and a class exists).
|
||
if self._superpose:
|
||
self.collapse_superposition(root)
|
||
|
||
# homemaker-py-qpk: IN-SEARCH global collapse (DESIGN.md §17 follow-on).
|
||
# Runs before any check, same as collapse_superposition above, so counts/
|
||
# adjacency/quality downstream see the collapsed (relabelled) types. Uses
|
||
# its own graph build (fixed geometry, only labels move) — safe to call
|
||
# on the unmerged tree, exactly as collapse_global's finish-time use does.
|
||
if self._collapse_insearch:
|
||
self.collapse_global(
|
||
root,
|
||
adjacency=self._collapse_insearch_adjacency,
|
||
objective="threshold",
|
||
preserve_public_access=True,
|
||
iters=self._collapse_insearch_iters,
|
||
)
|
||
|
||
# --- Phase 1: UNMERGED tree checks ---
|
||
check_fails, missing = graph_mod.check_space_counts(
|
||
root, programme, self._leaf_sharing, self._max_share,
|
||
self._multi_use, self.colocate_pairs())
|
||
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, self.usages()
|
||
)
|
||
|
||
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,
|
||
self._multi_use, self.colocate_pairs()))
|
||
failures.extend(graph_mod.check_level_constraints(
|
||
root, programme, missing, self._multi_use, self.colocate_pairs()))
|
||
failures.extend(graph_mod.check_vertical_connectivity(
|
||
root, programme, missing, self._multi_use, self.colocate_pairs()))
|
||
|
||
# --- 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, self.usages()
|
||
)
|
||
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 and not self._conn_grade: # §11.4 signal; off by default
|
||
for le in se.leaves:
|
||
grade += _leaf_grade(le.factors)
|
||
|
||
# §18 (homemaker-py-qi6): repurpose the grade channel for the graded
|
||
# circulation-connectivity signal — sum of per-level largest-circ-component
|
||
# fractions, higher when circulation is closer to a single connected spine.
|
||
# Secondary comparator key only; score and fail count are untouched.
|
||
if want_grade and self._conn_grade:
|
||
for gc in graph_circ:
|
||
grade += graph_mod.circulation_connectivity(gc)
|
||
|
||
building_factor = self.evaluate_building(root, tracking)
|
||
value *= building_factor
|
||
|
||
# 0.5^n failure penalty (programme-driven mode, not 0.1^n).
|
||
#
|
||
# homemaker-py-2v1: connectivity failures may carry EXTRA weight. Under
|
||
# the flat rule every failure costs x0.5, but severing a level's
|
||
# circulation *gains* value_inside/value_circulation = x6 (the corridor
|
||
# becomes habitable area when it merges into its sibling), so the
|
||
# objective was net-positive on destroying the spine — measured x4.06 on
|
||
# a well-daylit circulation leaf. ``connectivity_weight`` counts each
|
||
# connectivity fail as w failures; ``"auto"`` derives the smallest w that
|
||
# makes severing net-negative from the value rates themselves.
|
||
w = self._connectivity_weight
|
||
if w != 1.0:
|
||
n_conn = sum(1 for f in failures if is_connectivity_fail(f))
|
||
n_other = len(failures) - n_conn
|
||
value *= 0.5 ** (n_other + w * n_conn)
|
||
else:
|
||
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, validate_codes, validate_usages
|
||
_DW = (4.0, 1.0)
|
||
_DP = (1.5, 0.5)
|
||
spaces = conf.get("spaces") or {}
|
||
if not spaces:
|
||
self._programme_cache = None
|
||
return
|
||
# homemaker-py-ju3 (DESIGN.md §39.2): reject codes colliding with the
|
||
# generic c/o/s type prefixes here too — Fitness parses conf["spaces"]
|
||
# independently of programme._parse_spaces, so validating in only one
|
||
# of the two would leave the other door open.
|
||
validate_codes(spaces)
|
||
validate_usages(spaces)
|
||
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
|
||
_crink = _programme._optional_pair(c, "crinkliness")
|
||
reqs[code] = SpaceReq(
|
||
code=code,
|
||
usage=c["usage"],
|
||
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),
|
||
co_locate=list(c.get("co_locate") or []),
|
||
has_size="size" in c,
|
||
has_width="width" in c,
|
||
has_proportion="proportion" in c,
|
||
has_crinkliness="crinkliness" in c,
|
||
crinkliness=_crink[0],
|
||
crinkliness_sigma=_crink[1],
|
||
)
|
||
self._programme_cache = reqs
|