diff --git a/src/gausse/components/schema.py b/src/gausse/components/schema.py index 51b663e..615eae8 100644 --- a/src/gausse/components/schema.py +++ b/src/gausse/components/schema.py @@ -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 diff --git a/src/gausse/optim/objective.py b/src/gausse/optim/objective.py index 0fca746..1bbc69f 100644 --- a/src/gausse/optim/objective.py +++ b/src/gausse/optim/objective.py @@ -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, diff --git a/src/gausse/optim/search_space.py b/src/gausse/optim/search_space.py index 2079397..9e99f9e 100644 --- a/src/gausse/optim/search_space.py +++ b/src/gausse/optim/search_space.py @@ -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: diff --git a/src/gausse/report/bom.py b/src/gausse/report/bom.py index 5cd3384..ffeec4b 100644 --- a/src/gausse/report/bom.py +++ b/src/gausse/report/bom.py @@ -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( diff --git a/tests/test_capacitor_bank.py b/tests/test_capacitor_bank.py new file mode 100644 index 0000000..5efe7e8 --- /dev/null +++ b/tests/test_capacitor_bank.py @@ -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