Native fitness: leaf quality terms + cost model (homemaker-py-gnw)

Port Urb's programme-driven fitness leaf quality factors (perpendicular,
proportion, size, width, crinkliness, daylight, access), value rates,
and cost model (per-leaf area costs, interior/exterior wall edge costs,
boundary costs) to Python.  Passes 0-mismatch parity against the Urb
oracle across all 35 corpus files (407 leaves, 2849 factors), using
URB_NO_OCCLUSION=1 simple crinkliness (illumination factor pinned to 1).

Key fixes: _dist must use math.sqrt not math.hypot (1-ULP difference
flips boundary overlap predicates); leaf-scope fail regex requires ^\d+/
prefix to exclude building-level failure messages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-13 07:59:21 +01:00
parent 3bf507a483
commit c01a8a0887
5 changed files with 896 additions and 12 deletions

View file

@ -0,0 +1,99 @@
#!/usr/bin/perl
# Parity oracle for homemaker-py-gnw: per-leaf quality factors and per-storey
# cost/value from Urb's programme-driven fitness.
#
# Stock urb-fitness.pl cannot emit the per-leaf DEBUG lines: Leaf.pm/Storey.pm
# copy $Urb::Dom::Fitness::DEBUG into a package var at *compile* time, before
# Fitness.pm's own `our $DEBUG = $ENV{DEBUG}` line has run, so the flag is
# permanently false. This wrapper sets those package vars after loading and
# also wraps process_storey to print each storey's cost/value subtotal.
#
# Usage (from the corpus directory, URB_NO_OCCLUSION required):
# cd examples/programme-house
# URB_NO_OCCLUSION=1 perl -I$URB/lib dump_leaf_quality.pl a.dom b.dom ...
#
# Output (stdout):
# === FILE <name>
# ...Leaf.pm debug lines (' quality perpendicular: <f>' x7, then
# 'leaf level: L id: I type: T rate: R area: A width: W proportion: P')
# STOREY <level_id> cost: <c> value: <v>
# INITIAL_COST <c>
# FAIL <message> (one per failure)
# SCORE <fitness>
use strict;
use warnings;
use 5.010;
use Urb::Dom;
use Urb::Dom::Fitness;
use Urb::Dom::Fitness::ProgrammeDriven;
use YAML;
use Carp;
die "URB_NO_OCCLUSION=1 required: native fitness ports simple crinkliness only\n"
unless $ENV{URB_NO_OCCLUSION};
# Re-enable the per-leaf/storey debug gates (see header) and route everything
# to STDOUT so output interleaves deterministically.
$Urb::Dom::Fitness::Leaf::DEBUG = 1;
$Urb::Dom::Fitness::Storey::DEBUG = 1;
$Urb::Dom::Fitness::DEBUG = 1;
{
no warnings 'redefine';
*Urb::Dom::Fitness::debug = sub { print join(' ', @_), "\n"; return 1 };
*Urb::Dom::Fitness::Base::debug = sub {
my @text = @_;
shift @text if ref $text[0]; # method form: drop $self
print join(' ', @text), "\n";
return 1;
};
}
my $orig_process_storey = \&Urb::Dom::Fitness::Storey::process_storey;
{
no warnings 'redefine';
*Urb::Dom::Fitness::Storey::process_storey = sub {
my @args = @_;
my $result = $orig_process_storey->(@args);
printf "STOREY %s cost: %.17g value: %.17g\n",
$args[5], $result->{cost}, $result->{value};
return $result;
};
}
# Config loading exactly as urb-fitness.pl (project-level then local).
my $config = undef;
for my $path ('../patterns.config', 'patterns.config')
{
next unless -e $path;
my $config_temp = YAML::LoadFile ($path);
$config->{$_} = $config_temp->{$_} for keys %{$config_temp};
}
my $costs = undef;
for my $path ('../costs.config', 'costs.config')
{
next unless -e $path;
my $costs_temp = YAML::LoadFile ($path);
$costs->{$_} = $costs_temp->{$_} for keys %{$costs_temp};
}
die "no spaces config: wrapper only supports programme-driven mode\n"
unless $config && exists $config->{spaces};
my $assessor = Urb::Dom::Fitness::ProgrammeDriven->new ($config, $costs);
for my $path_yaml (@ARGV)
{
my @items = YAML::LoadFile ($path_yaml);
next unless scalar @items;
my $dom = Urb::Dom->new;
$dom->Deserialise (@items);
say "=== FILE $path_yaml";
printf "INITIAL_COST %.17g\n", $assessor->Cost ('plot') * $dom->Area;
my $score = $assessor->_apply ($dom);
say "FAIL $_" for ($dom->Failures);
printf "SCORE %.17g\n", $score;
$dom->DESTROY;
}
0;

183
experiments/leaf_parity.py Normal file
View file

@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""homemaker-py-gnw acceptance: per-leaf quality factors and per-storey
cost/value parity between homemaker.fitness and Urb's programme-driven fitness.
Runs experiments/dump_leaf_quality.pl (URB_NO_OCCLUSION oracle with per-leaf
DEBUG re-enabled) over the corpus, parses its output, and diffs against the
native evaluation: 7 quality factors + rate + area per leaf, cost/value per
storey, initial (plot) cost, and the leaf-scope failure set.
Usage: python3 experiments/leaf_parity.py [corpus_dir]
"""
from __future__ import annotations
import math
import re
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from homemaker import dom, fitness, graph # noqa: E402
URB = Path("/home/bruno/src/urb")
CORPUS = Path(sys.argv[1]) if len(sys.argv) > 1 else URB / "examples" / "programme-house"
WRAPPER = Path(__file__).resolve().parent / "dump_leaf_quality.pl"
REL_TOL = 1e-9
# Failures emitted by the gnw scope (leaf quality + cost functions + the two
# per-leaf structural checks in process_storey). All carry a "level/" prefix —
# requiring it excludes building-level fails like 'no outside public access'
# (homemaker-py-hgg scope), which would otherwise match the ' access' suffix.
_LEAF_FAIL_RE = re.compile(
r"^\d+/.* (perpendicular|proportion|size|width|crinkliness|daylight|access"
r"|edge too long|unsupported covered outside|covered outside above ground)$"
)
_FACTOR_RE = re.compile(r"^ quality (\w+): (\S+)$")
_LEAF_RE = re.compile(
r"^leaf level: (\d+) id: ([lr]*) type: (\S+) rate: (\S+) area: (\S+) "
r"width: (\S+) proportion: (\S+)$"
)
_STOREY_RE = re.compile(r"^STOREY (\d+) cost: (\S+) value: (\S+)$")
def run_oracle(files: list[str]) -> dict[str, dict]:
"""Run the wrapper over all files; return per-file parsed records."""
proc = subprocess.run(
["perl", f"-I{URB}/lib", str(WRAPPER)] + files,
cwd=CORPUS,
env={"URB_NO_OCCLUSION": "1", "PATH": "/usr/bin:/bin"},
capture_output=True,
text=True,
)
if proc.returncode != 0:
sys.exit(f"wrapper failed:\n{proc.stderr}")
out: dict[str, dict] = {}
rec = None
pending: dict[str, float] = {}
for line in proc.stdout.splitlines():
if line.startswith("=== FILE "):
rec = out[line[len("=== FILE "):]] = {
"leaves": {}, "storeys": {}, "fails": [], "initial_cost": None,
"score": None,
}
pending = {}
continue
if rec is None:
continue
m = _FACTOR_RE.match(line)
if m:
pending[m.group(1)] = float(m.group(2))
continue
m = _LEAF_RE.match(line)
if m:
level, lid = int(m.group(1)), m.group(2)
rec["leaves"][(level, lid)] = {
"type": m.group(3),
"rate": float(m.group(4)),
"area": float(m.group(5)),
"factors": pending,
}
pending = {}
continue
m = _STOREY_RE.match(line)
if m:
rec["storeys"][int(m.group(1))] = (float(m.group(2)), float(m.group(3)))
continue
if line.startswith("INITIAL_COST "):
rec["initial_cost"] = float(line.split()[1])
elif line.startswith("FAIL "):
rec["fails"].append(line[len("FAIL "):])
elif line.startswith("SCORE "):
rec["score"] = float(line.split()[1])
return out
def native_eval(path: Path, fit: fitness.Fitness) -> dict:
root = dom.load(str(path))
fails: list[str] = []
fit.preprocess_building(root)
dom.merge_divided(root)
graphs = graph.build_graphs(root)
rec: dict = {"leaves": {}, "storeys": {}, "fails": fails,
"initial_cost": fit.plot_cost(root)}
for li, lvl in enumerate(dom.levels(root)):
se = fit.process_storey(lvl, graphs[li], li, fails.append)
rec["storeys"][li] = (se.cost, se.value)
for le in se.leaves:
rec["leaves"][(le.level, le.id)] = {
"type": le.type, "rate": le.rate, "area": le.area,
"factors": le.factors,
}
return rec
def close(a: float, b: float) -> bool:
return math.isclose(a, b, rel_tol=REL_TOL, abs_tol=1e-12)
def main() -> int:
files = sorted(p.name for p in CORPUS.glob("*.dom"))
conf, cost = fitness.load_config(CORPUS)
fit = fitness.Fitness(conf, cost)
oracle = run_oracle(files)
n_leaves = n_factors = 0
bad: dict[str, list[str]] = defaultdict(list)
for name in files:
o = oracle[name]
n = native_eval(CORPUS / name, fit)
if not close(o["initial_cost"], n["initial_cost"]):
bad[name].append(
f"initial cost {o['initial_cost']} != {n['initial_cost']}")
if set(o["leaves"]) != set(n["leaves"]):
bad[name].append(
f"leaf sets differ: oracle-only {sorted(set(o['leaves']) - set(n['leaves']))}, "
f"native-only {sorted(set(n['leaves']) - set(o['leaves']))}")
for key in sorted(set(o["leaves"]) & set(n["leaves"])):
ol, nl = o["leaves"][key], n["leaves"][key]
n_leaves += 1
for fld in ("rate", "area"):
if not close(ol[fld], nl[fld]):
bad[name].append(f"leaf {key} {fld}: {ol[fld]} != {nl[fld]}")
if ol["type"] != nl["type"]:
bad[name].append(f"leaf {key} type: {ol['type']} != {nl['type']}")
for fac, ov in ol["factors"].items():
n_factors += 1
nv = nl["factors"].get(fac)
if nv is None or not close(ov, nv):
bad[name].append(f"leaf {key} {fac}: {ov} != {nv}")
for li, (oc, ov) in o["storeys"].items():
nc, nv = n["storeys"].get(li, (None, None))
if nc is None or not close(oc, nc):
bad[name].append(f"storey {li} cost: {oc} != {nc}")
if nv is None or not close(ov, nv):
bad[name].append(f"storey {li} value: {ov} != {nv}")
o_fails = sorted(f for f in o["fails"] if _LEAF_FAIL_RE.search(f))
n_fails = sorted(n["fails"])
if o_fails != n_fails:
bad[name].append(
f"leaf fails differ:\n oracle: {o_fails}\n native: {n_fails}")
for name in sorted(bad):
print(f"MISMATCH {name}")
for msg in bad[name]:
print(f" {msg}")
print(f"\n{len(files)} files, {n_leaves} leaves, {n_factors} factors compared; "
f"{len(bad)} files with mismatches")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -235,6 +235,48 @@ def _below_leaves(n: Node) -> "list[Node]":
return bm.leaves() if bm is not None else [] return bm.leaves() if bm is not None else []
def _above_node(n: Node) -> "Node | None":
"""Matching node on the level above; mirrors ``Urb::Quad::Above``."""
lr = _level_root(n)
if lr.above is None:
return None
return lr.above.by_id(n.id)
def _above_more(n: Node) -> "Node | None":
"""Matching node on level above, walking up to parent if path absent;
mirrors ``Urb::Quad::Above_More``."""
if _level_root(n).above is None:
return None
a = _above_node(n)
if a is not None:
return a
if n.parent is None:
return None
return _above_more(n.parent)
def _above_leaves(n: Node) -> "list[Node]":
am = _above_more(n)
return am.leaves() if am is not None else []
def level_of(n: Node) -> int:
"""Storey index of n (0 = ground); mirrors ``Urb::Quad::Level``
(= number of levels below n's level root)."""
lr = _level_root(n)
i = 0
while lr.below is not None:
lr = lr.below
i += 1
return i
def is_covered(n: Node) -> bool:
"""Any leaf above n is indoor; mirrors ``Urb::Dom::Is_Covered``."""
return any(not is_outside(lf) for lf in _above_leaves(n))
def is_supported(n: Node) -> bool: def is_supported(n: Node) -> bool:
"""All leaves below n are indoor; mirrors ``Urb::Dom::Is_Supported``.""" """All leaves below n are indoor; mirrors ``Urb::Dom::Is_Supported``."""
bl = _below_leaves(n) bl = _below_leaves(n)
@ -247,6 +289,23 @@ def is_unsupported(n: Node) -> bool:
return bool(bl) and all(is_outside(lf) for lf in bl) return bool(bl) and all(is_outside(lf) for lf in bl)
def is_usable(n: Node) -> bool:
"""Indoors, or on the ground floor, or supported by building below;
mirrors ``Urb::Dom::Is_Usable``."""
if not is_outside(n):
return True
if level_of(n) == 0:
return True
return is_supported(n)
def is_circulation(n: Node) -> bool:
"""Usable and type 'c'/'s'; mirrors ``Urb::Dom::Is_Circulation``."""
if not is_usable(n):
return False
return n.type is not None and n.type[0].lower() in ("c", "s")
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Merge_Divided (Urb::Dom::Merge_Divided) # Merge_Divided (Urb::Dom::Merge_Divided)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #

488
src/homemaker/fitness.py Normal file
View file

@ -0,0 +1,488 @@
"""Native port of Urb's programme-driven fitness: leaf quality terms + cost model.
Scope (homemaker-py-gnw): per-leaf quality factors (perpendicular, proportion,
size, width, crinkliness, daylight, access), the programme-driven parameter
lookup chain (``get_space_params``), value rates, and the cost denominator
(per-leaf area costs, interior/exterior wall edge costs, boundary costs).
Storey/building checks, staircases, failure stacking and final assembly are
homemaker-py-hgg; corpus-parity validation is homemaker-py-uxz.
Source of truth: ``Urb::Dom::Fitness::{Base,Leaf,Storey,ProgrammeDriven}``.
DESCOPE (DESIGN.md §6, decision 2026-06-12): this ports *simple* crinkliness
the CIEsky illumination factor is pinned to 1, exactly what Urb computes under
``URB_NO_OCCLUSION=1``. ``quality_daylight`` is likewise pinned to 1. Parity
targets the *flagged* oracle, never stock Urb.
Call ``dom.merge_divided(root)`` and rebuild graphs before ``process_storey``
storey processing runs on the MERGED tree (two-phase pattern, see graph.py).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
import networkx as nx
import yaml
from . import dom as dom_mod
from . import geometry
from .dom import Node
FAIL_THRESHOLD = 0.1 # Urb::Dom::Fitness::Base
# Urb::Dom::Fitness::Base $CONF — keep values byte-identical to the Perl
# expressions (5.0/6 etc. evaluate to the same IEEE doubles in both languages).
CONF_DEFAULTS: dict = {
"value_inside": 300.0,
"value_circulation": 50.0,
"value_outside": 100.0,
"value_supported": 300.0,
"storey_limit": 4,
"storey_minimum": 2,
"latitude": 53.3814,
"door_width": 1.2,
"plot_ratio": [2.00, 0.50],
"ratio_outside": [0.33, 0.15],
"ratio_circulation": [0.00, 0.20],
"uncrinkliness": [5.0 / 6, 1.1 / 3],
"uncrinkliness_circulation": [5.0 / 6, 1.1 / 3],
"size_circulation": [0.0, 14.0],
"size_inside": [16.0, 3.5],
"proportion_outside": [1.5, 50],
"proportion_circulation": [1.5, 0.5],
"proportion_inside": [1.5, 0.5],
"width_outside": [3.0, 0.3],
"width_circulation": [2.4, 0.2],
"width_inside": [4.0, 1.0],
"perpendicular_inside": 0.3,
"perpendicular_outside": 10.0,
"allow_sahn_circulation": 0,
"force_roof_garden": 1,
"evaluate_room_types": 1,
}
# Urb::Dom::Fitness::Base $COST
COST_DEFAULTS: dict = {
"plot": 10.0,
"outside_covered_supported": 210.0,
"outside_covered": 110.0,
"outside_supported": 110.0,
"outside": 10.0,
"inside": 200.0,
"interior_wall": 200.0 / 3,
"exterior_wall": 100.0,
"boundary": 50.0 / 3,
"boundary_wall": 400.0 / 3,
}
# ProgrammeDriven::default_params ultimate fallbacks
_PARAM_FALLBACKS = {
"size": [16.0, 3.5],
"width": [4.0, 1.0],
"proportion": [1.5, 0.5],
}
_E = 2.718281828 # Urb::Math::gaussian uses this truncated e, not math.e
def gaussian(x: float, a: float, b: float, c: float) -> float:
"""Bit-faithful port of ``Urb::Math::gaussian`` (note the truncated e)."""
return a * (_E ** (0 - ((x - b) ** 2 / (2 * c * c))))
def load_config(directory: str | Path) -> tuple[dict, dict]:
"""Load (patterns, costs) config for a corpus directory, mirroring
``urb-fitness.pl``: project-level ``../<name>.config`` first, then the
local file's keys override it."""
directory = Path(directory)
conf: dict = {}
cost: dict = {}
for target, name in ((conf, "patterns.config"), (cost, "costs.config")):
for p in (directory.parent / name, directory / name):
if p.is_file():
with open(p) as fh:
target.update(yaml.safe_load(fh) or {})
return conf, cost
@dataclass
class LeafEval:
level: int
id: str
type: str
area: float
rate: float
quality: float
factors: dict[str, float] = field(default_factory=dict)
@dataclass
class StoreyEval:
cost: float
value: float
leaves: list[LeafEval] = field(default_factory=list)
def _t0(n: Node) -> str:
"""First char of the type, lowercased ('' if untyped) — Urb's /^x/i tests."""
return n.type[0].lower() if n.type else ""
def _height(n: Node) -> float:
"""Floor-to-floor height of n's level; mirrors ``Urb::Quad::Height``."""
h = dom_mod._level_root(n).height
return h if h is not None else 3.0
def _perimeter(n: Node) -> dict:
"""Perimeter dict from the lowest level root (``Urb::Quad::Perimeter``)."""
lr = dom_mod._level_root(n)
while lr.below is not None:
lr = lr.below
return lr.perimeter or {}
class Fitness:
"""Programme-driven leaf quality + cost evaluation.
``conf`` is the parsed patterns.config mapping (including ``spaces``);
``cost`` the costs.config mapping. Lookup falls back to the Base.pm
defaults, as ``Urb::Dom::Fitness::Base::Conf/Cost`` do.
"""
def __init__(self, conf: dict | None = None, cost: dict | None = None):
self._conf = conf or {}
self._cost = cost or {}
self.spaces: dict = self._conf.get("spaces") or {}
def conf(self, key: str):
v = self._conf.get(key)
if v is not None:
return v
return CONF_DEFAULTS.get(key)
def cost(self, key: str) -> float:
v = self._cost.get(key)
if v is not None:
return v
return COST_DEFAULTS.get(key, 0.0)
def preprocess_building(self, root: Node) -> None:
"""Sahn-to-Outside type conversion (``Building.pm::preprocess_building``).
Run BEFORE graph build and merge_divided it changes merge outcomes."""
if self.conf("allow_sahn_circulation"):
return
for lvl in dom_mod.levels(root):
for leaf in lvl.leaves():
if _t0(leaf) == "s":
leaf.type = "O"
# ------------------------------------------------------------------ #
# Programme-driven parameter lookup (ProgrammeDriven.pm:29-69)
# ------------------------------------------------------------------ #
def get_space_params(self, code: str, param: str) -> list[float]:
c0 = code[0].lower() if code else ""
if c0 == "c":
v = self.conf(f"{param}_circulation")
if v is not None:
return v
if c0 in ("o", "s"):
v = self.conf(f"{param}_outside")
if v is not None:
return v
sp = self.spaces.get(code) # exact-key match, as in Perl
if sp is not None and param in sp:
return sp[param]
v = self.conf(f"{param}_inside")
if v is not None:
return v
return _PARAM_FALLBACKS.get(param)
# ------------------------------------------------------------------ #
# Quality terms (Leaf.pm)
# ------------------------------------------------------------------ #
def quality_perpendicular(self, leaf: Node) -> float:
sigma = self.conf(
"perpendicular_outside" if dom_mod.is_outside(leaf) else "perpendicular_inside"
)
score = 1.0
for i in range(4):
# 1.570796: Urb::Dom::Perpendicular hard-codes this, not pi/2
score *= gaussian(geometry.angle(leaf, i), 1.0, 1.570796, sigma)
return score
def quality_proportion(self, leaf: Node) -> float:
t0 = _t0(leaf)
if t0 in ("o", "s"):
params = self.conf("proportion_outside")
elif t0 == "c":
params = self.conf("proportion_circulation")
else:
params = self.get_space_params(leaf.type, "proportion")
aspect = geometry.aspect(leaf)
if aspect < params[0]:
return 1.0
return gaussian(aspect, 1.0, params[0], params[1])
def quality_size(self, leaf: Node) -> float:
t0 = _t0(leaf)
if t0 in ("o", "s"):
return 1.0
if t0 == "c":
params = self.conf("size_circulation")
else:
params = self.get_space_params(leaf.type, "size")
return gaussian(geometry.area(leaf), 1.0, params[0], params[1])
def quality_width(self, leaf: Node) -> float:
t0 = _t0(leaf)
if (
t0 in ("o", "s")
and not dom_mod.is_covered(leaf)
and not dom_mod.is_supported(leaf)
and dom_mod.level_of(leaf)
):
return 1.0
if t0 in ("o", "s"):
params = self.conf("width_outside")
elif t0 == "c":
params = self.conf("width_circulation")
else:
params = self.get_space_params(leaf.type, "width")
width = geometry.length_narrowest(leaf)
if width > params[0]:
return 1.0
return gaussian(width, 1.0, params[0], params[1])
# --- simple crinkliness (URB_NO_OCCLUSION: illumination factor = 1) --- #
def area_outside(self, leaf: Node, G: nx.Graph, groups: dict) -> float:
"""Illuminated external wall area; ``Urb::Dom::Area_Outside`` with the
CIEsky illumination factor pinned to 1 (simple crinkliness)."""
length = 0.0
for nb in G.neighbors(leaf):
if not dom_mod.is_outside(nb) or dom_mod.is_covered(nb):
continue
# Faithful loop over all internal boundaries: Overlap() is > 0
# only on a boundary both quads actually share an edge of.
for contributors in groups.values():
if geometry.boundary_pair_overlap(contributors, leaf, nb) > 0:
length += G[leaf][nb]["width"]
perimeter = _perimeter(leaf)
for e in range(4):
bid = geometry.boundary_id(leaf, e)
if bid not in geometry._EXTERNAL:
continue
ptype = (perimeter.get(bid) or "").lower()
if ptype in ("private", "fortified"):
continue
length += geometry.edge_length(leaf, e)
return length * _height(leaf)
def crinkliness(self, leaf: Node, G: nx.Graph, groups: dict) -> float:
area = geometry.area(leaf)
if not area:
return 9999999999
return self.area_outside(leaf, G, groups) / area
def quality_uncrinkliness(self, leaf: Node, G: nx.Graph, groups: dict) -> float:
if dom_mod.is_outside(leaf) and not dom_mod.is_covered(leaf):
return 1.0
key = "uncrinkliness_circulation" if dom_mod.is_circulation(leaf) else "uncrinkliness"
distance, sigma = self.conf(key)
crink = self.crinkliness(leaf, G, groups)
if not crink:
return 0.0
return gaussian(1 / crink, 1.0, distance, sigma)
# --- access --- #
def neighbour_types(self, leaf: Node, G: nx.Graph) -> list[str]:
return sorted(nb.type or "" for nb in G.neighbors(leaf) if dom_mod.is_usable(nb))
def access(self, leaf: Node, G: nx.Graph) -> list[str]:
"""Useful circulation/access neighbour types; ``Urb::Dom::Access``."""
types = self.neighbour_types(leaf, G)
if _t0(leaf) == "k":
return [t for t in types if t and t[0].lower() in ("l", "c", "s")]
if dom_mod.is_outside(leaf) or dom_mod.is_circulation(leaf):
return types
return [t for t in types if t and t[0].lower() in ("c", "s")]
# ------------------------------------------------------------------ #
# Leaf evaluation (Leaf.pm::evaluate_leaf)
# ------------------------------------------------------------------ #
def evaluate_leaf(
self, leaf: Node, G: nx.Graph, level_id: int, groups: dict, fail
) -> tuple[float, dict[str, float]]:
"""Return (quality, per-factor dict); appends failures via ``fail``.
Factor order and fail strings mirror ``evaluate_leaf`` exactly.
"""
lid = leaf.id
factors: dict[str, float] = {}
quality = 1.0
f = self.quality_perpendicular(leaf)
if f < FAIL_THRESHOLD:
fail(f"{level_id}/{lid} perpendicular")
factors["perpendicular"] = f
quality *= f
f = self.quality_proportion(leaf)
if f < FAIL_THRESHOLD:
fail(f"{level_id}/{lid} proportion")
factors["proportion"] = f
quality *= f
f = self.quality_size(leaf)
if f < FAIL_THRESHOLD:
fail(f"{level_id}/{lid} size")
factors["size"] = f
quality *= f
f = self.quality_width(leaf)
if f < FAIL_THRESHOLD:
fail(f"{level_id}/{lid} width")
factors["width"] = f
quality *= f
f = self.quality_uncrinkliness(leaf, G, groups)
if f < FAIL_THRESHOLD:
fail(f"{level_id}/{lid} crinkliness")
factors["crinkliness"] = f
quality *= f
# Daylight pinned to 1 — URB_NO_OCCLUSION semantics (DESIGN.md §6).
factors["daylight"] = 1.0
if len(self.access(leaf, G)) > 0:
f = 1.0
elif not dom_mod.level_of(leaf) and dom_mod.is_outside(leaf):
f = 1.0
else:
f = 0.01
fail(f"{level_id}/{lid} access")
factors["access"] = f
quality *= f
return quality, factors
# ------------------------------------------------------------------ #
# Value rates and costs (Leaf.pm:146-251, Storey.pm:122-147)
# ------------------------------------------------------------------ #
def value_rate(self, leaf: Node) -> float:
t0 = _t0(leaf)
if t0 in ("o", "s") and dom_mod.level_of(leaf) == 0:
return self.conf("value_outside")
if t0 in ("o", "s"):
return self.conf("value_supported")
if t0 == "c":
return self.conf("value_circulation")
return self.conf("value_inside")
def leaf_cost(self, leaf: Node) -> float:
if dom_mod.is_outside(leaf):
covered = dom_mod.is_covered(leaf)
supported = dom_mod.is_supported(leaf)
if covered and supported:
rate = self.cost("outside_covered_supported")
elif covered:
rate = self.cost("outside_covered")
elif supported:
rate = self.cost("outside_supported")
else:
rate = self.cost("outside")
else:
rate = self.cost("inside")
return rate * geometry.area(leaf)
def edge_cost(self, G: nx.Graph, a: Node, b: Node, fail) -> float:
"""Interior/exterior wall cost for one graph edge
(``Storey.pm::calculate_edge_cost``)."""
height = _height(a)
a_out, b_out = dom_mod.is_outside(a), dom_mod.is_outside(b)
if a_out and b_out:
rate = 0.0
elif not a_out and not b_out:
rate = self.cost("interior_wall")
else:
rate = self.cost("exterior_wall")
width = G[a][b]["width"]
if width > 8.0 and rate > 0.0:
fail(f"{dom_mod.level_of(a)}/{a.id} {b.id} edge too long")
return rate * width * height
def outside_edge_cost(self, leaf: Node, fail) -> float:
"""Plot-boundary cost for a leaf's external edges
(``Leaf.pm::calculate_outside_edge_cost``)."""
rate = self.cost("boundary") if dom_mod.is_outside(leaf) else self.cost("boundary_wall")
length = 0.0
for e in range(4):
if geometry.boundary_id(leaf, e) not in geometry._EXTERNAL:
continue
edge_len = geometry.edge_length(leaf, e)
length += edge_len
if dom_mod.is_outside(leaf):
continue
if edge_len > 8.0:
fail(f"{dom_mod.level_of(leaf)}/{leaf.id} outside edge too long")
return rate * length * _height(leaf)
# ------------------------------------------------------------------ #
# Storey processing (Storey.pm::process_storey — cost/value/leaf scope)
# ------------------------------------------------------------------ #
def process_storey(self, level_root: Node, G: nx.Graph, level_id: int, fail) -> StoreyEval:
"""Per-storey cost, value and leaf evaluations on the MERGED tree.
Covers the cost/value accumulation and per-leaf checks of
``process_storey``; circulation connectivity, roof-garden, stair fit
and tracking-driven building checks are homemaker-py-hgg.
"""
groups = geometry.boundary_groups(level_root)
cost = 0.0
value = 0.0
leaves_eval: list[LeafEval] = []
for leaf in level_root.leaves():
if dom_mod.is_outside(leaf) and dom_mod.is_covered(leaf) and level_id:
if not dom_mod.is_supported(leaf):
fail(f"{level_id}/{leaf.id} unsupported covered outside")
fail(f"{level_id}/{leaf.id} covered outside above ground")
cost += self.leaf_cost(leaf)
if not dom_mod.is_usable(leaf):
continue
quality, factors = self.evaluate_leaf(leaf, G, level_id, groups, fail)
rate = self.value_rate(leaf)
value += quality * rate * geometry.area(leaf)
leaves_eval.append(
LeafEval(
level=level_id,
id=leaf.id,
type=leaf.type or "",
area=geometry.area(leaf),
rate=rate,
quality=quality,
factors=factors,
)
)
for a, b in G.edges():
cost += self.edge_cost(G, a, b, fail)
for leaf in level_root.leaves():
cost += self.outside_edge_cost(leaf, fail)
return StoreyEval(cost=cost, value=value, leaves=leaves_eval)
def plot_cost(self, root: Node) -> float:
"""The 'initial cost' term: plot rate x lowest-root area."""
return self.cost("plot") * geometry.area(root)

View file

@ -86,7 +86,11 @@ def coord_b(n: Node) -> Point:
def _dist(a: Point, b: Point) -> float: def _dist(a: Point, b: Point) -> float:
return math.hypot(a[0] - b[0], a[1] - b[1]) # NOT math.hypot: Urb::Math::distance_2d is sqrt(dx**2 + dy**2) and the
# two differ in the last ULP. Boundary overlap tests feed the difference
# of near-equal lengths into a > 0 predicate (Urb::Boundary::Overlap), so
# a 1-ULP deviation flips adjacency decisions on exactly-touching quads.
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
def _triangle_area(a: Point, b: Point, c: Point) -> float: def _triangle_area(a: Point, b: Point, c: Point) -> float:
@ -107,6 +111,29 @@ def edge_length(n: Node, idx: int) -> float:
return _dist(coordinate(n, idx), coordinate(n, (idx + 1) % 4)) return _dist(coordinate(n, idx), coordinate(n, (idx + 1) % 4))
def angle(n: Node, idx: int) -> float:
"""Interior angle at corner ``idx`` in radians (``Urb::Quad::Angle``,
cosine rule). Clamped acos argument Perl leaves it unclamped but only a
degenerate quad would push it out of [-1, 1]."""
a = edge_length(n, idx)
b = edge_length(n, (idx + 3) % 4)
c = _dist(coordinate(n, (idx + 1) % 4), coordinate(n, (idx + 3) % 4))
return math.acos(max(-1.0, min(1.0, (a * a + b * b - c * c) / (2 * a * b))))
def aspect(n: Node) -> float:
"""Plan aspect ratio, always >= 1 (``Urb::Quad::Aspect``)."""
asp = (edge_length(n, 0) + edge_length(n, 2)) / (edge_length(n, 1) + edge_length(n, 3))
if 0 < asp < 1:
asp = 1 / asp
return asp
def length_narrowest(n: Node) -> float:
"""Shortest of the four edge lengths (``Urb::Quad::Length_Narrowest``)."""
return min(edge_length(n, i) for i in range(4))
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Plot wall inset (Urb::Quad::Coordinate_Offset, used on the root in Urb::Dom). # Plot wall inset (Urb::Quad::Coordinate_Offset, used on the root in Urb::Dom).
# Positive offset moves a corner outward, negative inward. Computed per corner # Positive offset moves a corner outward, negative inward. Computed per corner
@ -210,6 +237,44 @@ def _edge_overlap(a: Node, edge_a: int, b: Node, edge_b: int) -> float:
return max(0.0, len_a + len_b - max_dist) return max(0.0, len_a + len_b - max_dist)
_EXTERNAL = frozenset("abcd") # single-char external boundary ids
def boundary_groups(level_root: Node) -> dict[str, list[tuple[Node, int]]]:
"""Group (leaf, edge) pairs by shared internal boundary id; the data half
of ``Urb::Quad::Calc_Boundaries`` (external 'a'-'d' boundaries excluded
Urb's ``Boundary::Overlap`` returns 0 for them anyway).
Membership test uses the frozenset, NOT ``bid not in "abcd"`` the latter
is a substring check and silently drops the root-division boundary ('').
"""
from collections import defaultdict
groups: dict[str, list[tuple[Node, int]]] = defaultdict(list)
for leaf in level_root.leaves():
for edge in range(4):
bid = boundary_id(leaf, edge)
if bid not in _EXTERNAL:
groups[bid].append((leaf, edge))
return groups
def boundary_pair_overlap(contributors: list[tuple[Node, int]], a: Node, b: Node) -> float:
"""Overlap of quads ``a`` and ``b`` on one boundary's contributor list;
mirrors ``Urb::Boundary::Overlap`` (last matching edge wins, as in the
Perl loop). Returns 0.0 if either quad has no edge on this boundary.
"""
edge_a = edge_b = None
for leaf, edge in contributors:
if leaf is a:
edge_a = edge
if leaf is b:
edge_b = edge
if edge_a is None or edge_b is None:
return 0.0
return _edge_overlap(a, edge_a, b, edge_b)
def leaf_graph(level_root: Node, door_width: float = 1.2): # -> nx.Graph def leaf_graph(level_root: Node, door_width: float = 1.2): # -> nx.Graph
"""Leaf-adjacency graph for one storey; mirrors ``Urb::Quad::Graph``. """Leaf-adjacency graph for one storey; mirrors ``Urb::Quad::Graph``.
@ -220,19 +285,9 @@ def leaf_graph(level_root: Node, door_width: float = 1.2): # -> nx.Graph
never edges. A single-leaf storey gets one isolated vertex. never edges. A single-leaf storey gets one isolated vertex.
""" """
import networkx as nx import networkx as nx
from collections import defaultdict
_external = frozenset("abcd") # single-char external boundary ids
leaves = level_root.leaves() leaves = level_root.leaves()
# Group (leaf, edge) pairs by shared boundary id. groups = boundary_groups(level_root)
# Use frozenset membership, NOT 'bid not in "abcd"' — the latter is a
# substring check and silently drops the root-division boundary ('').
groups: dict[str, list[tuple[Node, int]]] = defaultdict(list)
for leaf in leaves:
for edge in range(4):
bid = boundary_id(leaf, edge)
if bid not in _external:
groups[bid].append((leaf, edge))
G: nx.Graph = nx.Graph() G: nx.Graph = nx.Graph()
for leaf in leaves: for leaf in leaves: