Three fixes from user feedback: 1. Single-pulse discharge (user spotted 3 current humps for one stage): a thyristor fires ONCE per shot -- you can't recharge the cap in microseconds. The discharge now terminates at the first current zero OR first local minimum (where the slug starts pumping current back), whichever comes first. Residual coil energy at cutoff is accounted as freewheel-diode dissipation using the exact saturating magnetic energy integral, so energy still balances. Verified: 3 humps -> 1. 2. Real tube geometry: the genome now carries tube INNER diameter (bore, the projectile flies through) and wall thickness; outer diameter = inner + 2*wall = the coil's inner diameter (which drives the field). The projectile must fit the bore (diameter < inner - clearance). 3. Projectiles up to 200 g: diameter to 28mm, length to 150mm, with mass capped at 200g (length clamped by density). Detail/BOM now report inner/outer/wall tube diameters, projectile mass in grams, and whether it fits the bore. Same single-pulse + eddy physics mirrored into the GPU batch integrator. Evolutionary polish updated for the new tube params. All test groups pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
114 lines
4.6 KiB
Python
114 lines
4.6 KiB
Python
import random
|
||
|
||
import pytest
|
||
|
||
from gausse.components.database import ComponentDatabase
|
||
from gausse.optim.search_space import (
|
||
PROJECTILE_BORE_CLEARANCE_M,
|
||
SearchBounds,
|
||
crossover,
|
||
decode,
|
||
genome_from_dict,
|
||
genome_to_dict,
|
||
mutate,
|
||
repair,
|
||
sample_genome,
|
||
)
|
||
|
||
DB = ComponentDatabase.load()
|
||
BOUNDS = SearchBounds()
|
||
|
||
|
||
def test_sample_genome_respects_bounds():
|
||
rng = random.Random(42)
|
||
for _ in range(50):
|
||
genome = sample_genome(DB, BOUNDS, rng)
|
||
assert BOUNDS.min_stages <= len(genome.stages) <= BOUNDS.max_stages
|
||
assert len(genome.inter_stage_gaps_m) == len(genome.stages) - 1
|
||
assert BOUNDS.tube_inner_d_m_min <= genome.tube_inner_d_m <= BOUNDS.tube_inner_d_m_max
|
||
assert genome.tube_od_m == pytest.approx(genome.tube_inner_d_m + 2 * genome.tube_wall_m)
|
||
# снаряд влезает в бор трубы
|
||
assert genome.projectile.diameter_m <= genome.tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M + 1e-9
|
||
# масса снаряда не больше 200 г
|
||
import math as _m
|
||
mat = DB.projectile_materials[genome.projectile.material_idx]
|
||
mass = _m.pi * (genome.projectile.diameter_m / 2) ** 2 * genome.projectile.length_m * mat.density_kg_m3
|
||
assert mass <= BOUNDS.projectile_mass_max_kg + 1e-6
|
||
for stage in genome.stages:
|
||
assert 0 <= stage.wire_idx < len(DB.wires)
|
||
assert BOUNDS.turns_per_layer_min <= stage.turns_per_layer <= BOUNDS.turns_per_layer_max
|
||
assert BOUNDS.layers_min <= stage.layers <= BOUNDS.layers_max
|
||
|
||
|
||
def test_decode_produces_valid_config():
|
||
rng = random.Random(1)
|
||
genome = sample_genome(DB, BOUNDS, rng)
|
||
config, initial_x_m, initial_v_mps = decode(genome, DB, BOUNDS)
|
||
assert len(config.stages) == len(genome.stages)
|
||
assert len(config.inter_stage_gaps_m) == len(genome.stages) - 1
|
||
assert initial_x_m < -config.stages[0].sensor_to_coil_distance_m
|
||
assert initial_v_mps == BOUNDS.initial_launch_velocity_mps
|
||
for stage_config, gene in zip(config.stages, genome.stages):
|
||
max_voltage = min(stage_config.capacitor.voltage_v, stage_config.switch.max_voltage_v)
|
||
assert stage_config.charge_voltage_v <= max_voltage + 1e-9
|
||
|
||
|
||
def test_mutate_keeps_genome_within_bounds():
|
||
rng = random.Random(7)
|
||
genome = sample_genome(DB, BOUNDS, rng)
|
||
for _ in range(200):
|
||
genome = mutate(genome, DB, BOUNDS, rng, rate=0.5)
|
||
assert BOUNDS.min_stages <= len(genome.stages) <= BOUNDS.max_stages
|
||
assert len(genome.inter_stage_gaps_m) == len(genome.stages) - 1
|
||
assert BOUNDS.tube_inner_d_m_min <= genome.tube_inner_d_m <= BOUNDS.tube_inner_d_m_max
|
||
assert genome.projectile.diameter_m <= genome.tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M + 1e-9
|
||
# decode должен всегда успевать без исключений после repair
|
||
decode(genome, DB, BOUNDS)
|
||
|
||
|
||
def test_mutate_can_change_stage_count():
|
||
rng = random.Random(3)
|
||
small_bounds = SearchBounds(min_stages=1, max_stages=3)
|
||
genome = sample_genome(DB, small_bounds, rng)
|
||
genome.stages = genome.stages[:1]
|
||
genome.inter_stage_gaps_m = []
|
||
counts = set()
|
||
for _ in range(100):
|
||
genome = mutate(genome, DB, small_bounds, rng, rate=0.6)
|
||
counts.add(len(genome.stages))
|
||
assert len(counts) > 1 # число ступеней реально меняется, не застряло
|
||
|
||
|
||
def test_crossover_produces_decodable_child():
|
||
rng = random.Random(11)
|
||
a = sample_genome(DB, BOUNDS, rng)
|
||
b = sample_genome(DB, BOUNDS, rng)
|
||
for _ in range(20):
|
||
child = crossover(a, b, rng)
|
||
child = repair(child, DB, BOUNDS)
|
||
config, _, _ = decode(child, DB, BOUNDS)
|
||
assert len(config.stages) == len(child.stages)
|
||
|
||
|
||
def test_crossover_of_two_single_stage_genomes_does_not_crash():
|
||
"""Регрессия: оба родителя с 1 ступенью (0 зазоров) роняли crossover
|
||
с IndexError при попытке взять запасной зазор из пустого списка."""
|
||
rng = random.Random(13)
|
||
a = sample_genome(DB, BOUNDS, rng)
|
||
b = sample_genome(DB, BOUNDS, rng)
|
||
a.stages = a.stages[:1]
|
||
a.inter_stage_gaps_m = []
|
||
b.stages = b.stages[:1]
|
||
b.inter_stage_gaps_m = []
|
||
for _ in range(20):
|
||
child = crossover(a, b, rng)
|
||
child = repair(child, DB, BOUNDS)
|
||
decode(child, DB, BOUNDS)
|
||
|
||
|
||
def test_genome_dict_roundtrip():
|
||
rng = random.Random(5)
|
||
genome = sample_genome(DB, BOUNDS, rng)
|
||
restored = genome_from_dict(genome_to_dict(genome))
|
||
assert restored == genome
|