214 lines
8.9 KiB
Python
214 lines
8.9 KiB
Python
|
|
"""Per-room-spec satisfiability audit of `patterns.config` targets.
|
||
|
|
|
||
|
|
Evidence for `homemaker-py-2v1`/`ssz`/`tdp` (DESIGN.md §38/§39). The corpus
|
||
|
|
configs were estimated years ago on the principle that exact values do not
|
||
|
|
matter for getting the engine working. This asks the opposite question: **does
|
||
|
|
any individual room spec make itself impossible to satisfy?**
|
||
|
|
|
||
|
|
For one room code, model the leaf as a rectangle of area ``A`` and aspect
|
||
|
|
``r = w/h >= 1`` (``h`` is `length_narrowest`, the width metric). The
|
||
|
|
FAIL_THRESHOLD-inverted bounds come from the already-validated
|
||
|
|
``shapecurve.leaf_constraints`` (§37.2), so this is not a reimplementation of
|
||
|
|
the Gaussians:
|
||
|
|
|
||
|
|
* size ``amin <= A <= amax``
|
||
|
|
* width ``h >= wmin`` => ``r <= A / wmin^2``
|
||
|
|
* proportion ``r <= rmax``
|
||
|
|
* crinkliness ``L_exposed >= A / (X * height)`` with ``X = 1.6202`` (§38.3)
|
||
|
|
|
||
|
|
The last one depends on how much of the leaf's boundary is external, which is a
|
||
|
|
*placement* property, not a spec property — so the audit reports the **minimum
|
||
|
|
number of exposed sides** each spec needs. A spec needing 2 adjacent sides is
|
||
|
|
demanding a corner; a rectangular storey has only four corners, so a programme
|
||
|
|
wanting more corner rooms than the plot has corners is over-subscribed before
|
||
|
|
the search starts.
|
||
|
|
|
||
|
|
Usage::
|
||
|
|
|
||
|
|
python experiments/audit_programme_config.py
|
||
|
|
python experiments/audit_programme_config.py examples/harbor-house --verbose
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import math
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import yaml
|
||
|
|
|
||
|
|
from homemaker_layout import dom, fitness, programme, shapecurve
|
||
|
|
from homemaker_layout.dom import Node
|
||
|
|
|
||
|
|
# 1/crink bounds from §38.3; recomputed from the live conf, never hard-coded.
|
||
|
|
def crink_bounds(fit: fitness.Fitness, circulation: bool = False) -> tuple[float, float]:
|
||
|
|
key = "uncrinkliness_circulation" if circulation else "uncrinkliness"
|
||
|
|
target, sigma = fit.conf(key)
|
||
|
|
k = math.sqrt(-2 * sigma * sigma
|
||
|
|
* math.log(fitness.FAIL_THRESHOLD) / math.log(fitness._E))
|
||
|
|
return target + k, max(1e-12, target - k)
|
||
|
|
|
||
|
|
|
||
|
|
# Exposure patterns, cheapest first: name -> exposed length given (w, h), w >= h.
|
||
|
|
EXPOSURE = [
|
||
|
|
("1 short side", lambda w, h: h),
|
||
|
|
("1 long side", lambda w, h: w),
|
||
|
|
("2 adjacent (corner)", lambda w, h: w + h),
|
||
|
|
("2 opposite long", lambda w, h: 2 * w),
|
||
|
|
("3 sides", lambda w, h: 2 * h + w),
|
||
|
|
("4 sides (freestanding)", lambda w, h: 2 * (w + h)),
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def _synthetic_leaf(code: str) -> Node:
|
||
|
|
"""A bare typed leaf — ``leaf_constraints`` reads only its type/flags."""
|
||
|
|
return Node(node=[[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]], type=code)
|
||
|
|
|
||
|
|
|
||
|
|
def audit_code(fit: fitness.Fitness, code: str, height: float,
|
||
|
|
grid: int = 240) -> dict:
|
||
|
|
"""Feasibility of one room spec, and the exposure it needs."""
|
||
|
|
bounds = shapecurve.leaf_constraints(fit, _synthetic_leaf(code))
|
||
|
|
amin, amax, wmin, rmax = bounds.amin, bounds.amax, bounds.wmin, bounds.rmax
|
||
|
|
if not math.isfinite(amax):
|
||
|
|
amax = max(amin * 4, 200.0)
|
||
|
|
|
||
|
|
hi, lo = crink_bounds(fit, circulation=code[:1].lower() == "c")
|
||
|
|
areas = np.linspace(max(amin, 1e-6), amax, grid)
|
||
|
|
ratios = np.linspace(1.0, max(rmax, 1.0), grid)
|
||
|
|
A, R = np.meshgrid(areas, ratios, indexing="ij")
|
||
|
|
W, H = np.sqrt(A * R), np.sqrt(A / R)
|
||
|
|
|
||
|
|
swp = (H >= wmin) & (R <= rmax) # size is satisfied by construction
|
||
|
|
result = {"code": code, "amin": amin, "amax": amax, "wmin": wmin,
|
||
|
|
"rmax": rmax, "swp": bool(swp.any()), "needs": None,
|
||
|
|
"swp_only_at": None}
|
||
|
|
if not result["swp"]:
|
||
|
|
return result
|
||
|
|
|
||
|
|
# smallest square-ish area that satisfies width at r=1, for the report
|
||
|
|
result["swp_only_at"] = float(max(amin, wmin * wmin))
|
||
|
|
|
||
|
|
for name, length_of in EXPOSURE:
|
||
|
|
L = length_of(W, H)
|
||
|
|
crink_ok = (L >= A / (hi * height)) & (L <= A / (lo * height))
|
||
|
|
if bool((swp & crink_ok).any()):
|
||
|
|
result["needs"] = name
|
||
|
|
break
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def audit_namespace(progdir: str) -> int:
|
||
|
|
"""Report programme codes that collide with the generic type prefixes.
|
||
|
|
|
||
|
|
Urb's type system is prefix-based — a type starting with ``c`` is
|
||
|
|
circulation, ``o``/``s`` is outside — and programme codes live in the *same
|
||
|
|
namespace*. So a room code that happens to start with one of those letters
|
||
|
|
is silently reinterpreted as a generic type. Three separate consequences,
|
||
|
|
none of them announced anywhere in the output:
|
||
|
|
|
||
|
|
1. ``graph.check_space_counts`` **skips the code entirely**
|
||
|
|
(``if code[0].lower() in ("c", "o", "s"): continue``) — the room is never
|
||
|
|
required, never counted, and never produces a missing/too-many failure.
|
||
|
|
2. ``Fitness.get_space_params`` returns the generic
|
||
|
|
``*_circulation``/``*_outside`` parameters *before* consulting the
|
||
|
|
programme, so declared size/width/proportion are overridden.
|
||
|
|
3. ``dom.is_circulation``/``is_outside`` become true, changing the leaf's
|
||
|
|
value rate, its crinkliness treatment, and whether it supplies daylight
|
||
|
|
to its neighbours.
|
||
|
|
"""
|
||
|
|
reqs = programme.load_programme_dir(progdir)
|
||
|
|
conf, cost = fitness.load_config(progdir)
|
||
|
|
fit = fitness.Fitness(conf, cost)
|
||
|
|
spaces = conf.get("spaces") or {}
|
||
|
|
|
||
|
|
hits = [c for c in sorted(reqs) if c[:1].lower() in ("c", "o", "s")]
|
||
|
|
total = sum(r.count for r in reqs.values())
|
||
|
|
if not hits:
|
||
|
|
print(f"=== {Path(progdir).name}: namespace clean "
|
||
|
|
f"({len(reqs)} codes / {total} instances)\n")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
skipped = sum(reqs[c].count for c in hits)
|
||
|
|
print(f"=== {Path(progdir).name}: {len(hits)} code(s) collide with the "
|
||
|
|
f"generic c/o/s type prefixes")
|
||
|
|
print(f" {skipped} of {total} room instances ({100 * skipped / total:.0f}%) "
|
||
|
|
f"are SILENTLY OPTIONAL — check_space_counts skips them\n")
|
||
|
|
for code in hits:
|
||
|
|
spec = spaces.get(code, {})
|
||
|
|
leaf = _synthetic_leaf(code)
|
||
|
|
print(f" {code} \"{reqs[code].name}\" (count {reqs[code].count})")
|
||
|
|
for param in ("size", "width", "proportion"):
|
||
|
|
declared = spec.get(param)
|
||
|
|
effective = fit.get_space_params(code, param)
|
||
|
|
flag = "" if declared == effective else " <-- OVERRIDDEN"
|
||
|
|
print(f" {param:<11} declared={str(declared):<16} "
|
||
|
|
f"effective={effective}{flag}")
|
||
|
|
print(f" is_circulation={dom.is_circulation(leaf)} "
|
||
|
|
f"is_outside={dom.is_outside(leaf)} "
|
||
|
|
f"value_rate={fit.value_rate(leaf)} (inside={fit.conf('value_inside')})")
|
||
|
|
print()
|
||
|
|
return skipped
|
||
|
|
|
||
|
|
|
||
|
|
def audit(progdir: str, verbose: bool) -> tuple[int, int]:
|
||
|
|
reqs = programme.load_programme_dir(progdir)
|
||
|
|
conf, cost = fitness.load_config(progdir)
|
||
|
|
fit = fitness.Fitness(conf, cost)
|
||
|
|
seed = yaml.safe_load(open(f"{progdir}/init.dom"))
|
||
|
|
height = seed.get("height") or 3.0
|
||
|
|
|
||
|
|
print(f"=== {Path(progdir).name} (height {height} m)")
|
||
|
|
hdr = f" {'code':<7}{'count':<7}{'area ok':<18}{'min width':<11}{'max aspect':<12}needs"
|
||
|
|
print(hdr)
|
||
|
|
print(" " + "-" * (len(hdr) - 2))
|
||
|
|
|
||
|
|
impossible = corner_demand = 0
|
||
|
|
for code in sorted(reqs) + ["C", "O"]:
|
||
|
|
count = reqs[code].count if code in reqs else 0
|
||
|
|
r = audit_code(fit, code, height)
|
||
|
|
if not r["swp"]:
|
||
|
|
verdict = "IMPOSSIBLE (size/width/proportion contradict)"
|
||
|
|
impossible += max(count, 1)
|
||
|
|
elif r["needs"] is None:
|
||
|
|
verdict = "IMPOSSIBLE even fully exposed (crinkliness)"
|
||
|
|
impossible += max(count, 1)
|
||
|
|
else:
|
||
|
|
verdict = r["needs"]
|
||
|
|
if "corner" in verdict or "opposite" in verdict or "3 sides" in verdict \
|
||
|
|
or "4 sides" in verdict:
|
||
|
|
corner_demand += max(count, 1)
|
||
|
|
area_col = "%.1f-%.1f m2" % (r["amin"], r["amax"])
|
||
|
|
width_col = "%.2f m" % r["wmin"]
|
||
|
|
aspect_col = "%.2f" % r["rmax"]
|
||
|
|
count_col = str(count) if count else "-"
|
||
|
|
print(f" {code:<7}{count_col:<7}{area_col:<18}"
|
||
|
|
f"{width_col:<11}{aspect_col:<12}{verdict}")
|
||
|
|
|
||
|
|
print(f"\n room instances that are impossible as specified : {impossible}")
|
||
|
|
print(f" room instances requiring >=2 exposed sides : {corner_demand}"
|
||
|
|
f" (a rectangular storey has 4 corners)\n")
|
||
|
|
return impossible, corner_demand
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> None:
|
||
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||
|
|
ap.add_argument("progdir", nargs="?", default=None)
|
||
|
|
ap.add_argument("--verbose", action="store_true")
|
||
|
|
args = ap.parse_args()
|
||
|
|
dirs = [args.progdir] if args.progdir else [
|
||
|
|
"examples/harbor-house", "examples/maple-court",
|
||
|
|
"examples/health-centre", "examples/programme-house"]
|
||
|
|
print("### namespace collisions (generic c/o/s type prefixes)\n")
|
||
|
|
for d in dirs:
|
||
|
|
audit_namespace(d)
|
||
|
|
print("### per-room-spec satisfiability\n")
|
||
|
|
for d in dirs:
|
||
|
|
audit(d, args.verbose)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|