- optim/worker_context.py: shared per-process DB/bounds init, extracted from sweep.py so evolutionary.py doesn't reach into another module's private state - optim/objective.py: build_run_record() shared between sweep and evolutionary so both write identically-shaped rows - optim/evolutionary.py: tournament selection, whole-stage-swap crossover, elitism, structural mutation (stage count itself evolves), then a serial Nelder-Mead polish of the best genome's continuous parameters with discrete component choices frozen - Found and fixed a real crash: crossover() indexed into an empty inter_stage_gaps_m list when both parents had only 1 stage (0 gaps), raising IndexError. Fixed the fallback to only choose from gaps that actually exist, defaulting to a neutral value (clipped later by repair()) when neither parent has one. Added a regression test. - Verified 59/59 tests pass both locally and inside the Docker image Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
107 lines
4.0 KiB
Python
107 lines
4.0 KiB
Python
import random
|
||
|
||
import pytest
|
||
|
||
from gausse.components.database import ComponentDatabase
|
||
from gausse.optim.search_space import (
|
||
TUBE_WALL_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_od_m_min <= genome.tube_od_m <= BOUNDS.tube_od_m_max
|
||
assert genome.projectile.diameter_m <= genome.tube_od_m - TUBE_WALL_CLEARANCE_M
|
||
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_od_m_min <= genome.tube_od_m <= BOUNDS.tube_od_m_max
|
||
assert genome.projectile.diameter_m <= genome.tube_od_m - TUBE_WALL_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
|