Model capacitor banks (series for voltage, parallel for current/capacitance)

Per user: each stage's energy source is now a bank of real capacitors,
n_series x n_parallel, not a single cap.

- components/schema.py CapacitorBank.from_spec: series multiplies voltage
  and ESR and divides capacitance; parallel multiplies capacitance and
  max current and divides ESR; price and count scale with total cap count.
  Duck-typed as a drop-in for CapacitorSpec in StageConfig.
- search_space: cap_series (1-6) and cap_parallel (1-8) per stage gene,
  sampled/mutated/repaired; decode builds the bank; charge voltage capped
  by min(bank voltage, switch rating)
- objective.build_detail records the full bank composition (base part,
  series/parallel, count, totals) and current-capability diagnostics:
  bank_current_over_limit / switch_current_over_limit vs peak discharge
  current (a warning, not a hard reject -- DB holds datasheet not pulse
  ratings; parallel caps already help for real via lower ESR)
- BOM shows "N шт (Sпосл × Pпар)" with per-unit and total price

Already supported and confirmed present (also user-requested): per-coil
independent wire gauge (wire_idx per stage) and computed wire length
(winding_geometry.total_wire_length_m, drives resistance/cost/DB detail).

83 tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-07-07 00:53:33 +05:00
parent 70b732d170
commit 950be5fcf2
5 changed files with 177 additions and 9 deletions

View File

@@ -27,6 +27,46 @@ class CapacitorSpec:
source: str source: str
@dataclass(frozen=True)
class CapacitorBank:
"""Батарея из одинаковых конденсаторов: n_series (напряжение) × n_parallel (ток/ёмкость).
Последовательное соединение складывает напряжение и ESR, делит ёмкость;
параллельное — складывает ёмкость, максимальный ток и делит ESR. Итоговая
батарея выставляет те же поля, что и одиночный конденсатор
(`capacitance_uf`, `voltage_v`, `esr_ohm`, `max_current_a`, `price`),
поэтому используется как drop-in замена `CapacitorSpec` в StageConfig.
"""
base_part_number: str
n_series: int
n_parallel: int
capacitance_uf: float
voltage_v: float
esr_ohm: float
max_current_a: float
price: float
@property
def count(self) -> int:
return self.n_series * self.n_parallel
@classmethod
def from_spec(cls, spec: "CapacitorSpec", n_series: int, n_parallel: int) -> "CapacitorBank":
n_series = max(1, int(n_series))
n_parallel = max(1, int(n_parallel))
return cls(
base_part_number=spec.part_number,
n_series=n_series,
n_parallel=n_parallel,
capacitance_uf=spec.capacitance_uf * n_parallel / n_series,
voltage_v=spec.voltage_v * n_series,
esr_ohm=spec.esr_ohm * n_series / n_parallel,
max_current_a=spec.max_current_a * n_parallel,
price=spec.price * n_series * n_parallel,
)
@dataclass(frozen=True) @dataclass(frozen=True)
class SwitchSpec: class SwitchSpec:
part_number: str part_number: str

View File

@@ -116,6 +116,12 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
geometry.mean_radius_m, geometry.coil_length_m, geometry.radial_depth_m, geometry.total_turns geometry.mean_radius_m, geometry.coil_length_m, geometry.radial_depth_m, geometry.total_turns
) )
cap = stage.capacitor
# cap может быть батареей (CapacitorBank) или одиночным конденсатором (CapacitorSpec)
cap_base = getattr(cap, "base_part_number", getattr(cap, "part_number", "?"))
n_series = getattr(cap, "n_series", 1)
n_parallel = getattr(cap, "n_parallel", 1)
entry = { entry = {
"stage_index": i, "stage_index": i,
"coil_center_position_m": coil_centers_m[i], "coil_center_position_m": coil_centers_m[i],
@@ -125,14 +131,22 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
"wire": stage.wire.part_id, "wire": stage.wire.part_id,
"wire_material": stage.wire.material, "wire_material": stage.wire.material,
"wire_gauge_mm": stage.wire.gauge_mm, "wire_gauge_mm": stage.wire.gauge_mm,
"capacitor": stage.capacitor.part_number,
"capacitance_uf": stage.capacitor.capacitance_uf,
"capacitor_voltage_rating_v": stage.capacitor.voltage_v,
"switch": stage.switch.part_number, "switch": stage.switch.part_number,
"switch_kind": stage.switch.kind, "switch_kind": stage.switch.kind,
"switch_max_current_a": stage.switch.max_current_a,
"sensor": stage.sensor.part_number, "sensor": stage.sensor.part_number,
"sensor_kind": stage.sensor.kind, "sensor_kind": stage.sensor.kind,
}, },
"capacitor_bank": {
"base_part": cap_base,
"n_series": n_series,
"n_parallel": n_parallel,
"count": n_series * n_parallel,
"total_capacitance_uf": cap.capacitance_uf,
"total_voltage_rating_v": cap.voltage_v,
"total_esr_ohm": cap.esr_ohm,
"max_current_a": cap.max_current_a,
},
"winding": { "winding": {
"turns_per_layer": stage.turns_per_layer, "turns_per_layer": stage.turns_per_layer,
"layers": stage.layers, "layers": stage.layers,
@@ -155,6 +169,13 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
peak_current_a = ( peak_current_a = (
float(np.max(np.abs(r.discharge_i))) if r.discharge_i is not None and len(r.discharge_i) else None float(np.max(np.abs(r.discharge_i))) if r.discharge_i is not None and len(r.discharge_i) else None
) )
# диагностика по току: хватает ли батареи и ключа на пиковый ток разряда.
# Это предупреждение, а не жёсткий отказ: в базе — паспортный (не импульсный)
# максимум тока, а одиночный выстрел кратковременный, поэтому честнее
# флажок, чем ложная отбраковка. Параллельные конденсаторы уже дают
# реальный выигрыш через сниженный ESR (меньше потери), см. capacitor_bank.
bank_over = peak_current_a is not None and peak_current_a > cap.max_current_a
switch_over = peak_current_a is not None and peak_current_a > stage.switch.max_current_a
entry["outcome"] = { entry["outcome"] = {
"reached": True, "reached": True,
"feasible": r.feasible, "feasible": r.feasible,
@@ -164,6 +185,8 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
"t_sensor_s": r.t_sensor_s, "t_sensor_s": r.t_sensor_s,
"t_fire_s": r.t_fire_s, "t_fire_s": r.t_fire_s,
"peak_current_a": peak_current_a, "peak_current_a": peak_current_a,
"bank_current_over_limit": bank_over,
"switch_current_over_limit": switch_over,
"peak_field_estimate_tesla": r.peak_field_estimate_tesla, "peak_field_estimate_tesla": r.peak_field_estimate_tesla,
"saturation_warning": r.saturation_warning, "saturation_warning": r.saturation_warning,
"energy_in_j": r.energy_in_j, "energy_in_j": r.energy_in_j,

View File

@@ -12,6 +12,7 @@ import random
from dataclasses import dataclass, field from dataclasses import dataclass, field
from gausse.components.database import ComponentDatabase from gausse.components.database import ComponentDatabase
from gausse.components.schema import CapacitorBank
from gausse.sim.coilgun import CoilgunConfig from gausse.sim.coilgun import CoilgunConfig
from gausse.sim.stage import ProjectileConfig, StageConfig from gausse.sim.stage import ProjectileConfig, StageConfig
@@ -40,6 +41,11 @@ class SearchBounds:
charge_voltage_fraction_max: float = 1.0 charge_voltage_fraction_max: float = 1.0
initial_launch_velocity_mps: float = 3.0 initial_launch_velocity_mps: float = 3.0
initial_approach_margin_m: float = 0.02 initial_approach_margin_m: float = 0.02
# батарея конденсаторов на ступень: последовательно (напряжение) × параллельно (ток/ёмкость)
cap_series_min: int = 1
cap_series_max: int = 6
cap_parallel_min: int = 1
cap_parallel_max: int = 8
@dataclass @dataclass
@@ -52,6 +58,8 @@ class StageGene:
layers: int layers: int
sensor_to_coil_distance_m: float sensor_to_coil_distance_m: float
charge_voltage_fraction: float charge_voltage_fraction: float
cap_series: int = 1
cap_parallel: int = 1
@dataclass @dataclass
@@ -87,6 +95,8 @@ def sample_stage_gene(db: ComponentDatabase, bounds: SearchBounds, rng: random.R
charge_voltage_fraction=rng.uniform( charge_voltage_fraction=rng.uniform(
bounds.charge_voltage_fraction_min, bounds.charge_voltage_fraction_max bounds.charge_voltage_fraction_min, bounds.charge_voltage_fraction_max
), ),
cap_series=rng.randint(bounds.cap_series_min, bounds.cap_series_max),
cap_parallel=rng.randint(bounds.cap_parallel_min, bounds.cap_parallel_max),
) )
@@ -138,6 +148,8 @@ def repair(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Genom
bounds.charge_voltage_fraction_min, bounds.charge_voltage_fraction_min,
bounds.charge_voltage_fraction_max, bounds.charge_voltage_fraction_max,
) )
stage.cap_series = int(_clip(stage.cap_series, bounds.cap_series_min, bounds.cap_series_max))
stage.cap_parallel = int(_clip(stage.cap_parallel, bounds.cap_parallel_min, bounds.cap_parallel_max))
genome.inter_stage_gaps_m = [ genome.inter_stage_gaps_m = [
_clip(g, bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max) _clip(g, bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max)
for g in genome.inter_stage_gaps_m[: len(genome.stages) - 1] for g in genome.inter_stage_gaps_m[: len(genome.stages) - 1]
@@ -161,9 +173,12 @@ def decode(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> tuple
stage_configs = [] stage_configs = []
for gene in genome.stages: for gene in genome.stages:
wire = db.wires[gene.wire_idx % len(db.wires)] wire = db.wires[gene.wire_idx % len(db.wires)]
capacitor = db.capacitors[gene.capacitor_idx % len(db.capacitors)] base_capacitor = db.capacitors[gene.capacitor_idx % len(db.capacitors)]
capacitor = CapacitorBank.from_spec(base_capacitor, gene.cap_series, gene.cap_parallel)
switch = db.switches[gene.switch_idx % len(db.switches)] switch = db.switches[gene.switch_idx % len(db.switches)]
sensor = db.sensors[gene.sensor_idx % len(db.sensors)] sensor = db.sensors[gene.sensor_idx % len(db.sensors)]
# напряжение батареи (base.V × n_series) может быть выше одиночного,
# но ключ обязан его выдержать — реальный ограничитель switch.max_voltage_v
max_voltage = min(capacitor.voltage_v, switch.max_voltage_v) max_voltage = min(capacitor.voltage_v, switch.max_voltage_v)
charge_voltage_v = gene.charge_voltage_fraction * max_voltage charge_voltage_v = gene.charge_voltage_fraction * max_voltage
stage_configs.append( stage_configs.append(
@@ -213,6 +228,10 @@ def mutate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds, rng: ran
stage.sensor_to_coil_distance_m *= rng.uniform(0.7, 1.3) stage.sensor_to_coil_distance_m *= rng.uniform(0.7, 1.3)
if rng.random() < rate: if rng.random() < rate:
stage.charge_voltage_fraction *= rng.uniform(0.9, 1.1) stage.charge_voltage_fraction *= rng.uniform(0.9, 1.1)
if rng.random() < rate:
stage.cap_series += rng.randint(-1, 1)
if rng.random() < rate:
stage.cap_parallel += rng.randint(-1, 1)
for i in range(len(child.inter_stage_gaps_m)): for i in range(len(child.inter_stage_gaps_m)):
if rng.random() < rate: if rng.random() < rate:

View File

@@ -33,14 +33,20 @@ def build_bom(config: CoilgunConfig, db: ComponentDatabase) -> list[BomLine]:
note=f"{geometry.total_turns} витков ({stage.turns_per_layer}x{stage.layers})", note=f"{geometry.total_turns} витков ({stage.turns_per_layer}x{stage.layers})",
) )
) )
cap = stage.capacitor
cap_base = getattr(cap, "base_part_number", getattr(cap, "part_number", "?"))
n_series = getattr(cap, "n_series", 1)
n_parallel = getattr(cap, "n_parallel", 1)
count = n_series * n_parallel
unit_price = cap.price / count if count else cap.price
lines.append( lines.append(
BomLine( BomLine(
stage_index=i, stage_index=i,
part=f"Конденсатор {stage.capacitor.part_number}", part=f"Конденсатор {cap_base}",
quantity="1 шт", quantity=f"{count} шт ({n_series}посл × {n_parallel}пар)",
unit_price_rub=stage.capacitor.price, unit_price_rub=unit_price,
total_price_rub=stage.capacitor.price, total_price_rub=cap.price,
note=f"{stage.capacitor.capacitance_uf}мкФ {stage.capacitor.voltage_v}В, заряд {stage.charge_voltage_v:.0f}В", note=f"батарея {cap.capacitance_uf:.0f}мкФ {cap.voltage_v:.0f}В, заряд {stage.charge_voltage_v:.0f}В, макс.ток {cap.max_current_a:.0f}А",
) )
) )
lines.append( lines.append(

View File

@@ -0,0 +1,80 @@
import random
import pytest
from gausse.components.database import ComponentDatabase
from gausse.components.schema import CapacitorBank, CapacitorSpec
from gausse.optim.objective import build_detail
from gausse.optim.search_space import SearchBounds, decode, sample_genome
from gausse.sim.coilgun import run_coilgun
BASE = CapacitorSpec(
part_number="c-base", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.2,
max_current_a=20.0, price=150.0, source="test",
)
def test_series_multiplies_voltage_and_divides_capacitance():
bank = CapacitorBank.from_spec(BASE, n_series=3, n_parallel=1)
assert bank.voltage_v == pytest.approx(1200.0) # 400 * 3
assert bank.capacitance_uf == pytest.approx(100.0 / 3) # C / 3
assert bank.esr_ohm == pytest.approx(0.6) # ESR * 3
assert bank.count == 3
assert bank.price == pytest.approx(450.0) # 150 * 3
def test_parallel_multiplies_capacitance_current_and_divides_esr():
bank = CapacitorBank.from_spec(BASE, n_series=1, n_parallel=4)
assert bank.capacitance_uf == pytest.approx(400.0) # C * 4
assert bank.voltage_v == pytest.approx(400.0) # без изменения
assert bank.esr_ohm == pytest.approx(0.05) # ESR / 4
assert bank.max_current_a == pytest.approx(80.0) # 20 * 4 -- вот "на силу тока"
assert bank.count == 4
def test_series_parallel_combination():
bank = CapacitorBank.from_spec(BASE, n_series=2, n_parallel=3)
assert bank.voltage_v == pytest.approx(800.0)
assert bank.capacitance_uf == pytest.approx(100.0 * 3 / 2)
assert bank.esr_ohm == pytest.approx(0.2 * 2 / 3)
assert bank.count == 6
assert bank.price == pytest.approx(150.0 * 6)
def test_energy_scales_with_bank():
single = CapacitorBank.from_spec(BASE, 1, 1)
bank = CapacitorBank.from_spec(BASE, 2, 2) # V×2, C×1 (2 series, 2 parallel => C*2/2=C)
e_single = 0.5 * (single.capacitance_uf * 1e-6) * single.voltage_v**2
e_bank = 0.5 * (bank.capacitance_uf * 1e-6) * bank.voltage_v**2
# тот же C, но вдвое большее напряжение -> вчетверо больше энергии
assert e_bank == pytest.approx(4 * e_single)
def test_decoded_record_contains_capacitor_bank_composition():
db = ComponentDatabase.load()
bounds = SearchBounds()
rng = random.Random(1)
genome = sample_genome(db, bounds, rng)
config, ix, iv = decode(genome, db, bounds)
result = run_coilgun(config)
detail = build_detail(config, result, db, ix, iv)
bank = detail["stages"][0]["capacitor_bank"]
assert set(bank) >= {"base_part", "n_series", "n_parallel", "count", "total_voltage_rating_v", "max_current_a"}
assert bank["count"] == bank["n_series"] * bank["n_parallel"]
# база должна быть реальной деталью
assert bank["base_part"] in {c.part_number for c in db.capacitors}
def test_genome_carries_series_parallel_and_they_vary():
db = ComponentDatabase.load()
bounds = SearchBounds()
rng = random.Random(5)
series_vals, parallel_vals = set(), set()
for _ in range(40):
g = sample_genome(db, bounds, rng)
for st in g.stages:
assert bounds.cap_series_min <= st.cap_series <= bounds.cap_series_max
assert bounds.cap_parallel_min <= st.cap_parallel <= bounds.cap_parallel_max
series_vals.add(st.cap_series)
parallel_vals.add(st.cap_parallel)
assert len(series_vals) > 1 and len(parallel_vals) > 1