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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user