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
@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)
class SwitchSpec:
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
)
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 = {
"stage_index": 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_material": stage.wire.material,
"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_kind": stage.switch.kind,
"switch_max_current_a": stage.switch.max_current_a,
"sensor": stage.sensor.part_number,
"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": {
"turns_per_layer": stage.turns_per_layer,
"layers": stage.layers,
@@ -155,6 +169,13 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
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
)
# диагностика по току: хватает ли батареи и ключа на пиковый ток разряда.
# Это предупреждение, а не жёсткий отказ: в базе — паспортный (не импульсный)
# максимум тока, а одиночный выстрел кратковременный, поэтому честнее
# флажок, чем ложная отбраковка. Параллельные конденсаторы уже дают
# реальный выигрыш через сниженный 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"] = {
"reached": True,
"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_fire_s": r.t_fire_s,
"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,
"saturation_warning": r.saturation_warning,
"energy_in_j": r.energy_in_j,

View File

@@ -12,6 +12,7 @@ import random
from dataclasses import dataclass, field
from gausse.components.database import ComponentDatabase
from gausse.components.schema import CapacitorBank
from gausse.sim.coilgun import CoilgunConfig
from gausse.sim.stage import ProjectileConfig, StageConfig
@@ -40,6 +41,11 @@ class SearchBounds:
charge_voltage_fraction_max: float = 1.0
initial_launch_velocity_mps: float = 3.0
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
@@ -52,6 +58,8 @@ class StageGene:
layers: int
sensor_to_coil_distance_m: float
charge_voltage_fraction: float
cap_series: int = 1
cap_parallel: int = 1
@dataclass
@@ -87,6 +95,8 @@ def sample_stage_gene(db: ComponentDatabase, bounds: SearchBounds, rng: random.R
charge_voltage_fraction=rng.uniform(
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_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 = [
_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]
@@ -161,9 +173,12 @@ def decode(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> tuple
stage_configs = []
for gene in genome.stages:
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)]
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)
charge_voltage_v = gene.charge_voltage_fraction * max_voltage
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)
if rng.random() < rate:
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)):
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})",
)
)
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(
BomLine(
stage_index=i,
part=f"Конденсатор {stage.capacitor.part_number}",
quantity="1 шт",
unit_price_rub=stage.capacitor.price,
total_price_rub=stage.capacitor.price,
note=f"{stage.capacitor.capacitance_uf}мкФ {stage.capacitor.voltage_v}В, заряд {stage.charge_voltage_v:.0f}В",
part=f"Конденсатор {cap_base}",
quantity=f"{count} шт ({n_series}посл × {n_parallel}пар)",
unit_price_rub=unit_price,
total_price_rub=cap.price,
note=f"батарея {cap.capacitance_uf:.0f}мкФ {cap.voltage_v:.0f}В, заряд {stage.charge_voltage_v:.0f}В, макс.ток {cap.max_current_a:.0f}А",
)
)
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