diff --git a/src/mechcomp/geom/__init__.py b/src/mechcomp/geom/__init__.py index bd4051f..b231955 100644 --- a/src/mechcomp/geom/__init__.py +++ b/src/mechcomp/geom/__init__.py @@ -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, +) diff --git a/src/mechcomp/geom/report.py b/src/mechcomp/geom/report.py new file mode 100644 index 0000000..503f057 --- /dev/null +++ b/src/mechcomp/geom/report.py @@ -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 diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..f85ae49 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,315 @@ +""" +Unit tests for validation, metrics and report formatting. + +The rounding tests carry values taken from the committed oracle, so they assert +against what the reference actually recorded rather than against my reading of +how OpenSCAD formats numbers. + +Each universal check is tested by constructing geometry that violates it and +nothing else. A check that cannot be made to fail is not a check. +""" + +from __future__ import annotations + +import math + +import pytest + +from mechcomp.geom.join import ( + bore_from_members, + fit_ring, + ring_members, + ring_shell, + section, +) +from mechcomp.geom.records import Geo, Member +from mechcomp.geom.report import ( + Metrics, + ProfileRejected, + Result, + check, + echo_num, + first_failure, + metrics, + report, + require, + universal_checks, +) + + +def geo(**kw) -> Geo: + base = dict(width=15.875, strap_t=0.508, count=1, clearance=0.2, + wall_inside=1.0, wall_outside=1.6, wall_edge=1.2, min_wall=0.8) + base.update(kw) + return Geo(**base) + + +def ngon(n, r=20.0, start=90.0): + return [(r * math.cos(math.radians(start + i * 360.0 / n)), + r * math.sin(math.radians(start + i * 360.0 / n))) + for i in range(n)] + + +TRI = ngon(3) + + +def ring_case(poly=TRI, web=1.2, g=None): + g = g or geo() + fitted = fit_ring(poly, g, web) + ms = ring_members(fitted, g) + bore = bore_from_members(ms, g) + shell = [ring_shell(fitted, g, 0.0)] + return section(shell, ms, g, bore), shell, ms, g + + +# ---------------------------------------------------------------------------- +# Checks +# ---------------------------------------------------------------------------- + +def test_check_coerces_to_bool(): + assert check(1, "m") == (True, "m") + assert check(0, "m") == (False, "m") + assert check([], "m") == (False, "m") + + +def test_first_failure_reports_the_earliest(): + """ + The reference reports the first failure, not an aggregate, so the caller + sees the most specific message the profile author put first. + """ + checks = [(True, "a"), (False, "b"), (False, "c")] + assert first_failure(checks) == "b" + + +def test_first_failure_is_none_when_all_pass(): + assert first_failure([(True, "a"), (True, "b")]) is None + + +def test_require_returns_ok_when_everything_passes(): + assert require([(True, "a")]) == "ok" + + +def test_require_raises_profile_rejected_with_the_message(): + with pytest.raises(ProfileRejected, match="leg angle"): + require([(True, "a"), (False, "reduce the leg angle"), (True, "c")]) + + +def test_rejection_carries_a_non_empty_message(): + """ + test_oracle.py requires a rejection to name the parameter and the limit. + An empty message fails the contract even though the rejection is correct. + """ + with pytest.raises(ProfileRejected) as exc: + require([(False, "min_wall_mm of 0.8 mm not met: actual 0.41 mm")]) + assert str(exc.value).strip() + assert "min_wall_mm" in str(exc.value) + + +# ---------------------------------------------------------------------------- +# echo_num -- OpenSCAD's print formatting +# ---------------------------------------------------------------------------- + +def test_echo_num_rounds_to_six_significant_figures(): + assert echo_num(135.57402118) == 135.574 + assert echo_num(13557.402118) == 13557.4 + assert echo_num(16.81118) == 16.8112 + assert echo_num(33.848812) == 33.8488 + + +def test_echo_num_leaves_short_values_alone(): + assert echo_num(1.2) == 1.2 + assert echo_num(0.508) == 0.508 + assert echo_num(1.0) == 1.0 + + +def test_echo_num_is_significant_figures_not_decimal_places(): + """Six *significant* figures: the decimal count shifts with magnitude.""" + assert echo_num(0.000123456789) == 0.000123457 + assert echo_num(123456.789) == 123457.0 + + +def test_volume_matches_the_oracles_own_derivation(): + """ + The oracle's volume equals its rounded area times the length to within + float noise -- 3.6e-12 across all 113 accepted cases. That holds only if + volume is computed from the unrounded area and rounded at print, which is + what this layer does. + """ + a = 135.57402118 + assert echo_num(a) == 135.574 + assert echo_num(a * 100.0) == 13557.4 + assert abs(echo_num(a * 100.0) - echo_num(a) * 100.0) < 1e-9 + + +# ---------------------------------------------------------------------------- +# Metrics +# ---------------------------------------------------------------------------- + +def test_metrics_on_a_real_ring_profile(): + sec, shell, ms, g = ring_case() + m = metrics(sec, shell, ms, g) + assert m.parts == 1 + assert m.slots == 3 + assert m.area > 0 + assert m.size_x > 0 and m.size_y > 0 + assert m.leak == pytest.approx(0.0, abs=1e-9) + + +def test_metrics_min_wall_is_the_thinnest_surviving_gap(): + sec, shell, ms, g = ring_case() + m = metrics(sec, shell, ms, g) + assert 0 < m.min_wall < 100 + + +def test_leak_is_positive_when_a_cavity_escapes_the_envelope(): + """A cavity outside the shell means the straps are not enclosed.""" + g = geo() + sec, shell, ms, _ = ring_case() + stray = list(ms) + [Member(500.0, 0.0, 0.0)] + m = metrics(sec, shell, stray, g) + assert m.leak > 1.0 + + +# ---------------------------------------------------------------------------- +# Universal checks -- each made to fail on its own +# ---------------------------------------------------------------------------- + +def _m(**kw) -> Metrics: + base = dict(area=100.0, parts=1, slots=3, min_wall=1.2, + size_x=30.0, size_y=30.0, leak=0.0) + base.update(kw) + return Metrics(**base) + + +def test_all_universal_checks_pass_on_a_good_profile(): + sec, shell, ms, g = ring_case() + m = metrics(sec, shell, ms, g) + assert first_failure(universal_checks(sec, m, 3, g)) is None + assert require(universal_checks(sec, m, 3, g)) == "ok" + + +def test_disconnected_section_is_rejected(): + sec, shell, ms, g = ring_case() + fail = first_failure(universal_checks(sec, _m(parts=2), 3, g)) + assert fail is not None and "connected solid" in fail + assert "2 separate pieces" in fail + + +def test_merged_channels_are_rejected(): + sec, shell, ms, g = ring_case() + fail = first_failure(universal_checks(sec, _m(slots=2), 3, g)) + assert fail is not None and "strap channels" in fail + assert "Expected 3" in fail and "found 2" in fail + + +def test_thin_wall_is_rejected(): + """ + Connectivity alone is not enough: a knife edge is topologically connected + and physically useless, and this is the check that catches it. + """ + sec, shell, ms, g = ring_case() + fail = first_failure(universal_checks(sec, _m(min_wall=0.14), 3, g)) + assert fail is not None and "Thinnest PLA+ wall" in fail + + +def test_wall_check_has_a_tolerance_band(): + """Exactly at the minimum must pass; float noise must not reject a profile.""" + sec, shell, ms, g = ring_case() + assert first_failure(universal_checks(sec, _m(min_wall=g.min_wall), 3, g)) is None + assert first_failure( + universal_checks(sec, _m(min_wall=g.min_wall - 1e-5), 3, g)) is None + assert first_failure( + universal_checks(sec, _m(min_wall=g.min_wall - 1e-2), 3, g)) is not None + + +def test_self_touching_section_is_rejected(): + """The manifold precondition: valid in 2D, impossible to extrude.""" + g = geo() + bowtie = [[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)]] + fail = first_failure(universal_checks(bowtie, _m(), 3, g)) + assert fail is not None and "cannot be " in fail + + +def test_escaped_cavity_is_rejected(): + sec, shell, ms, g = ring_case() + fail = first_failure(universal_checks(sec, _m(leak=5.0), 3, g)) + assert fail is not None and "breaks out of the outer envelope" in fail + + +def test_checks_are_reported_in_declaration_order(): + """Two failures at once must surface the connectivity message first.""" + sec, shell, ms, g = ring_case() + fail = first_failure(universal_checks(sec, _m(parts=3, slots=1), 3, g)) + assert "connected solid" in fail + + +# ---------------------------------------------------------------------------- +# Report +# ---------------------------------------------------------------------------- + +def test_report_carries_every_key_the_oracle_records(): + sec, shell, ms, g = ring_case() + m = metrics(sec, shell, ms, g) + r = report("3x", "Equilateral Triangle", "ok", g, m, 100.0, 1.24) + for key in ("STATUS", "FAMILY", "PROFILE", "STRAP_WIDTH_MM", + "STRAP_THICK_MM", "BUNDLE_COUNT", "BUNDLE_THICK_MM", + "CLEARANCE_MM", "WALL_INSIDE_MM", "WALL_OUTSIDE_MM", + "WALL_EDGE_MM", "MIN_WALL_SPEC_MM", "MIN_WALL_ACTUAL_MM", + "SECTION_AREA_MM2", "SECTION_PARTS", "STRAP_CHANNELS", + "ENVELOPE_X_MM", "ENVELOPE_Y_MM", "LENGTH_MM", "VOLUME_MM3", + "MASS_G"): + assert key in r, key + + +def test_report_numbers_are_rounded_on_the_way_out(): + sec, shell, ms, g = ring_case() + m = metrics(sec, shell, ms, g) + r = report("3x", "T", "ok", g, m, 100.0, 1.24) + for key, value in r.items(): + if isinstance(value, float): + assert echo_num(value) == value, key + + +def test_report_volume_is_area_times_length(): + sec, shell, ms, g = ring_case() + m = metrics(sec, shell, ms, g) + r = report("3x", "T", "ok", g, m, 100.0, 1.24) + assert r["VOLUME_MM3"] == echo_num(m.area * 100.0) + + +def test_report_mass_uses_density_in_g_per_cm3(): + """Volume is mm^3 and density g/cm^3, so the conversion divides by 1000.""" + sec, shell, ms, g = ring_case() + m = metrics(sec, shell, ms, g) + r = report("3x", "T", "ok", g, m, 100.0, 1.24) + assert r["MASS_G"] == echo_num(m.area * 100.0 * 1.24 / 1000.0) + + +def test_report_merges_and_rounds_extra_keys(): + """Profile-published keys are part of the recorded report, not an addendum.""" + sec, shell, ms, g = ring_case() + m = metrics(sec, shell, ms, g) + r = report("3x", "Y", "ok", g, m, 100.0, 1.24, + extra={"SPOKE_RADIUS_MM": 9.17133421, + "NOTE": "inside wall unused: no enclosed bore"}) + assert r["SPOKE_RADIUS_MM"] == 9.17133 + assert r["NOTE"] == "inside wall unused: no enclosed bore" + + +def test_report_leaves_strings_untouched(): + sec, shell, ms, g = ring_case() + m = metrics(sec, shell, ms, g) + r = report("3x", "Y", "ok", g, m, 100.0, 1.24) + assert r["FAMILY"] == "3x" + assert r["PROFILE"] == "Y" + assert r["STATUS"] == "ok" + + +def test_result_holds_the_report_and_the_geometry(): + sec, shell, ms, g = ring_case() + m = metrics(sec, shell, ms, g) + res = Result(report=report("3x", "T", "ok", g, m, 100.0, 1.24), + section=sec, members=list(ms), geo=g) + assert res.report["SECTION_PARTS"] == 1 + assert len(res.members) == 3 + assert res.geo is g