|
|
|
|
@@ -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),
|
|
|
|
|
}
|
|
|
|
|
|