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:
parent
3bf507a483
commit
c01a8a0887
5 changed files with 896 additions and 12 deletions
99
experiments/dump_leaf_quality.pl
Normal file
99
experiments/dump_leaf_quality.pl
Normal 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
183
experiments/leaf_parity.py
Normal 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())
|
||||
|
|
@ -235,6 +235,48 @@ def _below_leaves(n: Node) -> "list[Node]":
|
|||
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:
|
||||
"""All leaves below n are indoor; mirrors ``Urb::Dom::Is_Supported``."""
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
488
src/homemaker/fitness.py
Normal file
488
src/homemaker/fitness.py
Normal 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)
|
||||
|
|
@ -86,7 +86,11 @@ def coord_b(n: Node) -> Point:
|
|||
|
||||
|
||||
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:
|
||||
|
|
@ -107,6 +111,29 @@ def edge_length(n: Node, idx: int) -> float:
|
|||
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).
|
||||
# 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)
|
||||
|
||||
|
||||
_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
|
||||
"""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.
|
||||
"""
|
||||
import networkx as nx
|
||||
from collections import defaultdict
|
||||
|
||||
_external = frozenset("abcd") # single-char external boundary ids
|
||||
leaves = level_root.leaves()
|
||||
# Group (leaf, edge) pairs by shared boundary id.
|
||||
# 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))
|
||||
groups = boundary_groups(level_root)
|
||||
|
||||
G: nx.Graph = nx.Graph()
|
||||
for leaf in leaves:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue