oak-understory-shared-engineering-bench 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ .eggs/
6
+ *.egg-info/
7
+ build/
8
+ dist/
9
+
10
+ # uv
11
+ .uv/
12
+
13
+ # OS / editor
14
+ .DS_Store
15
+
16
+ # Backtest working set: the fetched corpus + the ONNX embedding model.
17
+ # Hundreds of MB, reproducible from the pinned snapshot and a bounded
18
+ # date, and therefore fetched rather than carried.
19
+ .cache/
20
+
21
+ # Builder staging (should never appear here, but just in case)
22
+ _staging/
23
+ docs/*.print.html
@@ -0,0 +1,11 @@
1
+ # GENERATED — source: manifest.toml
2
+ # DO NOT EDIT BY HAND. Regenerated on every `sync`; edits are lost.
3
+ # To change it, edit manifest.toml (or the template) and re-run sync.
4
+ # A harness library's developer entrypoints. `make check` is what CI runs.
5
+
6
+ .PHONY: test check
7
+
8
+ test:
9
+ python -m pytest -q
10
+
11
+ check: test
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: oak-understory-shared-engineering-bench
3
+ Version: 0.1.0
4
+ Summary: an Understory commons: shared discipline assets, no seat.
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: cambium-ai<0.4,>=0.3
7
+ Requires-Dist: oak-understory-common<3,>=2
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest-cov; extra == 'dev'
10
+ Requires-Dist: pytest>=8; extra == 'dev'
@@ -0,0 +1,25 @@
1
+ """The engineering commons — a shared bench, and deliberately no vocabulary.
2
+
3
+ ⭐ A commons carries METHODS and NEVER A STOPLIST, and `commons_has_no_stoplist`
4
+ refuses one at scaffold time. That asymmetry is the design: a Reynolds number is the
5
+ same number whichever engineering discipline computes it, so five faculties each
6
+ carrying a copy would be five places for it to drift — but a shared VOCABULARY would
7
+ make every cross-domain match between two of those faculties a topic match, which is
8
+ the one failure the whole thesis rests on avoiding.
9
+
10
+ Five exact Tier-1 calculations, each conferring affordances selected by a computed
11
+ value rather than by applicability:
12
+
13
+ flow laminar preserves, turbulent combines; the transition band confers
14
+ nothing, being unstable rather than intermediate
15
+ mechanics within the elastic limit a member recovers; past it, it degrades
16
+ buckling slenderness limits, material strength bounds
17
+ thermal a stack holds heat or transfers it
18
+ signals above Nyquist reconstructs; below, it degrades irreversibly
19
+ """
20
+
21
+ from oak_understory_shared_engineering_bench import ( # noqa: F401
22
+ buckling, flow, mechanics, signals, thermal,
23
+ )
24
+
25
+ __all__ = ["buckling", "flow", "mechanics", "signals", "thermal"]
@@ -0,0 +1,87 @@
1
+ """Column stability — the Euler critical load (Tier 1).
2
+
3
+ P_cr = pi^2 E I / (K L)^2
4
+
5
+ ⭐ The calculation that decides which failure mode you are designing against. A
6
+ short column crushes and a slender one buckles, and buckling arrives without
7
+ warning at a load the material's strength says is perfectly safe. Comparing the two
8
+ is the whole point: a strength check alone is how a slender strut passes on paper
9
+ and folds in service.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from oak_understory_common.method_doc import MethodDoc
15
+
16
+ METHOD_DOC = MethodDoc(
17
+ title="Will this column crush or fold?",
18
+ what="Works out the load at which a slender column suddenly bows sideways, and "
19
+ "says whether that happens before or after the material would be crushed.",
20
+ inputs="Elastic modulus in pascals, second moment of area in metres to the "
21
+ "fourth, unsupported length in metres, an end-fixity factor, the "
22
+ "cross-sectional area in square metres, and the yield strength in pascals.",
23
+ outputs="Critical buckling load in newtons, and the squash load in newtons. "
24
+ "Both are forces; the smaller one is what governs.",
25
+ limits="⭐ Euler is an IDEAL: perfectly straight, perfectly centred, perfectly "
26
+ "elastic. Real columns have an initial bow and an eccentric load, so they "
27
+ "fail BELOW this figure — the number is an upper bound, not a rating, and "
28
+ "using it as a rating is how a strut is undersized. It is invalid for stocky "
29
+ "columns, where the material yields before the elastic formula applies and "
30
+ "the result is meaningless rather than merely optimistic. The end-fixity "
31
+ "factor assumes an idealised support; a real joint is rarely either pinned "
32
+ "or truly fixed, and K is where most of the error enters. Nothing here "
33
+ "covers local buckling of thin walls, torsional modes, or eccentric loading.",
34
+ tier=1,
35
+ when_to_use="Whenever a member carries compression and is not obviously stocky.",
36
+ references=(
37
+ "Euler L, Methodus inveniendi lineas curvas, 1744.",
38
+ "Timoshenko SP & Gere JM, Theory of Elastic Stability, 2nd ed.",
39
+ ),
40
+ )
41
+
42
+ import math
43
+
44
+ #: Standard end-fixity factors. The field's, and named so a caller does not
45
+ #: silently pass 1.0 for a cantilever, which understates the length by half.
46
+ K_PINNED_PINNED = 1.0
47
+ K_FIXED_FIXED = 0.5
48
+ K_FIXED_PINNED = 0.699
49
+ K_FIXED_FREE = 2.0
50
+
51
+ METHOD_ID = "engineering.buckling.euler_critical_load"
52
+ VERSION = "0.1.0"
53
+
54
+ AFFORDANCES: tuple[str, ...] = ("constrain", "bound")
55
+
56
+
57
+ def critical_load_n(modulus_pa: float, second_moment_m4: float,
58
+ length_m: float, end_fixity: float = K_PINNED_PINNED) -> float:
59
+ """``pi^2 E I / (K L)^2`` in newtons."""
60
+ if min(modulus_pa, second_moment_m4, length_m, end_fixity) <= 0:
61
+ raise ValueError("modulus, second moment, length and fixity must be positive")
62
+ return math.pi ** 2 * modulus_pa * second_moment_m4 / (end_fixity * length_m) ** 2
63
+
64
+
65
+ def squash_load_n(area_m2: float, yield_pa: float) -> float:
66
+ """The load at which the material simply crushes: ``A x sigma_y``."""
67
+ if min(area_m2, yield_pa) <= 0:
68
+ raise ValueError("area and yield strength must be positive")
69
+ return area_m2 * yield_pa
70
+
71
+
72
+ def affordances(critical_n: float, squash_n: float) -> tuple[str, ...]:
73
+ """What the member lets you do, selected by WHICH failure arrives first.
74
+
75
+ ``constrain`` — buckling governs: the usable load is capped well below the
76
+ material's strength, and adding stronger material buys nothing. ⭐ Named
77
+ `constrain` and not `limit`: `electrical` claims `limit`, and RCGS renames the
78
+ capability rather than narrowing a stoplist.
79
+ ``bound`` — the material governs: strength sets the ceiling, which is the
80
+ ordinary case a strength calculation already describes.
81
+ """
82
+ return ("constrain",) if critical_n < squash_n else ("bound",)
83
+
84
+
85
+ __all__ = ["AFFORDANCES", "K_FIXED_FIXED", "K_FIXED_FREE", "K_FIXED_PINNED",
86
+ "K_PINNED_PINNED", "METHOD_ID", "VERSION", "affordances",
87
+ "critical_load_n", "squash_load_n"]
@@ -0,0 +1,170 @@
1
+ """⭐ DIMENSIONLESS GROUPS — the one method that is domain-free by construction.
2
+
3
+ Every other method in this institution answers one question in one discipline. This
4
+ one does something none of them do, and it is the reason it was built.
5
+
6
+ ## ⭐ Why this is a RETRIEVAL key and not just another rule
7
+
8
+ `risks-and-falsifiers.md` §2f measured seven method libraries closing an affordance
9
+ vocabulary and **not one of them making it selective**: five to nine terms over
10
+ hundreds of items, carrying two or three bits between them. The affordance TERM is
11
+ not the retrieval unit.
12
+
13
+ A dimensionless group is. **Re = 270 000 in a chiller pipe is the same structure as
14
+ Re = 270 000 in a blood vessel or a wind tunnel** — not by analogy but by
15
+ equivalence, because the governing equations are identical once non-dimensionalised.
16
+ Two systems with matching groups are *dynamically similar*, and that is the whole
17
+ content of transposition, computed exactly rather than argued.
18
+
19
+ That is why aircraft are tested as scale models and why a heat exchanger correlation
20
+ from 1930 still works on a fluid nobody had then. The group is the invariant; the
21
+ domain is what it was measured in.
22
+
23
+ ## What this does and does not claim
24
+
25
+ It computes named groups from whatever quantities a caller supplies and reports which
26
+ groups two systems share. ⚠️ **Matching groups mean the two systems obey the same
27
+ equations — NOT that a solution transfers.** Boundary conditions, geometry and the
28
+ groups nobody thought to compute all still differ, and similitude that ignores an
29
+ unmatched group is how a scale model gives a confidently wrong answer.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import math
35
+
36
+ from oak_understory_common.method_doc import MethodDoc
37
+
38
+ METHOD_DOC = MethodDoc(
39
+ title="Which dimensionless groups two systems share",
40
+ what="Reduces a set of physical quantities to the dimensionless groups that "
41
+ "govern them, and says which groups two different systems have in common.",
42
+ inputs="A mapping of quantity name to value for each system — density, "
43
+ "velocity, a length, viscosity, a diffusivity, and so on. Missing "
44
+ "quantities simply mean the groups needing them are not computed.",
45
+ outputs="Each group it could form, as a DIMENSIONLESS number, plus the groups "
46
+ "two systems match on and the relative difference on each. A ratio has no "
47
+ "unit and comparing it across domains is the entire point.",
48
+ limits="⭐ Matching groups mean two systems obey the same EQUATIONS. They do not "
49
+ "mean a solution transfers: boundary conditions, geometry and any group "
50
+ "nobody computed still differ, and similitude that ignores an unmatched "
51
+ "group is how a scale model gives a confidently wrong answer. It forms only "
52
+ "the groups it holds definitions for — an unnamed group governing the "
53
+ "problem is invisible here, and its absence is silent. It cannot tell "
54
+ "whether the quantities it was handed are the RIGHT ones: a caller who omits "
55
+ "the governing length scale gets a clean, wrong Reynolds number. And two "
56
+ "systems matching on one group out of five are not similar; the count "
57
+ "matters more than any single match.",
58
+ tier=1,
59
+ when_to_use="Before assuming a result from one domain applies in another, and to "
60
+ "find domains where a problem has already been solved.",
61
+ references=(
62
+ "Buckingham E, On physically similar systems, Phys. Rev. 1914.",
63
+ "Barenblatt GI, Scaling, Self-similarity, and Intermediate Asymptotics.",
64
+ ),
65
+ )
66
+
67
+ METHOD_ID = "engineering.dimensionless.groups"
68
+ VERSION = "0.1.0"
69
+
70
+ #: ⭐ Selected by the computed match, never by identity. `compare` when two systems
71
+ #: share enough groups to be worth reading across; `distinguish` when they do not.
72
+ AFFORDANCES: tuple[str, ...] = ("compare", "distinguish")
73
+
74
+ #: Standard gravity, m/s².
75
+ GRAVITY = 9.80665
76
+
77
+ #: How close two groups must be to count as matched, as a relative difference.
78
+ #: 10% because similitude is judged on order rather than on decimals — a scale
79
+ #: model matched to 1% on Reynolds is a rare luxury, and 10% is the working line.
80
+ MATCH_TOLERANCE = 0.10
81
+
82
+ #: Two systems sharing fewer than this many groups are not similar in any useful
83
+ #: sense. One shared group out of five is a coincidence with a name.
84
+ MIN_SHARED = 2
85
+
86
+
87
+ def _q(system, *names):
88
+ """The quantities, or None if any is missing. Absence is not zero."""
89
+ out = []
90
+ for name in names:
91
+ value = system.get(name)
92
+ if value is None:
93
+ return None
94
+ out.append(float(value))
95
+ return out
96
+
97
+
98
+ def groups(system: dict) -> dict[str, float]:
99
+ """Every named group this module can form from what it was given.
100
+
101
+ ⭐ A group whose quantities are absent is OMITTED, never defaulted. A Reynolds
102
+ number computed with an assumed viscosity is a clean number about a system
103
+ nobody described.
104
+ """
105
+ out: dict[str, float] = {}
106
+
107
+ if (v := _q(system, "density", "velocity", "length", "viscosity")) and v[3] > 0:
108
+ out["reynolds"] = v[0] * v[1] * v[2] / v[3]
109
+ if (v := _q(system, "velocity", "length")) and v[1] > 0:
110
+ out["froude"] = v[0] / math.sqrt(GRAVITY * v[1])
111
+ if (v := _q(system, "velocity", "sound_speed")) and v[1] > 0:
112
+ out["mach"] = v[0] / v[1]
113
+ if (v := _q(system, "frequency", "length", "velocity")) and v[2] > 0:
114
+ out["strouhal"] = v[0] * v[1] / v[2]
115
+ if (v := _q(system, "heat_transfer_coefficient", "length", "conductivity")) and v[2] > 0:
116
+ out["biot"] = v[0] * v[1] / v[2]
117
+ if (v := _q(system, "velocity", "length", "diffusivity")) and v[2] > 0:
118
+ out["peclet"] = v[0] * v[1] / v[2]
119
+ if (v := _q(system, "viscosity", "density", "diffusivity")) and v[1] > 0 and v[2] > 0:
120
+ out["prandtl"] = (v[0] / v[1]) / v[2]
121
+ if (v := _q(system, "density", "velocity", "length", "surface_tension")) and v[3] > 0:
122
+ out["weber"] = v[0] * v[1] ** 2 * v[2] / v[3]
123
+ if (v := _q(system, "reaction_rate", "length", "velocity")) and v[2] > 0:
124
+ out["damkohler"] = v[0] * v[1] / v[2]
125
+ return out
126
+
127
+
128
+ def shared(a: dict, b: dict, *, tolerance: float = MATCH_TOLERANCE) -> dict[str, float]:
129
+ """Groups both systems form, and the relative difference on each.
130
+
131
+ Only groups BOTH systems could form are considered. A group one system lacks is
132
+ not a mismatch — it is an unknown, and reporting it as a difference would make
133
+ an incomplete description look like a dissimilar system.
134
+ """
135
+ ga, gb = groups(a), groups(b)
136
+ out: dict[str, float] = {}
137
+ for name in sorted(set(ga) & set(gb)):
138
+ scale = max(abs(ga[name]), abs(gb[name]))
139
+ if scale == 0:
140
+ out[name] = 0.0
141
+ continue
142
+ out[name] = abs(ga[name] - gb[name]) / scale
143
+ return out
144
+
145
+
146
+ def matched(a: dict, b: dict, *, tolerance: float = MATCH_TOLERANCE) -> tuple[str, ...]:
147
+ """The groups on which two systems agree within ``tolerance``."""
148
+ return tuple(name for name, diff in shared(a, b, tolerance=tolerance).items()
149
+ if diff <= tolerance)
150
+
151
+
152
+ def affordances(matches: tuple[str, ...], comparable: int) -> tuple[str, ...]:
153
+ """What the comparison lets you do, selected by how many groups matched.
154
+
155
+ ``compare`` — enough shared groups agree that the two systems obey the same
156
+ equations and a result from one is worth reading across.
157
+ ``distinguish`` — they do not: whatever the systems have in common, it is not
158
+ the physics, and a transposition between them would be a metaphor.
159
+
160
+ ⭐ It confers NOTHING when the two systems share too few computable groups to
161
+ judge. That is an unknown, not a negative, and the third outcome exists for the
162
+ same reason the impasse detector's does.
163
+ """
164
+ if comparable < MIN_SHARED:
165
+ return ()
166
+ return ("compare",) if len(matches) >= MIN_SHARED else ("distinguish",)
167
+
168
+
169
+ __all__ = ["AFFORDANCES", "GRAVITY", "MATCH_TOLERANCE", "METHOD_DOC", "METHOD_ID",
170
+ "MIN_SHARED", "VERSION", "affordances", "groups", "matched", "shared"]
@@ -0,0 +1,82 @@
1
+ """Flow regime — the Reynolds number (Tier 1).
2
+
3
+ Re = rho x v x D / mu
4
+
5
+ Dimensionless, exact, and the calculation fluid engineering is organised around: it
6
+ decides whether a flow is orderly enough to predict from first principles or has to
7
+ be treated statistically. Nothing else about a pipe changes that answer.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from oak_understory_common.method_doc import MethodDoc
13
+
14
+ METHOD_DOC = MethodDoc(
15
+ title="Is this flow orderly or churning?",
16
+ what="Works out whether a fluid moving through a pipe travels in smooth layers "
17
+ "or breaks up into churning eddies.",
18
+ inputs="Fluid density in kilograms per cubic metre, average speed in metres per "
19
+ "second, the pipe's internal diameter in metres, and dynamic viscosity in "
20
+ "pascal-seconds.",
21
+ outputs="The Reynolds number — a pure ratio with NO units. It is not a speed, "
22
+ "not a pressure, and a bigger number is not a faster flow.",
23
+ limits="⭐ The 2300 and 4000 boundaries are for STEADY flow in a straight, "
24
+ "smooth, circular pipe, and every one of those words is load-bearing: a "
25
+ "bend, a rough wall, a sudden expansion or a pulsating supply all trip "
26
+ "turbulence early, and a carefully isolated laboratory pipe can stay "
27
+ "laminar into the tens of thousands. Between 2300 and 4000 the flow is "
28
+ "genuinely indeterminate — it is not 'mildly turbulent', it is unstable and "
29
+ "may be either — so this method confers nothing there rather than guessing. "
30
+ "It assumes a Newtonian fluid, so it is wrong for blood, slurries and "
31
+ "polymer melts, whose viscosity depends on how hard they are sheared.",
32
+ tier=1,
33
+ when_to_use="Before applying any pressure-drop or heat-transfer correlation, "
34
+ "because almost all of them are valid in one regime only.",
35
+ references=(
36
+ "Reynolds O, An experimental investigation of the circumstances which "
37
+ "determine whether the motion of water shall be direct or sinuous, 1883.",
38
+ "White FM, Fluid Mechanics, 7th ed.",
39
+ ),
40
+ )
41
+
42
+ #: The classical transition band for pipe flow. The field's, not derived here.
43
+ LAMINAR_BELOW = 2300.0
44
+ TURBULENT_ABOVE = 4000.0
45
+
46
+ METHOD_ID = "engineering.flow.reynolds_number"
47
+ VERSION = "0.1.0"
48
+
49
+ AFFORDANCES: tuple[str, ...] = ("preserve", "combine")
50
+
51
+
52
+ def reynolds_number(density_kg_m3: float, velocity_m_s: float,
53
+ diameter_m: float, viscosity_pa_s: float) -> float:
54
+ """``rho v D / mu``. Dimensionless and exact given the inputs."""
55
+ if min(density_kg_m3, diameter_m, viscosity_pa_s) <= 0:
56
+ raise ValueError("density, diameter and viscosity must be positive")
57
+ if velocity_m_s < 0:
58
+ raise ValueError("velocity may not be negative")
59
+ return density_kg_m3 * velocity_m_s * diameter_m / viscosity_pa_s
60
+
61
+
62
+ def affordances(reynolds: float) -> tuple[str, ...]:
63
+ """What a flow regime lets you do, selected by the computed number.
64
+
65
+ ``preserve`` — laminar: layers stay separate, so anything carried in one stays
66
+ there and the profile is predictable in closed form.
67
+ ``combine`` — turbulent: eddies mix across the whole cross-section, which is
68
+ what you want for mixing and heat transfer and cannot have in laminar flow.
69
+
70
+ Between the two boundaries it confers NOTHING. The transition band is unstable
71
+ rather than intermediate, and a method that split the difference there would be
72
+ asserting a regime the physics does not have.
73
+ """
74
+ if reynolds < LAMINAR_BELOW:
75
+ return ("preserve",)
76
+ if reynolds > TURBULENT_ABOVE:
77
+ return ("combine",)
78
+ return ()
79
+
80
+
81
+ __all__ = ["AFFORDANCES", "LAMINAR_BELOW", "METHOD_ID", "TURBULENT_ABOVE", "VERSION",
82
+ "affordances", "reynolds_number"]
@@ -0,0 +1,156 @@
1
+ """Elastic response — deflection and the yield margin, for a NAMED arrangement (Tier 1).
2
+
3
+ delta = a F L^3 / (E I) a from the support condition
4
+ sigma = M c / I, M = b F L b from the support condition
5
+
6
+ Exact within the elastic regime, which is precisely the thing the second calculation
7
+ is for: it says whether the first one is still allowed to be believed.
8
+
9
+ ## ⭐ Why the support condition is now an argument and not an assumption
10
+
11
+ Until 2026-08-03 this module computed **one** arrangement — a cantilever with a load
12
+ at the free end — behind a decision named `beam-loading`, and exposed no way to say
13
+ so. §3e measured what that cost: the chiller briefing's headline finding, *"423% of
14
+ yield; PAST YIELD"*, reads **52.8% of yield and confers `restore`** for the same total
15
+ load carried as a distributed load on a simple span. **Not a caveat on a failure: a
16
+ pass.** The method's own `limits` had named the gap — *"other supports … are all
17
+ outside it"* — and the protocol gave nobody a way to close it.
18
+
19
+ So the arrangement is a **reading**: a required, declared argument, refused rather
20
+ than defaulted. `M = F L` and `M = F L / 8` are both right, and which one you meant is
21
+ not a detail the library may choose on your behalf.
22
+
23
+ ⭐ The table below is enumerated, which this codebase normally refuses. It is the
24
+ legitimate exception, and the same one `units.py` makes for the SI base symbols:
25
+ these are the standard tabulated cases of the discipline, fixed by Roark rather than
26
+ by judgement, and **an unrecognised name raises instead of falling back.**
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from oak_understory_common.method_doc import MethodDoc
32
+
33
+ METHOD_DOC = MethodDoc(
34
+ title="How far it bends, and whether it springs back",
35
+ what="Works out how far a loaded beam bends, and whether the load leaves it "
36
+ "permanently deformed or lets it return to shape.",
37
+ inputs="Load in newtons, length in metres, the material's elastic modulus in "
38
+ "pascals, the section's second moment of area in metres to the fourth, the "
39
+ "distance from the neutral axis to the outer fibre in metres, and the "
40
+ "material's yield strength in pascals — and the SUPPORT CONDITION, which is "
41
+ "required and has no default.",
42
+ outputs="Deflection in metres and bending stress in pascals. A deflection is a "
43
+ "distance, not a fraction of the length.",
44
+ limits="⭐ Both formulas are ELASTIC and small-deflection: they assume the beam "
45
+ "returns to shape and that it has barely moved. Past yield the first is "
46
+ "simply wrong — it keeps returning a tidy number for a beam that is "
47
+ "bending permanently — which is why the yield margin is computed alongside "
48
+ "rather than left to the caller. ⭐ It covers the five STANDARD support "
49
+ "conditions and no others: a partially fixed end, a continuous beam over "
50
+ "several supports, an overhang, a non-central point load and any combined "
51
+ "loading are all outside it, and the answers differ by factors of several "
52
+ "between arrangements — which is why the support condition is a required "
53
+ "argument here and not a default. Buckling, fatigue under repeated loading "
54
+ "and dynamic amplification are outside it too, and fatigue in particular "
55
+ "breaks things that never come close to yield in a single application.",
56
+ tier=1,
57
+ when_to_use="For a first-pass sizing check, and to know whether a more careful "
58
+ "analysis is needed because yield is close.",
59
+ references=(
60
+ "Gere JM & Goodno BJ, Mechanics of Materials, 8th ed.",
61
+ "Roark's Formulas for Stress and Strain, 8th ed., Table 8.1.",
62
+ ),
63
+ )
64
+
65
+ #: Utilisation above which the elastic assumption is no longer safe to rely on. A
66
+ #: conventional design margin, not derived from anything measured here.
67
+ YIELD_MARGIN = 1.0
68
+
69
+ #: ⭐ The standard tabulated cases: (deflection coefficient a, moment coefficient b),
70
+ #: so ``delta = a F L^3 / (E I)`` and ``M = b F L``. For the distributed cases F is
71
+ #: the TOTAL load on the span, which is what makes the arrangements comparable at
72
+ #: equal load — the comparison §3e turned on.
73
+ #:
74
+ #: ⚠️ Enumerated deliberately, and an unknown name RAISES. See the module docstring.
75
+ SUPPORTS: dict[str, tuple[float, float]] = {
76
+ "cantilever-tip-point": (1.0 / 3.0, 1.0),
77
+ "simply-supported-central-point": (1.0 / 48.0, 1.0 / 4.0),
78
+ "simply-supported-distributed": (5.0 / 384.0, 1.0 / 8.0),
79
+ "fixed-fixed-central-point": (1.0 / 192.0, 1.0 / 8.0),
80
+ "fixed-fixed-distributed": (1.0 / 384.0, 1.0 / 12.0),
81
+ }
82
+
83
+ #: ⚠️ The id is KEPT although the module now covers five arrangements. A method id is
84
+ #: a published name that experiments, results JSONs and tests bind to by string, and
85
+ #: the standing rule is EXTEND, NEVER RENAME. The VERSION carries the change instead.
86
+ METHOD_ID = "engineering.mechanics.cantilever"
87
+ VERSION = "0.2.0"
88
+
89
+ AFFORDANCES: tuple[str, ...] = ("restore", "degrade")
90
+
91
+
92
+ def coefficients(support: str) -> tuple[float, float]:
93
+ """``(a, b)`` for a named arrangement. ⭐ Raises on anything unrecognised.
94
+
95
+ Falling back to a default here would reinstate the exact defect this argument
96
+ was added to remove, and it would do it invisibly.
97
+ """
98
+ try:
99
+ return SUPPORTS[support]
100
+ except (KeyError, TypeError):
101
+ raise ValueError(
102
+ f"{support!r} is not one of the standard arrangements "
103
+ f"{sorted(SUPPORTS)}. There is no default: which arrangement the number "
104
+ f"describes changes it by a factor of several, and guessing on the "
105
+ f"caller's behalf is how a pass was reported as a failure (§3e)."
106
+ ) from None
107
+
108
+
109
+ def max_deflection_m(load_n: float, length_m: float, modulus_pa: float,
110
+ second_moment_m4: float, support: str) -> float:
111
+ """``a F L³ / (E I)`` in metres, for the named arrangement."""
112
+ if min(length_m, modulus_pa, second_moment_m4) <= 0:
113
+ raise ValueError("length, modulus and second moment must be positive")
114
+ a, _ = coefficients(support)
115
+ return a * load_n * length_m ** 3 / (modulus_pa * second_moment_m4)
116
+
117
+
118
+ def bending_stress_pa(load_n: float, length_m: float, fibre_distance_m: float,
119
+ second_moment_m4: float, support: str) -> float:
120
+ """``M c / I`` at the governing section, where ``M = b F L``."""
121
+ if min(length_m, fibre_distance_m, second_moment_m4) <= 0:
122
+ raise ValueError("length, fibre distance and second moment must be positive")
123
+ _, b = coefficients(support)
124
+ return b * load_n * length_m * fibre_distance_m / second_moment_m4
125
+
126
+
127
+ def tip_deflection_m(load_n: float, length_m: float, modulus_pa: float,
128
+ second_moment_m4: float) -> float:
129
+ """⚠️ LEGACY — the cantilever-only deflection, kept because it is a published
130
+ name. New callers use :func:`max_deflection_m` with an explicit arrangement."""
131
+ return max_deflection_m(load_n, length_m, modulus_pa, second_moment_m4,
132
+ "cantilever-tip-point")
133
+
134
+
135
+ def utilisation(stress_pa: float, yield_pa: float) -> float:
136
+ """Stress over yield strength. 1.0 is the elastic limit exactly."""
137
+ if yield_pa <= 0:
138
+ raise ValueError("yield strength must be positive")
139
+ return stress_pa / yield_pa
140
+
141
+
142
+ def affordances(stress_pa: float, yield_pa: float) -> tuple[str, ...]:
143
+ """What the loaded member lets you do, selected by the computed utilisation.
144
+
145
+ ``restore`` — within the elastic limit, so the deflection is reversible and the
146
+ part returns to shape when the load comes off. ⭐ Named `restore` and not
147
+ `recover`, which `materials` claims.
148
+ ``degrade`` — past yield: the deformation is permanent, and the deflection
149
+ number computed above has stopped being true of it.
150
+ """
151
+ return ("restore",) if utilisation(stress_pa, yield_pa) < YIELD_MARGIN else ("degrade",)
152
+
153
+
154
+ __all__ = ["AFFORDANCES", "METHOD_ID", "SUPPORTS", "VERSION", "YIELD_MARGIN",
155
+ "affordances", "bending_stress_pa", "coefficients", "max_deflection_m",
156
+ "tip_deflection_m", "utilisation"]