diff --git a/src/physcom/engine/pipeline.py b/src/physcom/engine/pipeline.py index d6aefe4..c702771 100644 --- a/src/physcom/engine/pipeline.py +++ b/src/physcom/engine/pipeline.py @@ -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 - ) - 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 + 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 + ) + 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), + } diff --git a/src/physcom_web/routes/results.py b/src/physcom_web/routes/results.py index c765d64..afd43a9 100644 --- a/src/physcom_web/routes/results.py +++ b/src/physcom_web/routes/results.py @@ -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("///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, ) diff --git a/src/physcom_web/static/style.css b/src/physcom_web/static/style.css index a92b5bd..99d5a0b 100644 --- a/src/physcom_web/static/style.css +++ b/src/physcom_web/static/style.css @@ -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); diff --git a/src/physcom_web/templates/results/_explore_result.html b/src/physcom_web/templates/results/_explore_result.html new file mode 100644 index 0000000..177e329 --- /dev/null +++ b/src/physcom_web/templates/results/_explore_result.html @@ -0,0 +1,55 @@ +{% if explore_result is none %} +

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

+{% else %} +{% set r = explore_result %} +
+
+ {{ "%.4f"|format(r.composite_score) }} + composite score at this build +
+
+ +{% if r.exceeds_platform_envelope %} +

+ ⚠ 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 +

+{% endif %} +{% if r.insufficient_structure %} +

+ ⚠ 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 +

+{% endif %} + +
+ {% set total = r.total_mass %} +
+
+
+
+
+ platform {{ "%.1f"|format(r.platform_mass) }}kg + actuator {{ "%.1f"|format(r.actuator_mass) }}kg + storage {{ "%.1f"|format(r.storage_mass) }}kg + {{ "%.1f"|format(r.total_mass) }}kg total +
+ + + + + {% for mb in domain.metric_bounds %} + {% set val = r.raw_metrics.get(mb.metric_name) %} + {% set n = r.normalized_scores.get(mb.metric_name) %} + + + + + + + {% endfor %} + +
MetricRaw ValueNormalizedWeight
{{ mb.metric_name }}{{ val|qty(mb.unit) if val is not none else '—' }}{{ "%.4f"|format(n) if n is not none else '—' }}{{ "%.0f%%"|format(mb.weight * 100) }}{{ ' ↓' if mb.lower_is_better else '' }}
+{% endif %} diff --git a/src/physcom_web/templates/results/detail.html b/src/physcom_web/templates/results/detail.html index a79c2db..f7525e7 100644 --- a/src/physcom_web/templates/results/detail.html +++ b/src/physcom_web/templates/results/detail.html @@ -129,6 +129,51 @@ {% endif %} +{% if explore_result is not none %} +

Explore: Scale the Build

+

+ 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. +

+
+ {% set r = explore_result %} +
+
+ + + {{ "%.1f"|format(r.platform_mass) }}kg +
+
+ + + {{ "%.1f"|format(r.actuator_mass) }}kg +
+
+ + + {{ "%.1f"|format(r.storage_mass) }}kg +
+
+
+ {% include "results/_explore_result.html" %} +
+
+{% endif %} +

Human Review

{% include "results/_review_form.html" %} diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 9a3dfb2..209f830 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -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