Add variable-length genome search space and objective function (Stage 6 pt.1)
- optim/search_space.py: Genome encodes only indices into the real component database plus continuous/discrete geometry parameters; number of stages is itself mutable via add/remove/duplicate-stage operators. repair() clips everything back into bounds after mutation/crossover so decode() never sees an invalid genome. - optim/objective.py: evaluate() decodes a genome, computes real BOM cost from wire length/price and component prices, runs the full chain, and scores fitness = efficiency for feasible runs or a soft penalty scaled by how many stages it got through for infeasible ones (so the GA gets gradient instead of a wall). Tested against the real component database (not synthetic fixtures) — including an explicit infeasible case using the real inductive sensor's actual sensitivity/threshold values. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
60
tests/test_objective.py
Normal file
60
tests/test_objective.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import random
|
||||
|
||||
from gausse.components.database import ComponentDatabase
|
||||
from gausse.optim.objective import compute_cost_rub, decoded_summary, evaluate
|
||||
from gausse.optim.search_space import SearchBounds, decode, sample_genome
|
||||
|
||||
DB = ComponentDatabase.load()
|
||||
BOUNDS = SearchBounds()
|
||||
|
||||
|
||||
def test_evaluate_feasible_genome_has_fitness_equal_efficiency():
|
||||
rng = random.Random(2)
|
||||
found_feasible = False
|
||||
for _ in range(50):
|
||||
genome = sample_genome(DB, BOUNDS, rng)
|
||||
result = evaluate(genome, DB, BOUNDS)
|
||||
if result.feasible:
|
||||
found_feasible = True
|
||||
assert result.fitness == result.efficiency
|
||||
assert 0.0 <= result.efficiency <= 1.0
|
||||
assert result.cost_rub > 0
|
||||
break
|
||||
assert found_feasible, "ни один из 50 случайных геномов не оказался реализуемым"
|
||||
|
||||
|
||||
def test_evaluate_infeasible_genome_has_negative_fitness_and_reason():
|
||||
rng = random.Random(9)
|
||||
genome = sample_genome(DB, BOUNDS, rng)
|
||||
# индукционный датчик требует скорости >= порог/чувствительность = 5 м/с,
|
||||
# а старт по умолчанию 3 м/с -- первая ступень обязана провалиться
|
||||
inductive_idx = next(i for i, s in enumerate(DB.sensors) if s.kind == "inductive")
|
||||
genome.stages[0].sensor_idx = inductive_idx
|
||||
result = evaluate(genome, DB, BOUNDS)
|
||||
assert not result.feasible
|
||||
assert result.fitness < 0
|
||||
assert result.reason is not None
|
||||
assert result.failed_stage_index == 0
|
||||
|
||||
|
||||
def test_compute_cost_rub_is_positive_and_scales_with_stage_count():
|
||||
rng = random.Random(4)
|
||||
genome = sample_genome(DB, BOUNDS, rng)
|
||||
config, _, _ = decode(genome, DB, BOUNDS)
|
||||
cost = compute_cost_rub(config, DB)
|
||||
assert cost > 0
|
||||
|
||||
genome.stages.append(genome.stages[0])
|
||||
genome.inter_stage_gaps_m.append(0.05)
|
||||
config2, _, _ = decode(genome, DB, BOUNDS)
|
||||
cost2 = compute_cost_rub(config2, DB)
|
||||
assert cost2 > cost
|
||||
|
||||
|
||||
def test_decoded_summary_uses_real_part_numbers():
|
||||
rng = random.Random(6)
|
||||
genome = sample_genome(DB, BOUNDS, rng)
|
||||
summary = decoded_summary(genome, DB)
|
||||
assert len(summary["stages"]) == len(genome.stages)
|
||||
real_wire_ids = {w.part_id for w in DB.wires}
|
||||
assert all(s["wire"] in real_wire_ids for s in summary["stages"])
|
||||
90
tests/test_search_space.py
Normal file
90
tests/test_search_space.py
Normal file
@@ -0,0 +1,90 @@
|
||||
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_genome_dict_roundtrip():
|
||||
rng = random.Random(5)
|
||||
genome = sample_genome(DB, BOUNDS, rng)
|
||||
restored = genome_from_dict(genome_to_dict(genome))
|
||||
assert restored == genome
|
||||
Reference in New Issue
Block a user