geom: port sb-report, validation and report formatting

Checks as values, the five universal checks, metrics, ProfileRejected,
and the report block. Completes the shared layer; only the profiles and
build() remain.

Report numbers are rounded to six significant figures on the way out,
matching OpenSCAD echo, which is C %g at default precision. The oracle
records what OpenSCAD printed, not full-precision geometry.

This is load bearing rather than cosmetic. VOLUME_MM3 ends in _MM3, so
test_oracle.py compares it at the lengths tolerance of 1e-4 and not the
areas tolerance of 1e-3. Volume is section area times a 100 mm length,
so an unrounded port reporting 13557.402 against a recorded 13557.4
fails by twenty times the tolerance while being geometrically correct.

Verified against the oracle: across all 113 accepted cases the recorded
volume equals the rounded area times length to within 3.6e-12, which
holds only if volume is computed unrounded and rounded at print. That is
what this layer does.

The consequence is that geometry must agree with the reference to better
than one part in a million before rounding. Near a rounding boundary a
smaller error can still tip the last digit, and that will show up as a
single case failing by one unit in the last place rather than as
something mysterious.

28 tests. Nine mutations, all caught first pass, including rounding to
decimal places instead of significant figures and computing volume from
the already-rounded area.

Oracle acceptance still skips; 236 unchanged.
This commit is contained in:
2026-08-19 05:46:51 -05:00
parent ebf02d6573
commit b101fe6adf
3 changed files with 561 additions and 0 deletions
+13
View File
@@ -104,3 +104,16 @@ from .join import ( # noqa: F401
sleeve_shell,
strap_region,
)
from .report import ( # noqa: F401
Check,
Metrics,
ProfileRejected,
Result,
check,
echo_num,
first_failure,
metrics,
report,
require,
universal_checks,
)
+233
View File
@@ -0,0 +1,233 @@
"""
Port of ``legacy/openscad/lib/sb-report.scad`` -- validation and reporting.
VALIDATION
Checks are values, not statements. A profile builder returns a list of
``(condition, message)`` pairs and the core asserts over that list. Because
the list is built inside the selected profile's own function, no other
profile's parameters are ever touched: a parameter belonging to one
catalogue entry cannot break a different one.
Where OpenSCAD calls ``assert``, this raises ``ProfileRejected``. The
message is the contract, not decoration -- ``test_oracle.py`` requires that a
rejection names the parameter and the limit, as the reference does.
REPORTING
Every build emits a flat block of ``SB_KEY=value`` lines. The oracle records
them with the prefix stripped, so the report is a plain dict of ``KEY`` to
value.
**Numbers are rounded to six significant figures on the way out.** That is what
OpenSCAD's ``echo`` does -- C's ``%g`` at default precision -- and the oracle
records what OpenSCAD printed, not full-precision geometry.
This is not cosmetic. ``VOLUME_MM3`` ends in ``_MM3``, so ``test_oracle.py``
compares it at the lengths tolerance of 1e-4 rather than the areas tolerance of
1e-3. Volume is the section area times a 100 mm length, so an unrounded port
reporting 13557.402 against a recorded 13557.4 fails by twenty times the
tolerance while being geometrically correct. Rounding at the boundary makes the
comparison meaningful again.
The consequence, stated plainly: the geometry must agree with the reference to
better than one part in a million *before* rounding. Near a rounding boundary a
smaller error can still tip the last digit, and that shows up as a single case
failing by exactly one unit in the last place.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple
from .join import cavity_region
from .records import Geo, Member
from .region import (
area as region_total_area,
difference,
hull_region,
is_region_simple,
nparts,
pointlist_bounds,
)
from .primitives import Path, sb_region_min_gap
Check = Tuple[bool, str]
class ProfileRejected(Exception):
"""
An arrangement the reference refused to build.
Distinct from a programming error: this is the profile telling the caller
that the requested parameters do not produce a usable member. Ten of the
oracle's 123 cases end here, and a port that builds them anyway has failed
however good its numbers are elsewhere.
"""
# ----------------------------------------------------------------------------
# Checks
# ----------------------------------------------------------------------------
def check(condition, message: str) -> Check:
return (bool(condition), message)
def first_failure(checks: Sequence[Check]) -> Optional[str]:
for ok, message in checks:
if not ok:
return message
return None
def require(checks: Sequence[Check]) -> str:
"""
Raise on the first failing check, in declaration order.
Order is deliberate: the reference reports the first failure rather than all
of them, so the message a caller sees is the most specific one the profile
author put first, not an aggregate.
"""
fail = first_failure(checks)
if fail is not None:
raise ProfileRejected(fail)
return "ok"
# ----------------------------------------------------------------------------
# Metrics
# ----------------------------------------------------------------------------
@dataclass(frozen=True)
class Metrics:
area: float # PLA+ cross-section per unit length
parts: int # connected solids
slots: int # separate strap channels
min_wall: float # thinnest surviving wall
size_x: float # envelope width
size_y: float # envelope height
leak: float # cavity area outside the envelope
def metrics(section: Sequence[Path], shell: Sequence[Path],
members: Sequence[Member], g: Geo) -> Metrics:
cav = cavity_region(members, g)
hull = hull_region(shell)
b = pointlist_bounds(hull)
return Metrics(
area=region_total_area(section),
parts=nparts(section),
slots=nparts(cav),
min_wall=sb_region_min_gap(section),
size_x=b[1][0] - b[0][0],
size_y=b[1][1] - b[0][1],
leak=region_total_area(difference(cav, shell)),
)
def universal_checks(section: Sequence[Path], m: Metrics,
expected_members: int, g: Geo) -> List[Check]:
"""
Checks every profile must pass, whatever its shape or member count.
The connectivity test alone is not enough: a cross-section joined by a
0.14 mm knife edge is topologically connected and physically useless. The
minimum-wall test is what actually catches over-large corner radii,
swallowed junction gaps, and fillets that have stopped bridging.
"""
return [
check(m.parts == 1,
"Cross-section is not one connected solid (%s separate pieces). "
"Widen the junctions or thicken the walls." % m.parts),
check(m.slots == expected_members,
"Expected %s separate strap channels but found %s. Neighbouring "
"channels have merged, so those straps share one slot and are not "
"retained. Increase the relevant web."
% (expected_members, m.slots)),
check(m.min_wall >= g.min_wall - 1e-4,
"Thinnest PLA+ wall is %s mm, below the required minimum of %s "
"mm. Reduce the corner radius, increase the web, or lower "
"min_wall_mm if this really is acceptable."
% (m.min_wall, g.min_wall)),
check(is_region_simple(section),
"The cross-section touches itself at a point rather than crossing "
"cleanly. Such an outline is valid but cannot be tessellated, so "
"it would fail on extrusion. Nudge the junction fillet radius "
"away from zero, or change the web slightly."),
check(m.leak < 1e-4,
"A strap cavity breaks out of the outer envelope (%s mm^2 "
"outside). The straps would not be enclosed." % m.leak),
]
# ----------------------------------------------------------------------------
# Report
# ----------------------------------------------------------------------------
def echo_num(value: float) -> float:
"""
Round as OpenSCAD's ``echo`` prints: C ``%g``, six significant figures.
Applied at the boundary and nowhere else. Rounding inside the geometry would
compound across the solvers; rounding on the way out reproduces exactly what
the reference recorded.
"""
return float("%g" % value)
def report(family: str, profile: str, status: str, g: Geo, m: Metrics,
length_mm: float, density_g_cm3: float,
extra: Optional[Dict[str, object]] = None) -> Dict[str, object]:
"""
The standard report block.
``extra`` carries whatever the individual profile publishes -- solved sizes,
effective projections, headroom on a radius. Those keys are part of the
oracle's recorded report just as the standard ones are.
"""
volume_mm3 = m.area * length_mm
out: Dict[str, object] = {
"STATUS": status,
"FAMILY": family,
"PROFILE": profile,
"STRAP_WIDTH_MM": echo_num(g.width),
"STRAP_THICK_MM": echo_num(g.strap_t),
"BUNDLE_COUNT": echo_num(g.count),
"BUNDLE_THICK_MM": echo_num(g.bundle_t),
"CLEARANCE_MM": echo_num(g.clearance),
"WALL_INSIDE_MM": echo_num(g.wall_inside),
"WALL_OUTSIDE_MM": echo_num(g.wall_outside),
"WALL_EDGE_MM": echo_num(g.wall_edge),
"MIN_WALL_SPEC_MM": echo_num(g.min_wall),
"MIN_WALL_ACTUAL_MM": echo_num(m.min_wall),
"SECTION_AREA_MM2": echo_num(m.area),
"SECTION_PARTS": echo_num(m.parts),
"STRAP_CHANNELS": echo_num(m.slots),
"ENVELOPE_X_MM": echo_num(m.size_x),
"ENVELOPE_Y_MM": echo_num(m.size_y),
"LENGTH_MM": echo_num(length_mm),
"VOLUME_MM3": echo_num(volume_mm3),
"MASS_G": echo_num(volume_mm3 * density_g_cm3 / 1000.0),
}
for key, value in (extra or {}).items():
out[key] = echo_num(value) if isinstance(value, (int, float)) \
and not isinstance(value, bool) else value
return out
@dataclass(frozen=True)
class Result:
"""What ``build()`` returns. ``report`` is the dict the oracle compares."""
report: Dict[str, object]
section: List[List[Tuple[float, float]]]
members: List[Member]
geo: Geo