Add programme/solver/oracle + sizing experiments (negative result)
Adds the bottom-up ratio solver, programme parser, Perl-oracle bridge, and two experiments. Headline finding: the "isolated size solver on a frozen topology" hypothesis is NOT validated. - resolve_ratios.py: re-solving candidate-002 from programme targets recovers areas accurately but scores below the original (introduces width/perpendicular/crinkliness failures the area objective ignores). - refine_sweep.py: warm-start refine of all 34 evolved candidates regresses 34/34 (fails 124->297 perpendicular-tied; 124->626 area-only with free skew). Moving cuts to fix room area breaks the coupled adjacency/access/shape constraints those designs balanced. Conclusion: sizing is not separable from the rest of Urb's fitness; a geometry inner loop must optimise the full objective, not an area proxy. Geometry port remains validated byte-identical to Urb. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0366392da4
commit
497d05c343
7 changed files with 482 additions and 13 deletions
79
experiments/refine_sweep.py
Normal file
79
experiments/refine_sweep.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Population sweep: warm-start refine every evolved candidate and tally results.
|
||||
|
||||
For each real .dom in the example dir, score it, run the solver as a geometry
|
||||
optimiser (warm-start, no strip), and re-score. Reports how often bottom-up
|
||||
sizing improves vs regresses total fitness, plus aggregate fail-count change.
|
||||
|
||||
This is a breadth check on the solver-as-optimiser role; raw fitness is still
|
||||
confounded by the 0.5^n failure cliff and any topological defects, so the
|
||||
fail-count and per-candidate detail matter as much as the win/loss tally.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from homemaker import dom, oracle, programme, solver # noqa: E402
|
||||
|
||||
URB = Path("/home/bruno/src/urb")
|
||||
EX = URB / "examples/programme-house"
|
||||
|
||||
|
||||
def _is_candidate(p: Path) -> bool:
|
||||
# real designs: 32-hex hashes or candidate-NNN; skip init and our scratch
|
||||
name = p.stem
|
||||
return name not in {"init", "original", "roundtrip", "solved", "refined"}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
scratch = Path(__file__).resolve().parents[1] / "scratch"
|
||||
scratch.mkdir(exist_ok=True)
|
||||
shutil.copy(EX / "patterns.config", scratch / "patterns.config")
|
||||
targets = programme.load_programme(str(EX / "patterns.config"))
|
||||
|
||||
doms = sorted(p for p in EX.glob("*.dom") if _is_candidate(p))
|
||||
win = loss = tie = 0
|
||||
fails_before = fails_after = 0
|
||||
rows = []
|
||||
for src in doms:
|
||||
try:
|
||||
shutil.copy(src, scratch / "orig.dom")
|
||||
s0 = oracle.score(scratch / "orig.dom", URB)
|
||||
root = dom.load(str(src))
|
||||
# gentlest refiner: nudge cut POSITIONS for programme-room area only,
|
||||
# keep evolved cut angles and leave circulation/shape untouched.
|
||||
solver.solve_ratios(
|
||||
root, targets, strip=False, perpendicular=False,
|
||||
weight_width=0.0, weight_proportion=0.0, min_width_generic=0.0,
|
||||
)
|
||||
dom.dump(root, str(scratch / "ref.dom"))
|
||||
s1 = oracle.score(scratch / "ref.dom", URB)
|
||||
except Exception as e: # noqa: BLE001
|
||||
rows.append(f" {src.name:40s} ERROR {e}")
|
||||
continue
|
||||
fails_before += s0.n_fails
|
||||
fails_after += s1.n_fails
|
||||
if s1.fitness > s0.fitness * 1.001:
|
||||
win += 1
|
||||
mark = "+"
|
||||
elif s1.fitness < s0.fitness * 0.999:
|
||||
loss += 1
|
||||
mark = "-"
|
||||
else:
|
||||
tie += 1
|
||||
mark = "="
|
||||
rows.append(
|
||||
f" {mark} {src.name:40s} {s0.fitness:.4g} -> {s1.fitness:.4g}"
|
||||
f" fails {s0.n_fails}->{s1.n_fails}"
|
||||
)
|
||||
|
||||
print("\n".join(rows))
|
||||
n = win + loss + tie
|
||||
print(f"\n{n} candidates: {win} improved, {loss} regressed, {tie} tied")
|
||||
print(f"total fails: {fails_before} -> {fails_after}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
90
experiments/resolve_ratios.py
Normal file
90
experiments/resolve_ratios.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Go/no-go experiment for bottom-up sizing.
|
||||
|
||||
Take a real evolved .dom, throw away its division ratios, and re-solve them from
|
||||
the programme's target sizes alone. Score three versions through the Perl oracle:
|
||||
|
||||
original -- the evolved .dom as-is (baseline)
|
||||
roundtrip -- loaded and re-emitted unmodified (checks dump fidelity)
|
||||
solved -- ratios stripped to 0.5 then solved from programme targets
|
||||
|
||||
If `solved` scores >= `original`, sizing can be recovered from the programme
|
||||
without the evolved geometry, and the EA only needs to search topology.
|
||||
|
||||
Usage:
|
||||
python experiments/resolve_ratios.py [source.dom] [--urb /path/to/urb]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from homemaker import dom, oracle, programme, solver # noqa: E402
|
||||
|
||||
DEFAULT_URB = Path("/home/bruno/src/urb")
|
||||
DEFAULT_SRC = DEFAULT_URB / "examples/programme-house/candidate-002.dom"
|
||||
|
||||
|
||||
def _score_in(scratch: Path, name: str, root: dom.Node, urb: Path) -> oracle.Score:
|
||||
path = scratch / name
|
||||
dom.dump(root, str(path))
|
||||
return oracle.score(path, urb)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("source", nargs="?", default=str(DEFAULT_SRC))
|
||||
ap.add_argument("--urb", default=str(DEFAULT_URB))
|
||||
args = ap.parse_args()
|
||||
|
||||
src = Path(args.source).resolve()
|
||||
urb = Path(args.urb).resolve()
|
||||
config = src.parent / "patterns.config"
|
||||
|
||||
scratch = Path(__file__).resolve().parents[1] / "scratch"
|
||||
scratch.mkdir(exist_ok=True)
|
||||
shutil.copy(config, scratch / "patterns.config")
|
||||
|
||||
targets = programme.load_programme(str(config))
|
||||
|
||||
# baseline: score the original file as-is
|
||||
orig_copy = scratch / "original.dom"
|
||||
shutil.copy(src, orig_copy)
|
||||
s_orig = oracle.score(orig_copy, urb)
|
||||
|
||||
# roundtrip: load + re-emit unmodified
|
||||
s_round = _score_in(scratch, "roundtrip.dom", dom.load(str(src)), urb)
|
||||
|
||||
# solved: strip ratios and re-solve from programme targets
|
||||
root = dom.load(str(src))
|
||||
print("--- programme leaves BEFORE solve (ratios intact) ---")
|
||||
print(solver.area_report(root, targets))
|
||||
res = solver.solve_ratios(root, targets, strip=True)
|
||||
print("\n--- programme leaves AFTER solve (from targets, ratios stripped) ---")
|
||||
print(solver.area_report(root, targets))
|
||||
print(f"\nsolver: cost={res.cost:.4f} nfev={res.nfev} success={res.success}")
|
||||
s_solved = _score_in(scratch, "solved.dom", root, urb)
|
||||
|
||||
# refined: warm-start from the evolved ratios (solver as geometry optimiser)
|
||||
root2 = dom.load(str(src))
|
||||
solver.solve_ratios(root2, targets, strip=False)
|
||||
s_refined = _score_in(scratch, "refined.dom", root2, urb)
|
||||
|
||||
print("\n=== FITNESS (via urb-fitness.pl oracle) ===")
|
||||
for label, s in (
|
||||
("original", s_orig),
|
||||
("roundtrip", s_round),
|
||||
("solved", s_solved),
|
||||
("refined", s_refined),
|
||||
):
|
||||
print(f" {label:9s} fitness={s.fitness:.10g} fails={s.n_fails}")
|
||||
|
||||
print("\nVERDICT:")
|
||||
print(f" solved (strip, frozen topology): {'>=' if s_solved.fitness >= s_orig.fitness else '<'} original")
|
||||
print(f" refined (warm-start optimiser): {'>=' if s_refined.fitness >= s_orig.fitness else '<'} original")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -156,6 +156,7 @@ def load(path: str) -> Node:
|
|||
if root.node is not None:
|
||||
root.node_file = [list(p) for p in root.node]
|
||||
root.node = geometry.offset_quad(root.node, -root.wall_outer)
|
||||
geometry.clear_cache() # fresh tree: drop any stale coordinates
|
||||
return root
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,17 @@ from .dom import Node
|
|||
|
||||
Point = list[float]
|
||||
|
||||
# Memoisation of derived coordinates. The pull-based recursion mirrors Urb but,
|
||||
# uncached, re-derives ancestor/below corners exponentially with depth. Urb
|
||||
# itself caches in the node and clears via Clean_Cache(); we do the same with a
|
||||
# module cache keyed by node identity. Callers that mutate divisions (the
|
||||
# solver) must call clear_cache(); dom.load() clears it for a fresh tree.
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
def _interp(a: Point, b: Point, t: float) -> Point:
|
||||
return [a[0] * (1 - t) + b[0] * t, a[1] * (1 - t) + b[1] * t]
|
||||
|
|
@ -26,30 +37,52 @@ def _interp(a: Point, b: Point, t: float) -> Point:
|
|||
|
||||
def coordinate(n: Node, idx: int) -> Point:
|
||||
"""Corner ``idx`` (0..3) of ``n``; mirrors ``Urb::Quad::Coordinate``."""
|
||||
key = (id(n), idx)
|
||||
hit = _cache.get(key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
if n.below is not None: # upper storey inherits geometry from below
|
||||
return coordinate(n.below, idx)
|
||||
rid = (idx + n.rotation) % 4
|
||||
if n.parent is None: # level root: stored, rotation-adjusted corner
|
||||
return list(n.node[rid])
|
||||
p = n.parent
|
||||
if n.position == "l":
|
||||
return {0: coordinate(p, 0), 1: coord_a(p), 2: coord_b(p), 3: coordinate(p, 3)}[rid]
|
||||
# position == 'r'
|
||||
return {0: coord_a(p), 1: coordinate(p, 1), 2: coordinate(p, 2), 3: coord_b(p)}[rid]
|
||||
result = coordinate(n.below, idx)
|
||||
else:
|
||||
rid = (idx + n.rotation) % 4
|
||||
if n.parent is None: # level root: stored, rotation-adjusted corner
|
||||
result = list(n.node[rid])
|
||||
else:
|
||||
p = n.parent
|
||||
if n.position == "l":
|
||||
result = {0: coordinate(p, 0), 1: coord_a(p), 2: coord_b(p), 3: coordinate(p, 3)}[rid]
|
||||
else: # 'r'
|
||||
result = {0: coord_a(p), 1: coordinate(p, 1), 2: coordinate(p, 2), 3: coord_b(p)}[rid]
|
||||
_cache[key] = result
|
||||
return result
|
||||
|
||||
|
||||
def coord_a(n: Node) -> Point:
|
||||
"""End 'a' of the division line; mirrors ``Urb::Quad::Coordinate_a``."""
|
||||
key = (id(n), "a")
|
||||
hit = _cache.get(key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
if n.below is not None and n.below.divided:
|
||||
return coord_a(n.below)
|
||||
return _interp(coordinate(n, 0), coordinate(n, 1), n.division[0])
|
||||
result = coord_a(n.below)
|
||||
else:
|
||||
result = _interp(coordinate(n, 0), coordinate(n, 1), n.division[0])
|
||||
_cache[key] = result
|
||||
return result
|
||||
|
||||
|
||||
def coord_b(n: Node) -> Point:
|
||||
"""End 'b' of the division line; mirrors ``Urb::Quad::Coordinate_b``."""
|
||||
key = (id(n), "b")
|
||||
hit = _cache.get(key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
if n.below is not None and n.below.divided:
|
||||
return coord_b(n.below)
|
||||
return _interp(coordinate(n, 3), coordinate(n, 2), n.division[1])
|
||||
result = coord_b(n.below)
|
||||
else:
|
||||
result = _interp(coordinate(n, 3), coordinate(n, 2), n.division[1])
|
||||
_cache[key] = result
|
||||
return result
|
||||
|
||||
|
||||
def _dist(a: Point, b: Point) -> float:
|
||||
|
|
|
|||
52
src/homemaker/oracle.py
Normal file
52
src/homemaker/oracle.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""Phase-1 fitness oracle: score a ``.dom`` via Urb's ``urb-fitness.pl``.
|
||||
|
||||
This is the only throwaway component. It shells out to the Perl evaluator so we
|
||||
can validate the Python search core against the trusted fitness before porting
|
||||
fitness to Python (Phase 2). ``urb-fitness.pl`` reads ``patterns.config`` from
|
||||
its working directory, so the ``.dom`` must live beside the programme config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_URB_ROOT = Path("/home/bruno/src/urb")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Score:
|
||||
fitness: float
|
||||
fails: str # raw .fails content (YAML and/or plain lines)
|
||||
|
||||
@property
|
||||
def n_fails(self) -> int:
|
||||
return sum(1 for line in self.fails.splitlines() if line.strip() and line.strip() != "---")
|
||||
|
||||
|
||||
def score(dom_path: str | Path, urb_root: str | Path = DEFAULT_URB_ROOT) -> Score:
|
||||
dom_path = Path(dom_path).resolve()
|
||||
urb_root = Path(urb_root).resolve()
|
||||
score_file = Path(f"{dom_path}.score")
|
||||
fails_file = Path(f"{dom_path}.fails")
|
||||
for f in (score_file, fails_file):
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
env = {**os.environ, "DEBUG": "1"}
|
||||
proc = subprocess.run(
|
||||
["perl", f"-I{urb_root}/lib", str(urb_root / "bin" / "urb-fitness.pl"), dom_path.name],
|
||||
cwd=dom_path.parent,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if not score_file.exists():
|
||||
raise RuntimeError(
|
||||
f"urb-fitness.pl produced no score for {dom_path}\n"
|
||||
f"stdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
|
||||
)
|
||||
fitness = float(score_file.read_text().strip())
|
||||
fails = fails_file.read_text() if fails_file.exists() else ""
|
||||
return Score(fitness=fitness, fails=fails)
|
||||
64
src/homemaker/programme.py
Normal file
64
src/homemaker/programme.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Parse a ``patterns.config`` programme into per-code space requirements.
|
||||
|
||||
Only the ``spaces:`` section is read here. Generic codes (c/o/s) carry no
|
||||
explicit targets and are left unconstrained by the solver (they absorb slack).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
# Urb::Dom::Fitness defaults for optional params (ProgrammeDriven.default_params).
|
||||
_DEFAULT_WIDTH = (4.0, 1.0)
|
||||
_DEFAULT_PROPORTION = (1.5, 0.5)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpaceReq:
|
||||
code: str
|
||||
name: str = ""
|
||||
size: float = 0.0 # target floor area, m^2
|
||||
size_sigma: float = 1.0
|
||||
width: float = _DEFAULT_WIDTH[0]
|
||||
width_sigma: float = _DEFAULT_WIDTH[1]
|
||||
proportion: float = _DEFAULT_PROPORTION[0] # max length/width ratio
|
||||
proportion_sigma: float = _DEFAULT_PROPORTION[1]
|
||||
adjacency: list[str] = field(default_factory=list)
|
||||
level: int | None = None
|
||||
requires_below: str | None = None
|
||||
count: int = 1
|
||||
|
||||
|
||||
def _pair(d: dict, key: str, default: tuple[float, float]) -> tuple[float, float]:
|
||||
v = d.get(key)
|
||||
if v is None:
|
||||
return default
|
||||
return float(v[0]), float(v[1])
|
||||
|
||||
|
||||
def load_programme(path: str) -> dict[str, SpaceReq]:
|
||||
with open(path) as fh:
|
||||
conf = yaml.safe_load(fh)
|
||||
spaces = conf.get("spaces") or {}
|
||||
out: dict[str, SpaceReq] = {}
|
||||
for code, c in spaces.items():
|
||||
size = _pair(c, "size", (0.0, 1.0))
|
||||
width = _pair(c, "width", _DEFAULT_WIDTH)
|
||||
prop = _pair(c, "proportion", _DEFAULT_PROPORTION)
|
||||
out[code] = SpaceReq(
|
||||
code=code,
|
||||
name=c.get("name", ""),
|
||||
size=size[0],
|
||||
size_sigma=size[1],
|
||||
width=width[0],
|
||||
width_sigma=width[1],
|
||||
proportion=prop[0],
|
||||
proportion_sigma=prop[1],
|
||||
adjacency=list(c.get("adjacency") or []),
|
||||
level=c.get("level"),
|
||||
requires_below=c.get("requires_below"),
|
||||
count=int(c.get("count") or 1),
|
||||
)
|
||||
return out
|
||||
150
src/homemaker/solver.py
Normal file
150
src/homemaker/solver.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""Bottom-up division-ratio solver.
|
||||
|
||||
Given a *fixed* slicing topology (types, rotations, tree shape), solve every
|
||||
free division ratio so that each programme leaf best meets its target area —
|
||||
with soft, one-sided penalties for being too narrow or too elongated. This is
|
||||
the inversion of Urb's top-down sizing: rooms declare targets, geometry follows.
|
||||
|
||||
Only generic leaves (circulation/outside/storage) and unconstrained types are
|
||||
left to absorb the residual area, exactly as a real plan lets corridors flex.
|
||||
|
||||
A division is *free* only at the lowest storey where its tree path is divided;
|
||||
higher storeys inherit that cut via Below-inheritance (see geometry.coordinate),
|
||||
so their stored ratios are dead variables and must not be optimised.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import least_squares
|
||||
|
||||
from . import geometry
|
||||
from .dom import Node, levels
|
||||
from .programme import SpaceReq
|
||||
|
||||
_EPS = 0.02 # keep cuts off the edges
|
||||
|
||||
|
||||
def _branches(n: Node) -> list[Node]:
|
||||
if not n.divided:
|
||||
return []
|
||||
return [n] + _branches(n.left) + _branches(n.right)
|
||||
|
||||
|
||||
def free_branches(root: Node) -> list[Node]:
|
||||
"""Branches whose division actually drives geometry (not inherited)."""
|
||||
out: list[Node] = []
|
||||
for lvl in levels(root):
|
||||
for b in _branches(lvl):
|
||||
if b.below is None or not b.below.divided:
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
|
||||
def _width(leaf: Node) -> float:
|
||||
l0 = geometry.edge_length(leaf, 0)
|
||||
l1 = geometry.edge_length(leaf, 1)
|
||||
l2 = geometry.edge_length(leaf, 2)
|
||||
l3 = geometry.edge_length(leaf, 3)
|
||||
return min((l0 + l2) / 2, (l1 + l3) / 2)
|
||||
|
||||
|
||||
def _aspect(leaf: Node) -> float:
|
||||
l0 = geometry.edge_length(leaf, 0)
|
||||
l1 = geometry.edge_length(leaf, 1)
|
||||
l2 = geometry.edge_length(leaf, 2)
|
||||
l3 = geometry.edge_length(leaf, 3)
|
||||
a = (l0 + l2) / 2
|
||||
b = (l1 + l3) / 2
|
||||
if a <= 0 or b <= 0:
|
||||
return 1.0
|
||||
return max(a, b) / min(a, b)
|
||||
|
||||
|
||||
def solve_ratios(
|
||||
root: Node,
|
||||
targets: dict[str, SpaceReq],
|
||||
*,
|
||||
strip: bool = True,
|
||||
perpendicular: bool = True,
|
||||
weight_width: float = 1.0,
|
||||
weight_proportion: float = 0.3,
|
||||
min_width_generic: float = 1.2,
|
||||
max_nfev: int = 4000,
|
||||
):
|
||||
"""Solve free division ratios in place. Returns the scipy result object.
|
||||
|
||||
``strip=True`` discards the existing ratios first (start from 0.5) — the
|
||||
honest test that sizes are recoverable from the programme alone.
|
||||
|
||||
``perpendicular=True`` ties the two ends of each cut (``a == b``), one DOF
|
||||
per branch, so cuts stay perpendicular to their walls (matches Urb's
|
||||
``perpendicular`` quality and the slicing-tree model). ``min_width_generic``
|
||||
keeps unconstrained circulation/outside leaves from collapsing into slivers.
|
||||
"""
|
||||
free = free_branches(root)
|
||||
if not free:
|
||||
return None
|
||||
|
||||
if strip:
|
||||
for b in free:
|
||||
b.division = [0.5, 0.5]
|
||||
|
||||
per = 1 if perpendicular else 2
|
||||
x0 = np.array(
|
||||
[b.division[0] for b in free] if perpendicular
|
||||
else [v for b in free for v in b.division],
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
all_leaves = [leaf for lvl in levels(root) for leaf in lvl.leaves()]
|
||||
|
||||
def apply(x: np.ndarray) -> None:
|
||||
for j, b in enumerate(free):
|
||||
if perpendicular:
|
||||
b.division = [float(x[j]), float(x[j])]
|
||||
else:
|
||||
b.division = [float(x[2 * j]), float(x[2 * j + 1])]
|
||||
geometry.clear_cache() # divisions changed; invalidate derived coords
|
||||
|
||||
def residuals(x: np.ndarray) -> list[float]:
|
||||
apply(x)
|
||||
r: list[float] = []
|
||||
for leaf in all_leaves:
|
||||
req = targets.get(leaf.type)
|
||||
if req is not None:
|
||||
area = geometry.area(leaf)
|
||||
r.append((area - req.size) / req.size)
|
||||
if weight_width:
|
||||
w = _width(leaf)
|
||||
r.append(weight_width * min(0.0, (w - req.width) / req.width))
|
||||
if weight_proportion:
|
||||
asp = _aspect(leaf)
|
||||
r.append(weight_proportion * max(0.0, (asp - req.proportion) / req.proportion))
|
||||
elif min_width_generic:
|
||||
# keep circulation/outside from collapsing to slivers
|
||||
w = _width(leaf)
|
||||
r.append(min(0.0, (w - min_width_generic) / min_width_generic))
|
||||
return r
|
||||
|
||||
res = least_squares(
|
||||
residuals, x0, bounds=(_EPS, 1 - _EPS), max_nfev=max_nfev, xtol=1e-10, ftol=1e-10
|
||||
)
|
||||
apply(res.x)
|
||||
return res
|
||||
|
||||
|
||||
def area_report(root: Node, targets: dict[str, SpaceReq]) -> str:
|
||||
"""Human-readable per-programme-leaf area vs target (for experiment output)."""
|
||||
rows = []
|
||||
for lvl_idx, lvl in enumerate(levels(root)):
|
||||
for leaf in lvl.leaves():
|
||||
if leaf.type in targets:
|
||||
req = targets[leaf.type]
|
||||
a = geometry.area(leaf)
|
||||
rows.append(
|
||||
f" {lvl_idx}/{leaf.id:6s} {leaf.type:4s} area={a:6.2f} "
|
||||
f"target={req.size:6.2f} err={(a - req.size):+6.2f} "
|
||||
f"w={_width(leaf):.2f}/{req.width:.2f} asp={_aspect(leaf):.2f}/{req.proportion:.2f}"
|
||||
)
|
||||
return "\n".join(rows)
|
||||
Loading…
Add table
Reference in a new issue