remove food from ambient energy forms, size solar sails to actual power needs

Biological Feed (food) was treated as an "ambient" energy source, so
range_fuel always reported the domain's ceiling regardless of how much
food was carried -- stopping to eat is a resupply, the same category as
refuelling a tank, not a genuinely external/inexhaustible source like
sun or wind. Removed "biological" from AMBIENT_ENERGY_FORMS; food now
uses the normal storage-mass-limited range formula like any fuel.

Solar Sail was still special-cased to a fixed footprint-derived mass
(100m^2 -> 5kg) regardless of what a domain's power/velocity target
actually needed -- the same "fixed reference instead of a requirement
floor" bug biological actuators had before last session's fix. Folded
it into the same general requirement-floor + joint-optimizer path:
declared footprint becomes a FLOOR (SAIL_AREAL_DENSITY_KG_PER_M2), not
a fixed value, so sail size scales with what's actually needed --
"enough panels to supply enough power for actuator impulse" is now
enforced the same way structural/mass-ceiling requirements already are,
instead of relying on a product-spec constant that happened to work or
not. No actuator type is special-cased for mass sizing anymore.

Confirmed the fix surfaces an honest result rather than hiding one: a
solar sail's declared 0.01 W/kg specific power can never reach
interplanetary_travel's 10 W/kg power_density floor at any sail size
(the ratio is capped by the sail's own power_density regardless of
scale), so it correctly still scores 0 there -- a real technology/domain
mismatch, not a sizing bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 19:37:38 -05:00
parent 6cdd308583
commit d1f14dbf14
3 changed files with 46 additions and 36 deletions

View File

@@ -106,6 +106,16 @@ 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.
# Radiation-pressure actuators (solar sails) don't declare a "mass" at
# all -- thrust scales with sail area, not carried mass -- so their
# effective mass is derived from declared footprint via a thin deployable
# sail film's areal density. Used the same way as BIOLOGICAL_OPERATOR_MASS_KG
# below: converts the entity's declared footprint FLOOR into a mass floor,
# not a fixed value -- above it, effective mass is a free, budget-competing
# variable like any other actuator (bigger sail = more collected power),
# sized by the same joint optimizer, not a one-off product spec.
SAIL_AREAL_DENSITY_KG_PER_M2: float = 0.05
# Human/animal actuators declare mass_min=0 (there's no minimum purchase # 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 # 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 # literal floor that lets the optimizer size a payload down toward 0kg of
@@ -187,13 +197,16 @@ def _solve_two_requirement_masses(
return a_min, s_min return a_min, s_min
return max(a, a_min), max(s, s_min) return max(a, a_min), max(s, s_min)
# Ambient energy forms (sun, wind, gravity, food) aren't a depletable # Ambient energy forms (sun, wind, gravity) aren't a depletable onboard
# onboard store the way a fuel tank is -- "distance before running out" # store the way a fuel tank is -- "distance before running out" doesn't
# doesn't apply (a sailboat doesn't run out of wind). Rather than # apply (a sailboat doesn't run out of wind). Rather than degenerate to 0
# degenerate to 0 (mass_min=0, energy_density often undeclared entirely), # (mass_min=0, energy_density often undeclared entirely), range_fuel
# range_fuel reports the domain's own declared ceiling for these: full # reports the domain's own declared ceiling for these: full marks is the
# marks is the physically honest answer, not an error. # physically honest answer, not an error. Food is deliberately NOT here:
AMBIENT_ENERGY_FORMS: set[str] = {"biological", "wind", "radiation_pressure", "gravitational"} # stopping to eat is a resupply, the same category as refuelling a tank,
# not a genuinely external/inexhaustible power source -- Biological Feed
# uses the normal storage-mass-limited range_fuel formula.
AMBIENT_ENERGY_FORMS: set[str] = {"wind", "radiation_pressure", "gravitational"}
# Resistive energy cost of travel, J per kg of vehicle per meter -- # Resistive energy cost of travel, J per kg of vehicle per meter --
# rolling resistance for ground vehicles, cruise-flight lift/drag for # rolling resistance for ground vehicles, cruise-flight lift/drag for
@@ -931,13 +944,16 @@ class Pipeline:
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. Biological actuators (a rider's own body) get engine might score. Biological actuators (a rider's own body) and
the same treatment with one addition: BIOLOGICAL_OPERATOR_MASS_KG radiation-pressure actuators (a solar sail) get the same treatment
sets a floor under the floor -- at least one real operator, even if with one addition: BIOLOGICAL_OPERATOR_MASS_KG / a footprint-derived
the performance-derived requirement would otherwise ask for less floor (see SAIL_AREAL_DENSITY_KG_PER_M2) sets a floor under the
-- but above that, mass is a free variable exactly like a floor -- at least one real operator, or the sail's own declared
mechanical actuator's; "bigger" here means more or bigger minimum footprint, even if the performance-derived requirement
operators, not a fixed physiological constant. That floor also would otherwise ask for less -- but above that, mass is a free
variable exactly like a mechanical actuator's; "bigger" means more
or bigger operators, or a bigger sail, not a fixed constant. That
floor also
sets the smallest platform mass that could structurally carry it sets the smallest platform mass that could structurally carry it
(CARGO_KG_PER_STRUCTURAL_KG again, applied to the platform (CARGO_KG_PER_STRUCTURAL_KG again, applied to the platform
carrying its own actuator+storage instead of cargo) -- below that, carrying its own actuator+storage instead of cargo) -- below that,
@@ -970,16 +986,6 @@ class Pipeline:
return float(dep.value) return float(dep.value)
return None return None
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, 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).
min_accel = dep_value(ctx.platform, "min_effective_accel", "range_min") min_accel = dep_value(ctx.platform, "min_effective_accel", "range_min")
@@ -1026,6 +1032,13 @@ class Pipeline:
# performance solve above would have asked for -- see the # performance solve above would have asked for -- see the
# BIOLOGICAL_OPERATOR_MASS_KG module comment. # BIOLOGICAL_OPERATOR_MASS_KG module comment.
required_actuator = max(required_actuator, BIOLOGICAL_OPERATOR_MASS_KG[ctx.actuator_energy_form]) required_actuator = max(required_actuator, BIOLOGICAL_OPERATOR_MASS_KG[ctx.actuator_energy_form])
elif ctx.actuator_energy_form == "radiation_pressure":
# At least the entity's own declared minimum sail footprint,
# regardless of what the bare performance solve above would
# have asked for -- see the SAIL_AREAL_DENSITY_KG_PER_M2
# module comment.
footprint_floor = dep_value(ctx.actuator, "footprint", "range_min") or 0.0
required_actuator = max(required_actuator, footprint_floor * SAIL_AREAL_DENSITY_KG_PER_M2)
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
@@ -1296,20 +1309,19 @@ class Pipeline:
minimum (platform is also ceiling-clamped to its declared max) -- minimum (platform is also ceiling-clamped to its declared max) --
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 only for combos with no declared platform mass
(radiation-pressure sails -- thrust is area-driven, not a mass ceiling to bound a weight-class slider (e.g. Spaceship). Every
choice, see _decide_masses) or with no declared platform mass actuator type gets sliders, including biological (rider/operator
ceiling to bound a weight-class slider. Biological actuators DO mass, see BIOLOGICAL_OPERATOR_MASS_KG) and radiation-pressure
get sliders: rider/operator mass is a real, budget-competing (effective sail mass derived from footprint, see
variable like any other actuator (see BIOLOGICAL_OPERATOR_MASS_KG). SAIL_AREAL_DENSITY_KG_PER_M2) -- both are real, budget-competing
variables like any mechanical actuator's mass.
""" """
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 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 cargo_capacity_kg = (ctx.p_min + ctx.a_min + ctx.s_min) * CARGO_KG_PER_STRUCTURAL_KG
default_actuator, default_storage, default_platform, _feasible = self._decide_masses( default_actuator, default_storage, default_platform, _feasible = self._decide_masses(

View File

@@ -1,8 +1,6 @@
{% 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 footprint-derived quantity, like a 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

@@ -155,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, or operator count/size for muscle power)</label> <label for="actuator_mass">actuator (motor/collector 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'">