homemaker-layout/src/homemaker/solver.py
Bruno Postle 0dcdf1f29f Geometry inner loop: batched full-objective ratio optimiser (CMA-ES)
innerloop.py: optimise(root, programme_dir, x0=None, budget, method) ->
Result, optimising equal-offset free-branch ratios (midpoint projection of
legacy unequal cuts) against full oracle fitness. OracleEvaluator scores
each population in one batched perl call. Methods: cma (default) — multi-
start sigma ladder (0.05 local, 0.15 exploratory) with IPOP-style popsize
doubling and deterministic seeding (pycma treats seed 0 as clock!) — and
compass with Hooke-Jeeves pattern moves, kept for the d0s bake-off.

Acceptance (experiments/accept_innerloop.py, §4.5 bars vs unprojected
originals, within-noise tolerance 1%): x1.65 / x1.66 / x1.58 against bars
x1.24 / x1.67 / x1.59, no new failures, 46 oracle calls vs Nelder-Mead's
200. The two near-bar results are statistically indistinguishable from the
single-NM-draw bars (measured draw spread brackets them); decision approved
by Bruno 2026-06-12.

Also: tests/ scaffold (12 oracle-free unit tests, pytest pythonpath=src),
rebaseline_no_occlusion.py for homemaker-py-gp2, cma>=3.0 dependency
(installed via dnf), dead-variable cleanup in solver.py.

Closes homemaker-py-1p0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:42:24 +01:00

149 lines
5.1 KiB
Python

"""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]
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)