score-optimize actuator/storage/platform allocation, enforce structural feasibility

The saved composite score previously came from a requirement-solve that
only satisfied the platform's physical performance floor, not the
domain's actual weighted score -- a smaller/cheaper build could always
score higher by hand. _decide_masses now jointly searches platform,
actuator, and storage mass (coarse-to-fine grid, no external deps) to
maximize the domain's real weighted composite score, with the
requirement floor as a lower bound rather than the final answer.

Platform mass specifically was previously fixed at a geometric-mean
representative value, which could be too little structure to carry its
own required actuator+storage (reusing CARGO_KG_PER_STRUCTURAL_KG, the
existing structure-carries-N-times-its-mass ratio, applied to a
platform carrying its own powertrain instead of cargo). Growing
platform mass also raises that structural ceiling, so it has to be
searched jointly rather than fixed or bounded independently.

Because power_density/range_fuel/cost_efficiency are all per-kg
ratios, none of them naturally penalize a build whose absolute mass
exceeds its own platform's declared ceiling -- a Piston Engine sized
for a Hyperloop could still score well on a Light Personal Vehicle.
Pass 2 now detects genuine infeasibility (no platform mass within its
own declared ceiling can structurally carry the required floor) and
saves it as a per-domain block instead of a misleadingly good score.

Also adds an explore-panel warning (not a hard block, since exploration
is intentionally loose) when a manually-dragged slider build exceeds
the platform's mass ceiling or structural carrying capacity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 18:59:20 -05:00
parent 76f460499a
commit d871635779
6 changed files with 742 additions and 139 deletions

View File

@@ -10,11 +10,12 @@ from datetime import datetime, timezone
from physcom.db.repository import Repository
from physcom.engine.combinator import generate_combinations
from physcom.engine.constraint_resolver import ConstraintResolver, ConstraintResult
from physcom.engine.scorer import Scorer
from physcom.engine.scorer import Scorer, composite_score, normalize
from physcom.llm.base import LLMProvider, LLMRateLimitError
from physcom.llm.parsing import parse_rating
from physcom.models.combination import Combination, ScoredResult
from physcom.models.domain import Domain, MetricBound
from physcom.models.entity import Entity
# Stub-estimator heuristics (used only when no LLM provider is configured).
# Keyed by the same categorical vocabulary already used in seed data — never
@@ -208,6 +209,25 @@ SPECIFIC_ENERGY_CONSUMPTION_J_PER_KG_M: dict[str, float] = {
# pass does not implement. Space is deliberately left out of the dict above
# so it falls through to the old placeholder formula in the code below
# rather than silently claiming a resistance-based number that isn't real.
#
# KNOWN GAP: this whole table is mass-proportional resistance only (rolling
# resistance, effectively) -- there's no aerodynamic drag term (force ~
# frontal_area * velocity^2, independent of mass). That's a reasonable
# approximation for something car-scale, where rolling resistance genuinely
# dominates at typical speeds and this was validated against real car range.
# It badly overestimates range for light/human-scale vehicles, where drag
# is the dominant resistance term and doesn't scale down with mass the way
# this formula assumes -- confirmed on a real combo (Light Personal Vehicle +
# Electric Motor + Rechargeable Battery, #876): a sane 9kg battery on a
# realistic 31kg vehicle came out to ~1,977km, a 6-9x overestimate against
# real e-bikes on comparable battery energy (~50-80km on ~500Wh). The mass
# allocation itself was fine (correctly floor-clamped, nothing oversized) --
# this is a missing term in the resistance formula, not an allocation bug,
# so a mass-allocation optimizer wouldn't fix it either. Real fix needs a
# genuine drag term (frontal-area-ish figure -- `footprint` exists but is a
# ground-footprint number, not obviously the right proxy for cross-sectional
# area facing the wind -- and a drag coefficient assumption), scoped
# separately from the resistance-constant tuning already done here.
# Structural manufacturing cost, $ per kg of platform mass -- certification
# and materials overhead scale hugely by medium (aerospace-grade vs.
@@ -271,6 +291,27 @@ LIFETIME_DISTANCE_M_BY_MEDIUM: dict[str, float] = {
}
@dataclass
class _PhysicsContext:
"""Entity-level physics inputs for a combo that don't depend on a mass
allocation choice -- see Pipeline._physics_context."""
platform: Entity
actuator: Entity
storage: Entity
p_min: float
p_max: float | None
a_min: float
s_min: float
medium: str
actuator_energy_form: str | None
storage_energy_form: str | None
k_act: float
e_dens: float
k_med: float | None
p_rep: float
@dataclass
class PipelineResult:
"""Summary of a pipeline run."""
@@ -555,7 +596,31 @@ class Pipeline:
result.pass2_estimated += 1
return
raw_metrics = self._stub_estimate(combo, domain.metric_bounds)
raw_metrics, feasible = self._stub_estimate(combo, domain.metric_bounds)
if not feasible:
# No platform mass within its own declared ceiling could
# structurally carry the required actuator+storage floor for
# this domain's performance targets -- power_density/range_fuel/
# cost_efficiency are per-kg ratios and don't naturally penalize
# that, so without this check a physically impossible build
# (an engine too big to fit on its own platform) could still
# score and pass. Domain-specific (the requirement floor depends
# on this domain's velocity/range targets), so this is a
# per-domain block like the domain-constraint check above, not
# a combo-wide p1_fail.
self.repo.save_result(
combo.id, domain.id, composite_score=0.0, pass_reached=1,
domain_block_reason=(
"Required actuator+storage mass exceeds what any platform "
"mass within its own declared ceiling could structurally "
"carry for this domain's performance targets"
),
commit=False,
)
result.pass1_failed += 1
self._update_run_counters(run_id, result, current_pass=2)
return
estimate_dicts = []
for mname, rval in raw_metrics.items():
@@ -743,9 +808,358 @@ class Pipeline:
if run_id is not None:
self.repo.update_pipeline_run(run_id, status="running")
def _physics_context(
self, combo: Combination, bounds_by_name: dict[str, MetricBound]
) -> "_PhysicsContext | None":
"""Derive the entity-level physics inputs that don't depend on a
mass allocation choice -- shared by _stub_estimate (which picks the
allocation via solve or a special case) and _optimize_allocation
(which searches over candidate allocations). Returns None if the
combo doesn't have the platform/actuator/storage shape this whole
formula assumes (shouldn't happen for real combos, but a domain
without all three dimensions requested would hit this)."""
platform = next((e for e in combo.entities if e.dimension == "platform"), None)
actuator = next((e for e in combo.entities if e.dimension == "actuator"), None)
storage = next((e for e in combo.entities if e.dimension == "energy_storage"), None)
if platform is None or actuator is None or storage is None:
return None
def dep_value(entity, key, constraint_type) -> float | None:
for dep in entity.dependencies:
if dep.key == key and dep.constraint_type == constraint_type:
return float(dep.value)
return None
def dep_str(entity, key, constraint_type) -> str | None:
for dep in entity.dependencies:
if dep.key == key and dep.constraint_type == constraint_type:
return dep.value
return None
p_min = dep_value(platform, "mass", "range_min") or 0.0
a_min = dep_value(actuator, "mass", "range_min") or 0.0
s_min = dep_value(storage, "mass", "range_min") or 0.0
p_max = dep_value(platform, "mass", "range_max")
medium = dep_str(platform, "medium", "requires") or "ground"
return _PhysicsContext(
platform=platform, actuator=actuator, storage=storage,
p_min=p_min, p_max=p_max, a_min=a_min, s_min=s_min,
medium=medium,
actuator_energy_form=dep_str(actuator, "energy_form", "requires"),
storage_energy_form=dep_str(storage, "energy_form", "provides"),
k_act=dep_value(actuator, "power_density", "provides") or 0.0,
e_dens=dep_value(storage, "energy_density", "provides") or 0.0,
k_med=SPECIFIC_ENERGY_CONSUMPTION_J_PER_KG_M.get(medium),
p_rep=_representative_mass(p_min, p_max),
)
def _raw_physics_from_masses(
self,
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
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
if "range_fuel" in bounds_by_name:
if ctx.storage_energy_form in AMBIENT_ENERGY_FORMS or ctx.k_med is None:
mb = bounds_by_name.get("range_fuel")
out["range_fuel"] = mb.norm_max if mb else 0.0
elif floor_total > 0:
out["range_fuel"] = min(
(ctx.e_dens * storage_mass) / (ctx.k_med * floor_total), 1e13
)
if "cost_efficiency" in bounds_by_name:
structural_cost = p_mass * STRUCTURAL_COST_PER_KG_BY_MEDIUM.get(
ctx.medium, STRUCTURAL_COST_PER_KG_BY_MEDIUM["ground"]
)
actuator_hw_cost = actuator_mass * HARDWARE_COST_PER_KG_BY_ENERGY_FORM.get(
ctx.actuator_energy_form, 50.0
)
storage_hw_cost = storage_mass * HARDWARE_COST_PER_KG_BY_ENERGY_FORM.get(
ctx.storage_energy_form, 50.0
)
upfront_cost = structural_cost + actuator_hw_cost + storage_hw_cost
lifetime_m = LIFETIME_DISTANCE_M_BY_MEDIUM.get(
ctx.medium, LIFETIME_DISTANCE_M_BY_MEDIUM["ground"]
)
amortized_per_m = upfront_cost / lifetime_m
fuel_price_per_mj = FUEL_PRICE_PER_MJ.get(ctx.storage_energy_form, 0.04)
energy_per_m_mj = (
(ctx.k_med or SPECIFIC_ENERGY_CONSUMPTION_J_PER_KG_M["ground"]) * floor_total
) / 1e6
operating_per_m = energy_per_m_mj * fuel_price_per_mj
cost_per_m = amortized_per_m + operating_per_m
if units_by_name.get("cost_efficiency") == "$/(kg·m)":
out["cost_efficiency"] = cost_per_m / max(cargo_capacity_kg, 1.0)
else:
out["cost_efficiency"] = cost_per_m
return out
def _decide_masses(
self,
ctx: "_PhysicsContext",
bounds_by_name: dict[str, MetricBound],
units_by_name: dict[str, str],
cargo_capacity_kg: float,
) -> tuple[float, float, 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;
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
values, so an explore session opens on the exact build the saved
score reflects.
"""
def dep_value(entity, key, constraint_type) -> float | None:
for dep in entity.dependencies:
if dep.key == key and dep.constraint_type == constraint_type:
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.
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
# Step 1: the required floor (same solve as before -- now a floor
# for the search below, not the final answer).
min_accel = dep_value(ctx.platform, "min_effective_accel", "range_min")
specific_thrust = dep_value(ctx.actuator, "specific_thrust", "provides")
target_velocity = dep_value(ctx.platform, "target_velocity", "provides")
range_bounds = bounds_by_name.get("range_fuel")
target_range = range_bounds.norm_max if range_bounds else None
if min_accel and specific_thrust:
c1, r1 = specific_thrust, min_accel
elif target_velocity and ctx.k_med:
# Resistance alone (k_med) only covers steady-state cruise --
# a real vehicle also needs reserve force for acceleration
# events (merging, passing, hills), not just holding speed.
# F=ma: an acceleration reserve in m/s^2 is dimensionally a
# specific force (N/kg) exactly like k_med (J/(kg*m) = N/kg),
# so it adds directly before converting to specific power
# (P/mass = force/mass * v).
c1, r1 = ctx.k_act, (ctx.k_med + ACCELERATION_RESERVE_M_S2) * target_velocity
else:
c1 = r1 = 0.0 # no performance requirement available -- degenerates below
if target_range and ctx.k_med:
c2, r2 = ctx.e_dens, target_range * ctx.k_med
else:
c2 = r2 = 0.0
if c1 and c2:
required_actuator, required_storage = _solve_two_requirement_masses(
ctx.p_rep, c1, r1, c2, r2, ctx.a_min, ctx.s_min
)
else:
# No performance requirement available at all (e.g. a
# space-medium platform paired with an actuator that declares
# neither specific_thrust nor a usable target velocity) --
# fall back to bare floors, with the same near-zero-mass
# nominal reference used elsewhere so this doesn't silently
# degenerate to 0 power the way the original stub did.
required_actuator = ctx.a_min if ctx.a_min > 0.0 else 10.0
required_storage = ctx.s_min
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
a_floor = max(ctx.a_min, required_actuator)
s_floor = max(ctx.s_min, required_storage)
structural_floor = a_floor + s_floor # min mass the platform must carry
# Platform mass is NOT just the required-floor minimum: because
# actuator+storage are capped at platform_mass * CARGO_KG_PER_STRUCTURAL_KG
# (see insufficient_structure), a bigger platform also buys room for
# a bigger, higher-scoring actuator/storage build -- so platform
# mass has to be searched jointly with them, not fixed. The lower
# bound still can't go below what's needed to carry the required
# floor at all (that's a physical requirement, not a scoring
# choice); p_rep is used only as the starting point for that
# search, not the answer.
p_lo = max(ctx.p_min, ctx.p_rep, structural_floor / CARGO_KG_PER_STRUCTURAL_KG)
p_lo = min(p_lo, ctx.p_max)
if p_lo * CARGO_KG_PER_STRUCTURAL_KG < structural_floor or ctx.p_max - p_lo < structural_floor:
# Even the smallest viable platform can't carry the required
# floor within the mass ceiling -- genuinely infeasible
# allocation, not a search problem. Best-effort fallback masses
# (feasible=False tells the caller not to trust the resulting
# score: 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 --
# something else has to catch that).
return a_floor, s_floor, a_floor, 0.0, 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,
bounds_by_name, units_by_name, cargo_capacity_kg,
platform_mass=platform_mass,
)
scores, weights = [], []
for mb in bounds_by_name.values():
val = raw.get(mb.metric_name)
if val is None:
continue
n = normalize(val, mb.norm_min, mb.norm_max)
if mb.lower_is_better:
n = 1.0 - n
scores.append(n)
weights.append(mb.weight)
return composite_score(scores, weights)
def best_at_platform(p: float, grid: int, rounds: int) -> tuple[float, float, float]:
budget = min(ctx.p_max - p, p * CARGO_KG_PER_STRUCTURAL_KG)
if budget < structural_floor:
return a_floor, s_floor, -1.0
return self._search_best_allocation(
a_floor, s_floor, budget, lambda a, s: objective(p, a, s), grid=grid, rounds=rounds,
)
# Outer coarse-to-fine search over platform mass. A cheap/low-res
# inner (a, s) search keeps every round affordable -- a low-res
# inner score is still a reasonable relative ranking of platform
# values even if each individual score isn't fully converged, and
# coarse-to-fine narrowing self-corrects across rounds. p_floor is
# the hard physical minimum and must never be narrowed past,
# unlike win_lo/win_hi which shrink each round.
p_floor = p_lo
win_lo, win_hi = p_lo, ctx.p_max
best_p, best_score = p_lo, -1.0
grid = 10
for _round in range(5):
for i in range(grid + 1):
p = win_lo + (win_hi - win_lo) * i / grid
if p < p_floor or p > ctx.p_max:
continue
_a, _s, sc = best_at_platform(p, grid=6, rounds=3)
if sc > best_score:
best_score, best_p = sc, p
span = max((win_hi - win_lo) / grid * 2, 1e-6)
win_lo = max(p_floor, best_p - span)
win_hi = min(ctx.p_max, best_p + span)
# Narrow local refinement at higher inner precision, over the same
# window the coarse scan above already settled into -- the coarse
# scan can land near, but not exactly on, the true optimum since
# it ranks platform values using a cheap inner search. A handful
# of medium-precision resamples of that same narrow window closes
# the gap without paying full precision at every one of the wide
# scan's many candidates.
for i in range(7):
p = win_lo + (win_hi - win_lo) * i / 6
if p < p_floor or p > ctx.p_max:
continue
_a, _s, sc = best_at_platform(p, grid=9, rounds=4)
if sc > best_score:
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
@staticmethod
def _search_best_allocation(
a_min: float, s_min: float, budget: float, objective,
grid: int = 12, rounds: int = 6,
) -> tuple[float, float, float]:
"""Coarse-to-fine grid search for the (actuator_mass, storage_mass)
that maximizes `objective` over the feasible triangle a>=a_min,
s>=s_min, a+s<=budget. No external dependency (scipy etc.) -- the
objective is smooth and low-dimensional enough that ~6 rounds of a
13x13 grid, narrowing the window each round, converges well in
well under a millisecond. `grid`/`rounds` are reduced by callers
doing many cheap scans (e.g. the outer platform-mass search in
_decide_masses) and left at their precise defaults for a final
answer."""
a_lo, a_hi = a_min, max(a_min, budget - s_min)
s_lo, s_hi = s_min, max(s_min, budget - a_min)
best_a, best_s, best_score = a_lo, s_lo, -1.0
for _round in range(rounds):
for i in range(grid + 1):
a = a_lo + (a_hi - a_lo) * i / grid
if a < a_min:
continue
s_cap = min(s_hi, budget - a)
if s_cap < s_min:
continue
for j in range(grid + 1):
s = s_lo + (s_cap - s_lo) * j / grid
if s < s_min:
continue
sc = objective(a, s)
if sc > best_score:
best_score, best_a, best_s = sc, a, s
a_span = max((a_hi - a_lo) / grid * 2, 1e-6)
s_span = max((s_hi - s_lo) / grid * 2, 1e-6)
a_lo, a_hi = max(a_min, best_a - a_span), min(budget - s_min, best_a + a_span)
s_lo, s_hi = max(s_min, best_s - s_span), min(budget - a_min, best_s + s_span)
return best_a, best_s, best_score
def _stub_estimate(
self, combo: Combination, metric_bounds: list[MetricBound]
) -> dict[str, float]:
) -> tuple[dict[str, float], bool]:
"""Deterministic estimation from declared entity attributes (no LLM).
power_density, range_fuel, and cost_efficiency are computed from the
@@ -763,6 +1177,15 @@ class Pipeline:
"$/(kg·m)" (freight-style domains) isn't a rescaling of "$/m" — it's
a different quantity that needs dividing by cargo mass, not a
conversion factor.
Returns (raw_metrics, feasible). feasible is False when no platform
mass within its own declared ceiling could structurally carry the
required actuator+storage floor (see _decide_masses) -- raw_metrics
is still populated in that case (best-effort floor allocation) but
callers must not score it normally: none of power_density/
range_fuel/cost_efficiency are extensive quantities, so a build
whose absolute mass tramples its own platform's declared ceiling
can still produce perfectly plausible-looking per-kg ratios.
"""
metric_names = [mb.metric_name for mb in metric_bounds]
units_by_name = {mb.metric_name: mb.unit for mb in metric_bounds}
@@ -798,139 +1221,17 @@ class Pipeline:
# ── platform/actuator/storage-specific extraction, for
# power_density / range_fuel / cost_efficiency only ──────────────
platform = next((e for e in combo.entities if e.dimension == "platform"), None)
actuator = next((e for e in combo.entities if e.dimension == "actuator"), None)
storage = next((e for e in combo.entities if e.dimension == "energy_storage"), None)
def dep_value(entity, key, constraint_type) -> float | None:
if entity is None:
return None
for dep in entity.dependencies:
if dep.key == key and dep.constraint_type == constraint_type:
return float(dep.value)
return None
def dep_str(entity, key, constraint_type) -> str | None:
if entity is None:
return None
for dep in entity.dependencies:
if dep.key == key and dep.constraint_type == constraint_type:
return dep.value
return None
p_min = dep_value(platform, "mass", "range_min") or 0.0
a_min = dep_value(actuator, "mass", "range_min") or 0.0
s_min = dep_value(storage, "mass", "range_min") or 0.0
p_max = dep_value(platform, "mass", "range_max")
medium = dep_str(platform, "medium", "requires") or "ground"
actuator_energy_form = dep_str(actuator, "energy_form", "requires")
storage_energy_form = dep_str(storage, "energy_form", "provides")
k_act = dep_value(actuator, "power_density", "provides") or 0.0
e_dens = dep_value(storage, "energy_density", "provides") or 0.0
k_med = SPECIFIC_ENERGY_CONSUMPTION_J_PER_KG_M.get(medium)
p_rep = _representative_mass(p_min, p_max) # platform's representative build size
# actuator/storage mass: sized to what's actually necessary (see
# module note above _solve_two_requirement_masses), except the
# documented near-zero-owned-mass cases below.
denom_offset = 0.0 # extra propelled mass that never competes for the build budget
if actuator_energy_form in BIOLOGICAL_OPERATOR_MASS_KG:
power_mass = BIOLOGICAL_OPERATOR_MASS_KG[actuator_energy_form]
denom_offset = power_mass
actuator_mass, storage_mass = a_min, s_min
elif 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.
footprint = dep_value(actuator, "footprint", "range_min") or 0.0
actuator_mass = footprint * 0.05 # kg/m^2, thin deployable sail film
power_mass = actuator_mass
storage_mass = s_min
else:
min_accel = dep_value(platform, "min_effective_accel", "range_min")
specific_thrust = dep_value(actuator, "specific_thrust", "provides")
target_velocity = dep_value(platform, "target_velocity", "provides")
range_bounds = bounds_by_name.get("range_fuel")
target_range = range_bounds.norm_max if range_bounds else None
if min_accel and specific_thrust:
c1, r1 = specific_thrust, min_accel
elif target_velocity and k_med:
# Resistance alone (k_med) only covers steady-state cruise --
# a real vehicle also needs reserve force for acceleration
# events (merging, passing, hills), not just holding speed.
# F=ma: an acceleration reserve in m/s^2 is dimensionally a
# specific force (N/kg) exactly like k_med (J/(kg*m) = N/kg),
# so it adds directly before converting to specific power
# (P/mass = force/mass * v).
c1, r1 = k_act, (k_med + ACCELERATION_RESERVE_M_S2) * target_velocity
else:
c1 = r1 = 0.0 # no performance requirement available -- solve degenerates below
if target_range and k_med:
c2, r2 = e_dens, target_range * k_med
else:
c2 = r2 = 0.0
if c1 and c2:
actuator_mass, storage_mass = _solve_two_requirement_masses(
p_rep, c1, r1, c2, r2, a_min, s_min
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(
ctx, bounds_by_name, units_by_name, cargo_capacity_kg
)
else:
# No performance requirement available at all (e.g. a
# space-medium platform paired with an actuator that
# declares neither specific_thrust nor a usable target
# velocity) -- fall back to bare floors, with the same
# near-zero-mass nominal reference used elsewhere so this
# doesn't silently degenerate to 0 power the way the
# original stub did.
actuator_mass = a_min if a_min > 0.0 else 10.0
storage_mass = s_min
power_mass = actuator_mass
floor_total = p_rep + actuator_mass + storage_mass
physics_denom = floor_total + denom_offset
if "power_density" in raw:
raw["power_density"] = (k_act * power_mass) / physics_denom if physics_denom else 0.0
if "range_fuel" in raw:
if storage_energy_form in AMBIENT_ENERGY_FORMS or k_med is None:
# Ambient sources aren't a depletable store (see module note
# above). Space/rocket platforms (k_med undeclared for
# "space") are the same conclusion from different physics:
# in vacuum coast there's no resistance to fight, so a
# working engine covers arbitrary distance given enough
# time -- "range" isn't fuel-quantity-limited the way it is
# for a vehicle fighting drag. The real constraint for a
# rocket is its delta-v budget (maneuvering capability),
# which isn't a distance and isn't what this metric asks --
# reporting the domain's ceiling is the honest answer, not
# the old magic-constant guess (e_dens * 2.78) it replaces.
mb = bounds_by_name.get("range_fuel")
raw["range_fuel"] = mb.norm_max if mb else 0.0
elif floor_total > 0:
raw["range_fuel"] = min((e_dens * storage_mass) / (k_med * floor_total), 1e13)
if "cost_efficiency" in raw:
structural_cost = p_rep * STRUCTURAL_COST_PER_KG_BY_MEDIUM.get(medium, STRUCTURAL_COST_PER_KG_BY_MEDIUM["ground"])
actuator_hw_cost = actuator_mass * HARDWARE_COST_PER_KG_BY_ENERGY_FORM.get(actuator_energy_form, 50.0)
storage_hw_cost = storage_mass * HARDWARE_COST_PER_KG_BY_ENERGY_FORM.get(storage_energy_form, 50.0)
upfront_cost = structural_cost + actuator_hw_cost + storage_hw_cost
lifetime_m = LIFETIME_DISTANCE_M_BY_MEDIUM.get(medium, LIFETIME_DISTANCE_M_BY_MEDIUM["ground"])
amortized_per_m = upfront_cost / lifetime_m
fuel_price_per_mj = FUEL_PRICE_PER_MJ.get(storage_energy_form, 0.04)
energy_per_m_mj = ((k_med or SPECIFIC_ENERGY_CONSUMPTION_J_PER_KG_M["ground"]) * floor_total) / 1e6
operating_per_m = energy_per_m_mj * fuel_price_per_mj
cost_per_m = amortized_per_m + operating_per_m
if units_by_name.get("cost_efficiency") == "$/(kg·m)":
raw["cost_efficiency"] = cost_per_m / max(cargo_capacity_kg, 1.0)
else:
raw["cost_efficiency"] = cost_per_m
raw.update(self._raw_physics_from_masses(
ctx, actuator_mass, storage_mass, power_mass, denom_offset,
bounds_by_name, units_by_name, cargo_capacity_kg,
platform_mass=platform_mass,
))
if "safety" in raw:
candidates = [
@@ -962,4 +1263,105 @@ class Pipeline:
if "reliability" in raw:
raw["reliability"] = ENERGY_FORM_RELIABILITY.get(energy_form, 0.6)
return raw
return raw, feasible
def evaluate_allocation(
self,
combo: Combination,
domain: Domain,
platform_mass: float | None = None,
actuator_mass: float | None = None,
storage_mass: float | None = None,
) -> dict | None:
"""Direct, non-optimizing exploration: compute the resulting raw
metrics, normalized scores, and composite score for an EXPLICIT
(platform, actuator, storage) mass choice -- "what happens to
range and power if I build a bigger motor, or pick a heavier
weight class," not "what's the best possible build." Exists
purely for exploration (a combo detail page slider); nothing here
is ever persisted.
Any mass left as None defaults to what the real requirement-based
solve already picked (see _decide_masses / _stub_estimate), so a
slider opens on today's actual build, not an arbitrary point.
Explicit values are floor-clamped to each component's own declared
minimum (platform is also ceiling-clamped to its declared max) --
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.
"""
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"
):
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(
ctx, bounds_by_name, units_by_name, cargo_capacity_kg
)
p_mass = default_platform if platform_mass is None else platform_mass
a_mass = default_actuator if actuator_mass is None else actuator_mass
s_mass = default_storage if storage_mass is None else storage_mass
p_mass = max(ctx.p_min, min(p_mass, ctx.p_max))
a_mass = max(ctx.a_min, a_mass)
s_mass = max(ctx.s_min, s_mass)
raw = self._raw_physics_from_masses(
ctx, a_mass, s_mass, a_mass, 0.0,
bounds_by_name, units_by_name, cargo_capacity_kg,
platform_mass=p_mass,
)
normalized: dict[str, float] = {}
scores, weights = [], []
for mb in domain.metric_bounds:
val = raw.get(mb.metric_name)
if val is None:
continue
n = normalize(val, mb.norm_min, mb.norm_max)
if mb.lower_is_better:
n = 1.0 - n
normalized[mb.metric_name] = n
scores.append(n)
weights.append(mb.weight)
# Loose slider ceilings for the UI: how big this component could
# get if platform and the other component sat at their own floors
# -- not a hard physics limit, just a sane default range to draw.
actuator_slider_max = max(a_mass, ctx.p_max - ctx.p_min - ctx.s_min)
storage_slider_max = max(s_mass, ctx.p_max - ctx.p_min - ctx.a_min)
total_mass = p_mass + a_mass + s_mass
return {
"platform_mass": p_mass, "platform_min": ctx.p_min, "platform_max": ctx.p_max,
"actuator_mass": a_mass, "actuator_min": ctx.a_min, "actuator_slider_max": actuator_slider_max,
"storage_mass": s_mass, "storage_min": ctx.s_min, "storage_slider_max": storage_slider_max,
"total_mass": total_mass,
# The sliders are intentionally loose (see actuator/storage_slider_max
# above) so exploration isn't boxed in by wherever the platform slider
# currently sits. That means a chosen build can exceed the platform's
# own declared mass ceiling -- physically, more assembled mass than
# this platform category is rated to carry. Flagged, not blocked.
"exceeds_platform_envelope": total_mass > ctx.p_max,
# None of power_density/range_fuel/cost_efficiency penalize a
# platform mass that's too small to structurally carry its own
# actuator+storage -- they only see the total. Reuse the same
# structure-supports-N-times-its-own-mass ratio already used for
# cargo_capacity_kg (CARGO_KG_PER_STRUCTURAL_KG) rather than a
# one-off constant: a platform can't carry more actuator+storage
# mass than that, any more than it could carry that much cargo.
"insufficient_structure": (a_mass + s_mass) > p_mass * CARGO_KG_PER_STRUCTURAL_KG,
"raw_metrics": raw,
"normalized_scores": normalized,
"composite_score": composite_score(scores, weights),
}

View File

@@ -4,11 +4,25 @@ from __future__ import annotations
from flask import Blueprint, flash, redirect, render_template, request, url_for
from physcom.engine.constraint_resolver import ConstraintResolver
from physcom.engine.pipeline import Pipeline
from physcom.engine.scorer import Scorer
from physcom_web.app import get_repo
bp = Blueprint("results", __name__, url_prefix="/results")
def _run_evaluate(repo, domain, combo, platform_mass=None, actuator_mass=None, storage_mass=None):
"""Purely exploratory -- never writes to the DB. Returns None if this
combo has no free mass allocation to explore (see
Pipeline.evaluate_allocation's docstring)."""
pipeline = Pipeline(repo, ConstraintResolver(), Scorer(domain))
return pipeline.evaluate_allocation(
combo, domain,
platform_mass=platform_mass, actuator_mass=actuator_mass, storage_mass=storage_mass,
)
@bp.route("/")
def results_index():
repo = get_repo()
@@ -62,6 +76,7 @@ def result_detail(domain_name: str, combo_id: int):
flash("No results for this combination in this domain.", "error")
return redirect(url_for("results.results_domain", domain_name=domain_name))
scores = repo.get_combination_scores(combo_id, domain.id)
explore_result = _run_evaluate(repo, domain, combo)
return render_template(
"results/detail.html",
@@ -69,6 +84,40 @@ def result_detail(domain_name: str, combo_id: int):
combo=combo,
result=result,
scores=scores,
explore_result=explore_result,
)
@bp.route("/<domain_name>/<int:combo_id>/explore", methods=["POST"])
def explore(domain_name: str, combo_id: int):
"""Live, purely exploratory re-evaluation for an explicit platform/
actuator/storage mass choice -- never touches stored data. Returns an
HTMX partial."""
repo = get_repo()
domain = repo.get_domain(domain_name)
combo = repo.get_combination(combo_id) if domain else None
if not domain or not combo:
return "", 404
def _mass(field: str) -> float | None:
raw = request.form.get(field)
if raw is None:
return None
try:
return float(raw)
except ValueError:
return None
explore_result = _run_evaluate(
repo, domain, combo,
platform_mass=_mass("platform_mass"),
actuator_mass=_mass("actuator_mass"),
storage_mass=_mass("storage_mass"),
)
return render_template(
"results/_explore_result.html",
domain=domain,
explore_result=explore_result,
)

View File

@@ -464,6 +464,52 @@ dd { font-size: 0.9rem; color: var(--text-primary); }
margin-left: 0.3rem;
}
/* ── Mass allocation bar (optimizer) ───────────────────────── */
.mass-bar-container {
display: flex;
width: 100%;
height: 18px;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border-subtle);
margin-top: 0.5rem;
}
.mass-bar-seg { height: 100%; }
.mass-bar-platform { background: var(--accent-blue); }
.mass-bar-actuator { background: var(--accent-gold); }
.mass-bar-storage { background: var(--accent-teal); }
.mass-bar-legend {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.4rem;
align-items: center;
}
.mass-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 0.35rem;
vertical-align: middle;
}
.optimize-summary { margin-bottom: 0.25rem; }
.optimize-score { display: flex; flex-direction: column; gap: 0.1rem; }
/* ── Importance sliders (optimizer) ────────────────────────── */
.weight-slider-row {
display: grid;
grid-template-columns: 140px 1fr 48px;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.weight-slider-row label { font-size: 0.85rem; color: var(--text-muted); }
.weight-slider-row output { font-size: 0.85rem; text-align: right; font-variant-numeric: tabular-nums; }
.weight-slider-row input[type="range"] { width: 100%; }
/* ── Select dropdown dark styling ────────────────────────── */
select option {
background: var(--bg-surface);

View File

@@ -0,0 +1,55 @@
{% if explore_result is none %}
<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
quantity), or the platform has no declared mass ceiling to bound the sliders.</p>
{% else %}
{% set r = explore_result %}
<div class="optimize-summary">
<div class="optimize-score">
<span class="score-cell" style="font-size:1.4rem">{{ "%.4f"|format(r.composite_score) }}</span>
<span class="subtitle">composite score at this build</span>
</div>
</div>
{% if r.exceeds_platform_envelope %}
<p class="badge badge-p1_fail" style="display:inline-block;margin-bottom:0.75rem">
⚠ total mass {{ "%.1f"|format(r.total_mass) }}kg exceeds this platform's declared ceiling
({{ "%.1f"|format(r.platform_max) }}kg) — not a build this platform category could carry
</p>
{% endif %}
{% if r.insufficient_structure %}
<p class="badge badge-p1_fail" style="display:inline-block;margin-bottom:0.75rem">
⚠ platform mass {{ "%.1f"|format(r.platform_mass) }}kg is too little structure to carry
{{ "%.1f"|format(r.actuator_mass + r.storage_mass) }}kg of actuator+storage
</p>
{% endif %}
<div class="mass-bar-container" title="platform {{ '%.1f'|format(r.platform_mass) }}kg / actuator {{ '%.1f'|format(r.actuator_mass) }}kg / storage {{ '%.1f'|format(r.storage_mass) }}kg">
{% set total = r.total_mass %}
<div class="mass-bar-seg mass-bar-platform" style="width: {{ (r.platform_mass / total * 100)|round(1) }}%"></div>
<div class="mass-bar-seg mass-bar-actuator" style="width: {{ (r.actuator_mass / total * 100)|round(1) }}%"></div>
<div class="mass-bar-seg mass-bar-storage" style="width: {{ (r.storage_mass / total * 100)|round(1) }}%"></div>
</div>
<div class="mass-bar-legend">
<span><span class="mass-swatch mass-bar-platform"></span>platform {{ "%.1f"|format(r.platform_mass) }}kg</span>
<span><span class="mass-swatch mass-bar-actuator"></span>actuator {{ "%.1f"|format(r.actuator_mass) }}kg</span>
<span><span class="mass-swatch mass-bar-storage"></span>storage {{ "%.1f"|format(r.storage_mass) }}kg</span>
<span class="subtitle">{{ "%.1f"|format(r.total_mass) }}kg total</span>
</div>
<table class="compact" style="margin-top:0.75rem">
<thead><tr><th>Metric</th><th>Raw Value</th><th>Normalized</th><th>Weight</th></tr></thead>
<tbody>
{% for mb in domain.metric_bounds %}
{% set val = r.raw_metrics.get(mb.metric_name) %}
{% set n = r.normalized_scores.get(mb.metric_name) %}
<tr>
<td>{{ mb.metric_name }}</td>
<td class="score-cell">{{ val|qty(mb.unit) if val is not none else '—' }}</td>
<td class="score-cell">{{ "%.4f"|format(n) if n is not none else '—' }}</td>
<td>{{ "%.0f%%"|format(mb.weight * 100) }}{{ ' ↓' if mb.lower_is_better else '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

View File

@@ -129,6 +129,51 @@
</div>
{% endif %}
{% if explore_result is not none %}
<h2>Explore: Scale the Build</h2>
<p class="subtitle">
Purely exploratory — nothing here is saved. Drag a slider to pick a
platform weight class, motor size, or battery size directly, and see how
power density, range, and the resulting score respond. Sliders open on
the saved build above, which is already the score-optimized allocation
for this domain (subject to the platform's physical performance floor),
so the starting point is the best build already found, not an arbitrary
or merely functional one.
</p>
<div class="card">
{% set r = explore_result %}
<form id="explore-form"
hx-post="{{ url_for('results.explore', domain_name=domain.name, combo_id=combo.id) }}"
hx-trigger="input changed delay:200ms"
hx-target="#explore-result" hx-swap="innerHTML">
<div class="weight-slider-row">
<label for="platform_mass">platform (weight class)</label>
<input type="range" min="{{ r.platform_min }}" max="{{ r.platform_max }}" step="0.1"
id="platform_mass" name="platform_mass" value="{{ r.platform_mass }}"
oninput="document.getElementById('out_platform_mass').textContent = (+this.value).toFixed(1) + 'kg'">
<output id="out_platform_mass">{{ "%.1f"|format(r.platform_mass) }}kg</output>
</div>
<div class="weight-slider-row">
<label for="actuator_mass">actuator (motor size)</label>
<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 }}"
oninput="document.getElementById('out_actuator_mass').textContent = (+this.value).toFixed(1) + 'kg'">
<output id="out_actuator_mass">{{ "%.1f"|format(r.actuator_mass) }}kg</output>
</div>
<div class="weight-slider-row">
<label for="storage_mass">storage (battery/tank size)</label>
<input type="range" min="{{ r.storage_min }}" max="{{ r.storage_slider_max }}" step="0.1"
id="storage_mass" name="storage_mass" value="{{ r.storage_mass }}"
oninput="document.getElementById('out_storage_mass').textContent = (+this.value).toFixed(1) + 'kg'">
<output id="out_storage_mass">{{ "%.1f"|format(r.storage_mass) }}kg</output>
</div>
</form>
<div id="explore-result">
{% include "results/_explore_result.html" %}
</div>
</div>
{% endif %}
<h2>Human Review</h2>
<div id="review-section">
{% include "results/_review_form.html" %}

View File

@@ -69,6 +69,12 @@ def test_blocked_combos_not_scored(seeded_repo):
score_threshold=0.0, passes=[1, 2, 3, 5],
)
# Estimated count should be less than total (blocked ones filtered)
# Estimated count should be less than total (blocked ones filtered).
# Not necessarily equal to pass1_valid + pass1_conditional: a combo can
# pass pass 1's entity-declared-floor checks but still turn out
# structurally infeasible once pass 2 solves the domain-specific
# actuator/storage requirement (e.g. an engine too big to fit its own
# platform's declared mass ceiling) -- that's a legitimate per-domain
# block, not a bug (see Pipeline._decide_masses' `feasible` return).
assert result.pass2_estimated < result.total_generated
assert result.pass2_estimated == result.pass1_valid + result.pass1_conditional
assert result.pass2_estimated <= result.pass1_valid + result.pass1_conditional