From 6cdd308583b9a6555eba878214ab95a0f08e1a31 Mon Sep 17 00:00:00 2001 From: Andrew Simonson Date: Sat, 15 Aug 2026 19:17:59 -0500 Subject: [PATCH] treat biological actuators as normal budget-competing mass, fix explore-panel visibility Biological actuators (Human Muscle, Animal Traction) were special-cased out of the mass optimizer entirely: a fixed 70kg reference used only in the power formula, excluded from the platform's mass budget and from the explore-panel sliders. That made "bigger operator" or "more operators" inexpressible, and required a power_mass/denom_offset parameter pair throughout the physics code solely to keep this one case's numerator mass separate from its budget mass. Operator mass is now a normal, budget-competing, structurally-carried variable sized by the same joint optimizer as any mechanical actuator, with BIOLOGICAL_OPERATOR_MASS_KG reinterpreted as a floor (at least one real operator) rather than a fixed value -- the explore slider now reads as "how many/how large are the operators." Since every remaining case set power_mass == actuator_mass and denom_offset == 0.0 anyway, those parameters were entirely vestigial once biological's special case was gone, so _raw_physics_from_masses drops them. Also: the explore section was gated on `explore_result is not none`, so combos with no free mass to explore (radiation-pressure sails, or previously biological) showed nothing at all instead of the existing explanatory message. Gated on `scores` instead, so the section always renders and the message inside `_explore_result.html` is reachable. Co-Authored-By: Claude Sonnet 5 --- src/physcom/engine/pipeline.py | 121 +++++++++--------- .../templates/results/_explore_result.html | 5 +- src/physcom_web/templates/results/detail.html | 6 +- 3 files changed, 71 insertions(+), 61 deletions(-) diff --git a/src/physcom/engine/pipeline.py b/src/physcom/engine/pipeline.py index c702771..bd2ca47 100644 --- a/src/physcom/engine/pipeline.py +++ b/src/physcom/engine/pipeline.py @@ -106,13 +106,18 @@ ENERGY_FORM_RELIABILITY: dict[str, float] = { # validated it against the platform's ceiling), so it's used as the point # estimate rather than an invented one. -# Human/animal actuators correctly declare mass_min=0 (a rider's body isn't -# purchasable vehicle-borne mass and must not compete for the platform's -# mass budget), but that same 0 breaks power = power_density * mass. Fix: -# a fixed physiological reference mass used only in the power formula, -# added to -- never substituted into -- the vehicle's own mass budget. +# Human/animal actuators declare mass_min=0 (there's no minimum purchase +# quantity for a rider the way there is for an engine), but treated as a +# literal floor that lets the optimizer size a payload down toward 0kg of +# operator -- nonsensical, and it also breaks power = power_density * mass. +# Used as a FLOOR (not a fixed value) on top of the declared mass_min: at +# least one real operator must be present. Above that floor, actuator mass +# is a free, budget-competing, structurally-carried variable exactly like +# any mechanical actuator -- the "size" slider means more or bigger +# operators (a loaded cargo trike, a two-horse team), sized by the same +# joint optimizer everything else uses, not a fixed physiological constant. BIOLOGICAL_OPERATOR_MASS_KG: dict[str, float] = { - "biological": 70.0, # human rider; Animal Traction shares this form too + "biological": 70.0, # one average human rider; Animal Traction shares this form too } # A platform's declared mass range often spans a whole real-world class, not @@ -859,28 +864,21 @@ class Pipeline: ctx: "_PhysicsContext", actuator_mass: float, storage_mass: float, - power_mass: float, - denom_offset: float, bounds_by_name: dict[str, MetricBound], units_by_name: dict[str, str], cargo_capacity_kg: float, platform_mass: float | None = None, ) -> dict[str, float]: """power_density/range_fuel/cost_efficiency for an EXPLICIT mass - allocation. `power_mass` is separate from `actuator_mass` for the - biological/radiation-pressure special cases (see _stub_estimate), - where the numerator mass isn't the same as the build-budget mass; - for the normal (solved, optimized, or manually-explored) case - they're the same value. `platform_mass` defaults to the platform's + allocation. `platform_mass` defaults to the platform's representative mass (ctx.p_rep) -- pass an explicit value to explore a specific weight class instead (see evaluate_allocation).""" p_mass = ctx.p_rep if platform_mass is None else platform_mass out: dict[str, float] = {} floor_total = p_mass + actuator_mass + storage_mass - physics_denom = floor_total + denom_offset if "power_density" in bounds_by_name: - out["power_density"] = (ctx.k_act * power_mass) / physics_denom if physics_denom else 0.0 + out["power_density"] = (ctx.k_act * actuator_mass) / floor_total if floor_total else 0.0 if "range_fuel" in bounds_by_name: if ctx.storage_energy_form in AMBIENT_ENERGY_FORMS or ctx.k_med is None: @@ -927,34 +925,39 @@ class Pipeline: bounds_by_name: dict[str, MetricBound], units_by_name: dict[str, str], cargo_capacity_kg: float, - ) -> tuple[float, float, float, float, float, bool]: + ) -> tuple[float, float, float, bool]: """Pick the platform/actuator/storage mass for the build this domain actually scores. First, the platform's declared physical performance target (accel/thrust, or target_velocity/resistance) sets a FLOOR -- a rotorcraft that can't produce enough thrust to hover isn't a rotorcraft, regardless of how a smaller/cheaper - engine might score. That floor also sets the smallest platform - mass that could structurally carry it (CARGO_KG_PER_STRUCTURAL_KG - again, applied to the platform carrying its own actuator+storage - instead of cargo) -- below that, no actuator/storage choice is - physically possible. Above that lower bound, platform mass is a - real THIRD search variable, not fixed at p_rep: a bigger platform - also raises the structural cap on how much actuator+storage it can - carry, so growing all three together can score higher than - minimizing platform down to what's merely required. Searched - jointly (outer coarse-to-fine scan over platform mass, inner - coarse-to-fine scan over actuator/storage at each candidate) for - whatever allocation maximizes this domain's own weighted composite - score, using the same normalize()/composite_score() the real - scoring pass uses. Not "just enough to function" and not "best - score regardless of function" -- both, floor then optimize jointly. - Returns (actuator_mass, storage_mass, power_mass, denom_offset, - platform_mass, feasible); see _raw_physics_from_masses for what - power_mass and denom_offset mean. `feasible` is False only when no - platform mass within its own declared ceiling could structurally - carry the required floor -- power_density/range_fuel/cost_efficiency - are all per-kg ratios, so they don't naturally penalize a build - whose absolute mass tramples its own platform's declared ceiling; + engine might score. Biological actuators (a rider's own body) get + the same treatment with one addition: BIOLOGICAL_OPERATOR_MASS_KG + sets a floor under the floor -- at least one real operator, even if + the performance-derived requirement would otherwise ask for less + -- but above that, mass is a free variable exactly like a + mechanical actuator's; "bigger" here means more or bigger + operators, not a fixed physiological constant. That floor also + sets the smallest platform mass that could structurally carry it + (CARGO_KG_PER_STRUCTURAL_KG again, applied to the platform + carrying its own actuator+storage instead of cargo) -- below that, + no actuator/storage choice is physically possible. Above that + lower bound, platform mass is a real THIRD search variable, not + fixed at p_rep: a bigger platform also raises the structural cap + on how much actuator+storage it can carry, so growing all three + together can score higher than minimizing platform down to what's + merely required. Searched jointly (outer coarse-to-fine scan over + platform mass, inner coarse-to-fine scan over actuator/storage at + each candidate) for whatever allocation maximizes this domain's + own weighted composite score, using the same normalize()/ + composite_score() the real scoring pass uses. Not "just enough to + function" and not "best score regardless of function" -- both, + floor then optimize jointly. Returns (actuator_mass, storage_mass, + platform_mass, feasible). `feasible` is False only when no platform + mass within its own declared ceiling could structurally carry the + required floor -- power_density/range_fuel/cost_efficiency are all + per-kg ratios, so they don't naturally penalize a build whose + absolute mass tramples its own platform's declared ceiling; callers must treat an infeasible build as a hard fail rather than trusting the (still-computable, still ratio-plausible) score. Also used by evaluate_allocation to compute the slider's starting @@ -967,16 +970,15 @@ class Pipeline: return float(dep.value) return None - if ctx.actuator_energy_form in BIOLOGICAL_OPERATOR_MASS_KG: - power_mass = BIOLOGICAL_OPERATOR_MASS_KG[ctx.actuator_energy_form] - return ctx.a_min, ctx.s_min, power_mass, power_mass, ctx.p_rep, True if ctx.actuator_energy_form == "radiation_pressure": # thrust scales with sail area, not carried mass -- derive an # effective mass from declared footprint and a thin-film areal # density estimate rather than the (undeclared) mass attribute. + # No meaningful "bigger sail" mass slider here (area-driven, + # not budget-driven), so this stays its own case. footprint = dep_value(ctx.actuator, "footprint", "range_min") or 0.0 actuator_mass = footprint * 0.05 # kg/m^2, thin deployable sail film - return actuator_mass, ctx.s_min, actuator_mass, 0.0, ctx.p_rep, True + return actuator_mass, ctx.s_min, ctx.p_rep, True # Step 1: the required floor (same solve as before -- now a floor # for the search below, not the final answer). @@ -1019,10 +1021,16 @@ class Pipeline: required_actuator = ctx.a_min if ctx.a_min > 0.0 else 10.0 required_storage = ctx.s_min + if ctx.actuator_energy_form in BIOLOGICAL_OPERATOR_MASS_KG: + # At least one real operator, regardless of what the bare + # performance solve above would have asked for -- see the + # BIOLOGICAL_OPERATOR_MASS_KG module comment. + required_actuator = max(required_actuator, BIOLOGICAL_OPERATOR_MASS_KG[ctx.actuator_energy_form]) + if ctx.p_max is None: # No declared mass ceiling (e.g. Spaceship) -- no bounded # budget to search within, use the requirement floor as-is. - return required_actuator, required_storage, required_actuator, 0.0, ctx.p_rep, True + return required_actuator, required_storage, ctx.p_rep, True a_floor = max(ctx.a_min, required_actuator) s_floor = max(ctx.s_min, required_storage) @@ -1048,11 +1056,11 @@ class Pipeline: # per-kg ratios, so they don't naturally penalize a build whose # ABSOLUTE mass tramples its own platform's declared ceiling -- # something else has to catch that). - return a_floor, s_floor, a_floor, 0.0, p_lo, False + return a_floor, s_floor, p_lo, False def objective(platform_mass: float, actuator_mass: float, storage_mass: float) -> float: raw = self._raw_physics_from_masses( - ctx, actuator_mass, storage_mass, actuator_mass, 0.0, + ctx, actuator_mass, storage_mass, bounds_by_name, units_by_name, cargo_capacity_kg, platform_mass=platform_mass, ) @@ -1115,7 +1123,7 @@ class Pipeline: best_score, best_p = sc, p actuator_mass, storage_mass, _score = best_at_platform(best_p, grid=12, rounds=6) - return actuator_mass, storage_mass, actuator_mass, 0.0, best_p, True + return actuator_mass, storage_mass, best_p, True @staticmethod def _search_best_allocation( @@ -1224,11 +1232,11 @@ class Pipeline: ctx = self._physics_context(combo, bounds_by_name) feasible = True if ctx is not None: - actuator_mass, storage_mass, power_mass, denom_offset, platform_mass, feasible = self._decide_masses( + actuator_mass, storage_mass, platform_mass, feasible = self._decide_masses( ctx, bounds_by_name, units_by_name, cargo_capacity_kg ) raw.update(self._raw_physics_from_masses( - ctx, actuator_mass, storage_mass, power_mass, denom_offset, + ctx, actuator_mass, storage_mass, bounds_by_name, units_by_name, cargo_capacity_kg, platform_mass=platform_mass, )) @@ -1289,23 +1297,22 @@ class Pipeline: never silently allowed below what pass 1 would have rejected. Returns None for combos with no free actuator mass to explore - (biological actuators, radiation-pressure sails -- see - _stub_estimate's module note) or with no declared platform mass - ceiling to bound a weight-class slider. + (radiation-pressure sails -- thrust is area-driven, not a mass + choice, see _decide_masses) or with no declared platform mass + ceiling to bound a weight-class slider. Biological actuators DO + get sliders: rider/operator mass is a real, budget-competing + variable like any other actuator (see BIOLOGICAL_OPERATOR_MASS_KG). """ bounds_by_name = {mb.metric_name: mb for mb in domain.metric_bounds} units_by_name = {mb.metric_name: mb.unit for mb in domain.metric_bounds} ctx = self._physics_context(combo, bounds_by_name) if ctx is None or ctx.p_max is None: return None - if ( - ctx.actuator_energy_form in BIOLOGICAL_OPERATOR_MASS_KG - or ctx.actuator_energy_form == "radiation_pressure" - ): + if ctx.actuator_energy_form == "radiation_pressure": return None cargo_capacity_kg = (ctx.p_min + ctx.a_min + ctx.s_min) * CARGO_KG_PER_STRUCTURAL_KG - default_actuator, default_storage, _power_mass, _denom_offset, default_platform, _feasible = self._decide_masses( + default_actuator, default_storage, default_platform, _feasible = self._decide_masses( ctx, bounds_by_name, units_by_name, cargo_capacity_kg ) p_mass = default_platform if platform_mass is None else platform_mass @@ -1317,7 +1324,7 @@ class Pipeline: s_mass = max(ctx.s_min, s_mass) raw = self._raw_physics_from_masses( - ctx, a_mass, s_mass, a_mass, 0.0, + ctx, a_mass, s_mass, bounds_by_name, units_by_name, cargo_capacity_kg, platform_mass=p_mass, ) diff --git a/src/physcom_web/templates/results/_explore_result.html b/src/physcom_web/templates/results/_explore_result.html index 177e329..dad062f 100644 --- a/src/physcom_web/templates/results/_explore_result.html +++ b/src/physcom_web/templates/results/_explore_result.html @@ -1,7 +1,8 @@ {% if explore_result is none %}

No free mass allocation to explore for this combination — its -actuator's mass isn't a design choice (a physiological or footprint-derived -quantity), or the platform has no declared mass ceiling to bound the sliders.

+actuator's mass isn't a design choice (a footprint-derived quantity, like a +radiation-pressure sail), or the platform has no declared mass ceiling to +bound the sliders.

{% else %} {% set r = explore_result %}
diff --git a/src/physcom_web/templates/results/detail.html b/src/physcom_web/templates/results/detail.html index f7525e7..dae8af3 100644 --- a/src/physcom_web/templates/results/detail.html +++ b/src/physcom_web/templates/results/detail.html @@ -129,7 +129,7 @@
{% endif %} -{% if explore_result is not none %} +{% if scores %}

Explore: Scale the Build

Purely exploratory — nothing here is saved. Drag a slider to pick a @@ -141,6 +141,7 @@ or merely functional one.

+ {% if explore_result is not none %} {% set r = explore_result %}
{{ "%.1f"|format(r.platform_mass) }}kg
- + @@ -168,6 +169,7 @@ {{ "%.1f"|format(r.storage_mass) }}kg
+ {% endif %}
{% include "results/_explore_result.html" %}