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 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 19:17:59 -05:00
parent d871635779
commit 6cdd308583
3 changed files with 71 additions and 61 deletions

View File

@@ -106,13 +106,18 @@ ENERGY_FORM_RELIABILITY: dict[str, float] = {
# validated it against the platform's ceiling), so it's used as the point # validated it against the platform's ceiling), so it's used as the point
# estimate rather than an invented one. # estimate rather than an invented one.
# Human/animal actuators correctly declare mass_min=0 (a rider's body isn't # Human/animal actuators declare mass_min=0 (there's no minimum purchase
# purchasable vehicle-borne mass and must not compete for the platform's # quantity for a rider the way there is for an engine), but treated as a
# mass budget), but that same 0 breaks power = power_density * mass. Fix: # literal floor that lets the optimizer size a payload down toward 0kg of
# a fixed physiological reference mass used only in the power formula, # operator -- nonsensical, and it also breaks power = power_density * mass.
# added to -- never substituted into -- the vehicle's own mass budget. # 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_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 # A platform's declared mass range often spans a whole real-world class, not
@@ -859,28 +864,21 @@ class Pipeline:
ctx: "_PhysicsContext", ctx: "_PhysicsContext",
actuator_mass: float, actuator_mass: float,
storage_mass: float, storage_mass: float,
power_mass: float,
denom_offset: float,
bounds_by_name: dict[str, MetricBound], bounds_by_name: dict[str, MetricBound],
units_by_name: dict[str, str], units_by_name: dict[str, str],
cargo_capacity_kg: float, cargo_capacity_kg: float,
platform_mass: float | None = None, platform_mass: float | None = None,
) -> dict[str, float]: ) -> dict[str, float]:
"""power_density/range_fuel/cost_efficiency for an EXPLICIT mass """power_density/range_fuel/cost_efficiency for an EXPLICIT mass
allocation. `power_mass` is separate from `actuator_mass` for the allocation. `platform_mass` defaults to the platform's
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
representative mass (ctx.p_rep) -- pass an explicit value to representative mass (ctx.p_rep) -- pass an explicit value to
explore a specific weight class instead (see evaluate_allocation).""" explore a specific weight class instead (see evaluate_allocation)."""
p_mass = ctx.p_rep if platform_mass is None else platform_mass p_mass = ctx.p_rep if platform_mass is None else platform_mass
out: dict[str, float] = {} out: dict[str, float] = {}
floor_total = p_mass + actuator_mass + storage_mass floor_total = p_mass + actuator_mass + storage_mass
physics_denom = floor_total + denom_offset
if "power_density" in bounds_by_name: 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 "range_fuel" in bounds_by_name:
if ctx.storage_energy_form in AMBIENT_ENERGY_FORMS or ctx.k_med is None: 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], bounds_by_name: dict[str, MetricBound],
units_by_name: dict[str, str], units_by_name: dict[str, str],
cargo_capacity_kg: float, 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 """Pick the platform/actuator/storage mass for the build this domain
actually scores. First, the platform's declared physical actually scores. First, the platform's declared physical
performance target (accel/thrust, or target_velocity/resistance) performance target (accel/thrust, or target_velocity/resistance)
sets a FLOOR -- a rotorcraft that can't produce enough thrust to sets a FLOOR -- a rotorcraft that can't produce enough thrust to
hover isn't a rotorcraft, regardless of how a smaller/cheaper hover isn't a rotorcraft, regardless of how a smaller/cheaper
engine might score. That floor also sets the smallest platform engine might score. Biological actuators (a rider's own body) get
mass that could structurally carry it (CARGO_KG_PER_STRUCTURAL_KG the same treatment with one addition: BIOLOGICAL_OPERATOR_MASS_KG
again, applied to the platform carrying its own actuator+storage sets a floor under the floor -- at least one real operator, even if
instead of cargo) -- below that, no actuator/storage choice is the performance-derived requirement would otherwise ask for less
physically possible. Above that lower bound, platform mass is a -- but above that, mass is a free variable exactly like a
real THIRD search variable, not fixed at p_rep: a bigger platform mechanical actuator's; "bigger" here means more or bigger
also raises the structural cap on how much actuator+storage it can operators, not a fixed physiological constant. That floor also
carry, so growing all three together can score higher than sets the smallest platform mass that could structurally carry it
minimizing platform down to what's merely required. Searched (CARGO_KG_PER_STRUCTURAL_KG again, applied to the platform
jointly (outer coarse-to-fine scan over platform mass, inner carrying its own actuator+storage instead of cargo) -- below that,
coarse-to-fine scan over actuator/storage at each candidate) for no actuator/storage choice is physically possible. Above that
whatever allocation maximizes this domain's own weighted composite lower bound, platform mass is a real THIRD search variable, not
score, using the same normalize()/composite_score() the real fixed at p_rep: a bigger platform also raises the structural cap
scoring pass uses. Not "just enough to function" and not "best on how much actuator+storage it can carry, so growing all three
score regardless of function" -- both, floor then optimize jointly. together can score higher than minimizing platform down to what's
Returns (actuator_mass, storage_mass, power_mass, denom_offset, merely required. Searched jointly (outer coarse-to-fine scan over
platform_mass, feasible); see _raw_physics_from_masses for what platform mass, inner coarse-to-fine scan over actuator/storage at
power_mass and denom_offset mean. `feasible` is False only when no each candidate) for whatever allocation maximizes this domain's
platform mass within its own declared ceiling could structurally own weighted composite score, using the same normalize()/
carry the required floor -- power_density/range_fuel/cost_efficiency composite_score() the real scoring pass uses. Not "just enough to
are all per-kg ratios, so they don't naturally penalize a build function" and not "best score regardless of function" -- both,
whose absolute mass tramples its own platform's declared ceiling; 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 callers must treat an infeasible build as a hard fail rather than
trusting the (still-computable, still ratio-plausible) score. Also trusting the (still-computable, still ratio-plausible) score. Also
used by evaluate_allocation to compute the slider's starting used by evaluate_allocation to compute the slider's starting
@@ -967,16 +970,15 @@ class Pipeline:
return float(dep.value) return float(dep.value)
return None 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": if ctx.actuator_energy_form == "radiation_pressure":
# thrust scales with sail area, not carried mass -- derive an # thrust scales with sail area, not carried mass -- derive an
# effective mass from declared footprint and a thin-film areal # effective mass from declared footprint and a thin-film areal
# density estimate rather than the (undeclared) mass attribute. # 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 footprint = dep_value(ctx.actuator, "footprint", "range_min") or 0.0
actuator_mass = footprint * 0.05 # kg/m^2, thin deployable sail film 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 # Step 1: the required floor (same solve as before -- now a floor
# for the search below, not the final answer). # 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_actuator = ctx.a_min if ctx.a_min > 0.0 else 10.0
required_storage = ctx.s_min 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: if ctx.p_max is None:
# No declared mass ceiling (e.g. Spaceship) -- no bounded # No declared mass ceiling (e.g. Spaceship) -- no bounded
# budget to search within, use the requirement floor as-is. # 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) a_floor = max(ctx.a_min, required_actuator)
s_floor = max(ctx.s_min, required_storage) 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 # per-kg ratios, so they don't naturally penalize a build whose
# ABSOLUTE mass tramples its own platform's declared ceiling -- # ABSOLUTE mass tramples its own platform's declared ceiling --
# something else has to catch that). # 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: def objective(platform_mass: float, actuator_mass: float, storage_mass: float) -> float:
raw = self._raw_physics_from_masses( 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, bounds_by_name, units_by_name, cargo_capacity_kg,
platform_mass=platform_mass, platform_mass=platform_mass,
) )
@@ -1115,7 +1123,7 @@ class Pipeline:
best_score, best_p = sc, p best_score, best_p = sc, p
actuator_mass, storage_mass, _score = best_at_platform(best_p, grid=12, rounds=6) 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 @staticmethod
def _search_best_allocation( def _search_best_allocation(
@@ -1224,11 +1232,11 @@ class Pipeline:
ctx = self._physics_context(combo, bounds_by_name) ctx = self._physics_context(combo, bounds_by_name)
feasible = True feasible = True
if ctx is not None: 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 ctx, bounds_by_name, units_by_name, cargo_capacity_kg
) )
raw.update(self._raw_physics_from_masses( 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, bounds_by_name, units_by_name, cargo_capacity_kg,
platform_mass=platform_mass, platform_mass=platform_mass,
)) ))
@@ -1289,23 +1297,22 @@ class Pipeline:
never silently allowed below what pass 1 would have rejected. never silently allowed below what pass 1 would have rejected.
Returns None for combos with no free actuator mass to explore Returns None for combos with no free actuator mass to explore
(biological actuators, radiation-pressure sails -- see (radiation-pressure sails -- thrust is area-driven, not a mass
_stub_estimate's module note) or with no declared platform mass choice, see _decide_masses) or with no declared platform mass
ceiling to bound a weight-class slider. 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} 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} units_by_name = {mb.metric_name: mb.unit for mb in domain.metric_bounds}
ctx = self._physics_context(combo, bounds_by_name) ctx = self._physics_context(combo, bounds_by_name)
if ctx is None or ctx.p_max is None: if ctx is None or ctx.p_max is None:
return None return None
if ( if ctx.actuator_energy_form == "radiation_pressure":
ctx.actuator_energy_form in BIOLOGICAL_OPERATOR_MASS_KG
or ctx.actuator_energy_form == "radiation_pressure"
):
return None return None
cargo_capacity_kg = (ctx.p_min + ctx.a_min + ctx.s_min) * CARGO_KG_PER_STRUCTURAL_KG 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 ctx, bounds_by_name, units_by_name, cargo_capacity_kg
) )
p_mass = default_platform if platform_mass is None else platform_mass 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) s_mass = max(ctx.s_min, s_mass)
raw = self._raw_physics_from_masses( 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, bounds_by_name, units_by_name, cargo_capacity_kg,
platform_mass=p_mass, platform_mass=p_mass,
) )

View File

@@ -1,7 +1,8 @@
{% if explore_result is none %} {% if explore_result is none %}
<p class="empty">No free mass allocation to explore for this combination — its <p class="empty">No free mass allocation to explore for this combination — its
actuator's mass isn't a design choice (a physiological or footprint-derived actuator's mass isn't a design choice (a footprint-derived quantity, like a
quantity), or the platform has no declared mass ceiling to bound the sliders.</p> radiation-pressure sail), or the platform has no declared mass ceiling to
bound the sliders.</p>
{% else %} {% else %}
{% set r = explore_result %} {% set r = explore_result %}
<div class="optimize-summary"> <div class="optimize-summary">

View File

@@ -129,7 +129,7 @@
</div> </div>
{% endif %} {% endif %}
{% if explore_result is not none %} {% if scores %}
<h2>Explore: Scale the Build</h2> <h2>Explore: Scale the Build</h2>
<p class="subtitle"> <p class="subtitle">
Purely exploratory — nothing here is saved. Drag a slider to pick a Purely exploratory — nothing here is saved. Drag a slider to pick a
@@ -141,6 +141,7 @@
or merely functional one. or merely functional one.
</p> </p>
<div class="card"> <div class="card">
{% if explore_result is not none %}
{% set r = explore_result %} {% set r = explore_result %}
<form id="explore-form" <form id="explore-form"
hx-post="{{ url_for('results.explore', domain_name=domain.name, combo_id=combo.id) }}" hx-post="{{ url_for('results.explore', domain_name=domain.name, combo_id=combo.id) }}"
@@ -154,7 +155,7 @@
<output id="out_platform_mass">{{ "%.1f"|format(r.platform_mass) }}kg</output> <output id="out_platform_mass">{{ "%.1f"|format(r.platform_mass) }}kg</output>
</div> </div>
<div class="weight-slider-row"> <div class="weight-slider-row">
<label for="actuator_mass">actuator (motor size)</label> <label for="actuator_mass">actuator (motor size, or operator count/size for muscle power)</label>
<input type="range" min="{{ r.actuator_min }}" max="{{ r.actuator_slider_max }}" step="0.1" <input type="range" min="{{ r.actuator_min }}" max="{{ r.actuator_slider_max }}" step="0.1"
id="actuator_mass" name="actuator_mass" value="{{ r.actuator_mass }}" id="actuator_mass" name="actuator_mass" value="{{ r.actuator_mass }}"
oninput="document.getElementById('out_actuator_mass').textContent = (+this.value).toFixed(1) + 'kg'"> oninput="document.getElementById('out_actuator_mass').textContent = (+this.value).toFixed(1) + 'kg'">
@@ -168,6 +169,7 @@
<output id="out_storage_mass">{{ "%.1f"|format(r.storage_mass) }}kg</output> <output id="out_storage_mass">{{ "%.1f"|format(r.storage_mass) }}kg</output>
</div> </div>
</form> </form>
{% endif %}
<div id="explore-result"> <div id="explore-result">
{% include "results/_explore_result.html" %} {% include "results/_explore_result.html" %}
</div> </div>