"""Integration tests for the full simulator.""" from agentic_rl.engine.simulator import FishFarmSimulator class TestSimulatorBasics: def test_reset_creates_valid_state(self): sim = FishFarmSimulator(seed=42) state = sim.reset() assert state["fish"]["weight_g"] > 0 assert state["fish"]["population"] > 0 assert state["water"]["DO"] > 0 assert state["water"]["temperature"] > 0 def test_step_advances_time(self): sim = FishFarmSimulator(seed=42) sim.reset() state = sim.step(feeding_rate=0.5, aeration_rate=0.5, heater_setting=0.0, water_exchange_rate=0.01, harvest=False, treatment="none") assert state["time"]["hour"] == 1 def test_24_hours_equals_one_day(self): sim = FishFarmSimulator(seed=42) sim.reset() for _ in range(24): state = sim.step(0.5, 0.5, 0.0, 0.01, False, "none") assert state["time"]["day"] == 1 def test_overfeeding_causes_ammonia_rise(self): sim = FishFarmSimulator(seed=42) sim.reset() initial_tan = sim.water.TAN for _ in range(48): # 2 days of overfeeding sim.step(1.0, 0.3, 0.0, 0.0, False, "none") # max feed, low aeration, no exchange assert sim.water.TAN > initial_tan def test_no_aeration_causes_do_drop(self): sim = FishFarmSimulator(seed=42) sim.reset() for _ in range(12): # 12 hours nighttime without aeration sim.step(0.0, 0.0, 0.0, 0.0, False, "none") assert sim.water.DO < 7.0 # should drop from initial def test_fish_grow_over_time(self): sim = FishFarmSimulator(seed=42) sim.reset() initial_weight = sim.fish.weight_g for _ in range(24 * 7): # 1 week sim.step(0.5, 0.5, 0.0, 0.02, False, "none") assert sim.fish.weight_g > initial_weight def test_harvest_ends_episode(self): sim = FishFarmSimulator(seed=42) sim.reset() state = sim.step(0.5, 0.5, 0.0, 0.01, True, "none") # harvest=True assert state["harvested"] is True def test_mass_mortality_is_catastrophe(self): sim = FishFarmSimulator(seed=42) sim.reset() # Force lethal conditions sim.water.DO = 0.5 sim.water.TAN = 5.0 sim.water.temperature = 40.0 state = sim.step(0.0, 0.0, 0.0, 0.0, False, "none") assert state["fish"]["mortality_today"] > 0 def test_cascade_overfeed_to_mortality(self): """The signature RL challenge: overfeed -> ammonia -> DO crash -> deaths.""" sim = FishFarmSimulator(seed=42) sim.reset() # Heavy overfeeding for 3 days with no aeration or exchange for _ in range(72): sim.step(1.0, 0.0, 0.0, 0.0, False, "none") # Should see elevated ammonia and reduced survival assert sim.water.TAN > 1.0 or sim.fish.population < 10000 def test_state_includes_enhanced_economics(self): """State dict should include new economics fields from engine enhancement.""" sim = FishFarmSimulator(seed=42) sim.reset() state = sim.step(0.5, 0.5, 0.0, 0.02, False, "none") econ = state["economics"] assert "feed_price_per_kg" in econ assert "marginal_cost_per_hour" in econ assert "roi_pct" in econ assert econ["feed_price_per_kg"] > 0 def test_stochastic_feed_price_varies(self): """Feed price should vary stochastically over time.""" sim = FishFarmSimulator(seed=42) sim.reset() prices = [] for _ in range(48): state = sim.step(0.5, 0.5, 0.0, 0.02, False, "none") prices.append(state["economics"]["feed_price_per_kg"]) # Price should not be perfectly constant (OU process adds noise) assert len(set(prices)) > 1 def test_seasonal_price_varies_by_day(self): """Market price multiplier should reflect seasonal demand.""" from agentic_rl.engine.economics import EconomicsEngine econ = EconomicsEngine() econ.reset() econ.apply_seasonal_price(day_of_year=360) # Christmas → premium xmas_price = econ.market_price_multiplier econ.market_price_multiplier = 1.0 # reset econ.apply_seasonal_price(day_of_year=180) # mid-year → dip midyear_price = econ.market_price_multiplier assert xmas_price > midyear_price def test_vaccination_treatment_option(self): """Vaccination should move susceptible fish to recovered.""" from agentic_rl.engine.disease import DiseaseEngine de = DiseaseEngine() de.reset(population=10000) initial_susceptible = de.susceptible de.apply_treatment("vaccination") assert de.recovered > 0 assert de.susceptible < initial_susceptible # 80% should be vaccinated assert de.recovered >= int(initial_susceptible * 0.79) def test_temperature_affects_disease_virulence(self): """Disease should progress differently at different temperatures.""" from agentic_rl.engine.disease import DiseaseEngine de_warm = DiseaseEngine() de_warm.reset(population=10000) de_warm.trigger_outbreak(50) de_cold = DiseaseEngine() de_cold.reset(population=10000) de_cold.trigger_outbreak(50) # Run for 5 days for _ in range(120): de_warm.step(1.0, de_warm.susceptible + de_warm.exposed + de_warm.infected + de_warm.recovered, temperature=30.0) de_cold.step(1.0, de_cold.susceptible + de_cold.exposed + de_cold.infected + de_cold.recovered, temperature=15.0) # Warm conditions should produce more disease deaths assert de_warm.total_disease_deaths >= de_cold.total_disease_deaths class TestObservationCompleteness: """Verify the FarmObservation includes all enhanced fields.""" def test_observation_has_fish_growth_fields(self): """Observation should include FCR, SGR, growth rate, stocking density.""" from agentic_rl.server.environment import FishFarmEnvironment env = FishFarmEnvironment() obs = env.reset(task_id="feeding_basics") assert hasattr(obs, "fcr") assert hasattr(obs, "sgr") assert hasattr(obs, "growth_rate_g_day") assert hasattr(obs, "stocking_density") def test_observation_has_economics_fields(self): """Observation should include stochastic feed price, ROI, marginal cost.""" from agentic_rl.server.environment import FishFarmEnvironment from agentic_rl.models import FarmAction env = FishFarmEnvironment() env.reset(task_id="feeding_basics") step_obs = env.step(FarmAction(feeding_rate=0.5, aeration_rate=0.5)) assert hasattr(step_obs, "feed_price_per_kg") assert hasattr(step_obs, "market_price_multiplier") assert hasattr(step_obs, "marginal_cost_per_hour") assert hasattr(step_obs, "roi_pct") assert step_obs.feed_price_per_kg > 0 def test_observation_has_weather_fields(self): """Observation should include daytime, storm, humidity.""" from agentic_rl.server.environment import FishFarmEnvironment env = FishFarmEnvironment() obs = env.reset(task_id="feeding_basics") assert hasattr(obs, "is_daytime") assert hasattr(obs, "storm_active") assert hasattr(obs, "humidity") def test_observation_has_disease_signal(self): """Observation should have disease_suspected (behavioral indicator).""" from agentic_rl.server.environment import FishFarmEnvironment env = FishFarmEnvironment() obs = env.reset(task_id="feeding_basics") assert hasattr(obs, "disease_suspected") # No disease initially assert obs.disease_suspected is False def test_observation_has_survival_fields(self): """Observation should include cumulative mortality and survival rate.""" from agentic_rl.server.environment import FishFarmEnvironment env = FishFarmEnvironment() obs = env.reset(task_id="feeding_basics") assert hasattr(obs, "cumulative_mortality") assert hasattr(obs, "survival_rate") assert obs.survival_rate == 1.0 def test_observation_has_nitrate_and_algae(self): """Observation should include NO3 and algae bloom status.""" from agentic_rl.server.environment import FishFarmEnvironment env = FishFarmEnvironment() obs = env.reset(task_id="feeding_basics") assert hasattr(obs, "nitrate") assert hasattr(obs, "algae_bloom") class TestHeuristicAgent: """Test the rule-based heuristic fallback agent.""" def test_heuristic_reduces_feed_on_low_do(self): from inference import heuristic_action obs = {"dissolved_oxygen": 2.0, "ammonia_toxic": 0.01, "temperature": 28.0, "stress_level": 0.3, "feeding_response": "sluggish", "avg_fish_weight": 100.0, "population": 5000, "feed_remaining_kg": 200.0, "biofilter_working": True, "aerator_working": True, "disease_suspected": False, "is_daytime": True, "market_price_multiplier": 1.0} action = heuristic_action(obs, "feeding_basics", 10, 168) assert action["feeding_rate"] <= 0.2 assert action["aeration_rate"] == 1.0 # emergency DO def test_heuristic_treats_disease(self): from inference import heuristic_action obs = {"dissolved_oxygen": 6.0, "ammonia_toxic": 0.01, "temperature": 28.0, "stress_level": 0.5, "feeding_response": "sluggish", "avg_fish_weight": 200.0, "population": 5000, "feed_remaining_kg": 200.0, "biofilter_working": True, "aerator_working": True, "disease_suspected": True, "mortality_today": 15, "is_daytime": True, "market_price_multiplier": 1.0} action = heuristic_action(obs, "disease_outbreak", 50, 240) assert action["treatment"] == "antibiotics" def test_heuristic_harvests_at_market_weight(self): from inference import heuristic_action obs = {"dissolved_oxygen": 7.0, "ammonia_toxic": 0.01, "temperature": 28.0, "stress_level": 0.1, "feeding_response": "eager", "avg_fish_weight": 550.0, "population": 5000, "feed_remaining_kg": 200.0, "biofilter_working": True, "aerator_working": True, "disease_suspected": False, "is_daytime": True, "market_price_multiplier": 1.15, "mortality_today": 0} action = heuristic_action(obs, "full_growout", 1400, 1440) assert action["harvest_decision"] is True def test_heuristic_heats_cold_water(self): from inference import heuristic_action obs = {"dissolved_oxygen": 7.0, "ammonia_toxic": 0.01, "temperature": 22.0, "stress_level": 0.2, "feeding_response": "normal", "avg_fish_weight": 100.0, "population": 5000, "feed_remaining_kg": 200.0, "biofilter_working": True, "aerator_working": True, "disease_suspected": False, "is_daytime": True, "market_price_multiplier": 1.0, "mortality_today": 0} action = heuristic_action(obs, "temperature_stress", 10, 120) assert action["heater_setting"] > 0 def test_heuristic_increases_exchange_for_high_ammonia(self): from inference import heuristic_action obs = {"dissolved_oxygen": 6.0, "ammonia_toxic": 0.15, "ammonia": 2.5, "temperature": 28.0, "stress_level": 0.3, "feeding_response": "sluggish", "avg_fish_weight": 150.0, "population": 5000, "feed_remaining_kg": 200.0, "biofilter_working": True, "aerator_working": True, "disease_suspected": False, "is_daytime": True, "market_price_multiplier": 1.0, "mortality_today": 0} action = heuristic_action(obs, "ammonia_crisis", 10, 72) assert action["water_exchange_rate"] >= 0.05 class TestStochasticGrowth: """Test stochastic growth noise (KB-03 Sec 9.2).""" def test_growth_has_variance_across_seeds(self): """Different seeds should produce slightly different growth outcomes.""" weights = [] for seed in [1, 2, 3, 4, 5]: sim = FishFarmSimulator(seed=seed) sim.reset(seed=seed) for _ in range(24): sim.step(0.5, 0.5, 0.0, 0.02, False, "none") weights.append(sim.fish.weight_g) # All should be close (same conditions) but not identical (stochastic noise) assert max(weights) > min(weights) # some variation exists # But within reasonable bounds (<2% spread for 24h) spread = (max(weights) - min(weights)) / min(weights) assert spread < 0.05 # less than 5% spread in 24h def test_deterministic_with_same_seed(self): """Same seed should produce identical results.""" results = [] for _ in range(2): sim = FishFarmSimulator(seed=42) sim.reset(seed=42) for _ in range(24): sim.step(0.5, 0.5, 0.0, 0.02, False, "none") results.append(sim.fish.weight_g) assert results[0] == results[1] class TestNighttimeDORisk: """Test nighttime DO crash risk tracking.""" def test_state_includes_nighttime_do_risk(self): sim = FishFarmSimulator(seed=42) state = sim.reset() assert "nighttime_do_risk" in state["water"] assert 0.0 <= state["water"]["nighttime_do_risk"] <= 1.0 def test_observation_has_nighttime_do_risk(self): from agentic_rl.server.environment import FishFarmEnvironment env = FishFarmEnvironment() obs = env.reset(task_id="feeding_basics") assert hasattr(obs, "nighttime_do_risk") assert 0.0 <= obs.nighttime_do_risk <= 1.0 def test_high_algae_increases_nighttime_risk(self): """Algae bloom should raise nighttime DO crash risk.""" sim = FishFarmSimulator(seed=42) sim.reset() # Force algae bloom sim.water.chlorophyll_a = 100.0 # Run through a day-night cycle (24h) for _ in range(24): sim.step(0.5, 0.3, 0.0, 0.02, False, "none") # Risk should be non-zero with high algae assert sim.water.nighttime_do_risk >= 0.0 def test_heuristic_boosts_aeration_on_high_risk(self): """Heuristic should increase aeration when nighttime DO risk is high.""" from inference import heuristic_action obs = {"dissolved_oxygen": 6.0, "ammonia_toxic": 0.01, "temperature": 28.0, "stress_level": 0.1, "feeding_response": "normal", "avg_fish_weight": 100.0, "population": 5000, "feed_remaining_kg": 200.0, "biofilter_working": True, "aerator_working": True, "disease_suspected": False, "is_daytime": False, "market_price_multiplier": 1.0, "mortality_today": 0, "nighttime_do_risk": 0.8} action = heuristic_action(obs, "oxygen_management", 10, 72) assert action["aeration_rate"] >= 0.9 # should boost for high risk class TestHeatWaveEvent: """Test that heat_wave events actually raise water temperature.""" def test_heat_wave_raises_temperature(self): """Heat wave event should increase water temperature over time.""" from agentic_rl.engine.events import Event sim = FishFarmSimulator(seed=42) sim.reset( initial_temp=28.0, base_air_temp=30.0, scheduled_events=[ Event(type="heat_wave", trigger_hour=0, severity=0.7, duration_hours=48, description="Heat wave test"), ], ) # Run 24 hours with heat wave active for _ in range(24): sim.step(0.3, 0.5, 0.0, 0.02, False, "none") # Water should warm up from the heat wave assert sim.water.temperature > 28.0 def test_heat_wave_ends_correctly(self): """After heat wave ends, temperature should not keep rising.""" from agentic_rl.engine.events import Event sim = FishFarmSimulator(seed=42) sim.reset( initial_temp=28.0, base_air_temp=28.0, scheduled_events=[ Event(type="heat_wave", trigger_hour=0, severity=0.7, duration_hours=6, description="Short heat wave"), ], ) # Run through the 6-hour heat wave for _ in range(6): sim.step(0.3, 0.5, 0.0, 0.02, False, "none") temp_at_end = sim.water.temperature # Run 12 more hours after heat wave ended — temp should stabilize/drop for _ in range(12): sim.step(0.3, 0.5, -0.3, 0.02, False, "none") # slight cooling # Should not have increased further (heat wave is over) assert sim.water.temperature <= temp_at_end + 1.0 # small tolerance for thermal inertia class TestVaccinationProphylaxis: """Test that vaccination works as preventive measure (KB-03 Sec 4.2).""" def test_vaccination_without_active_disease(self): """Vaccination should work even when no disease is active.""" sim = FishFarmSimulator(seed=42) sim.reset() assert sim.disease.is_active is False initial_susceptible = sim.disease.susceptible sim.step(0.5, 0.5, 0.0, 0.02, False, "vaccination") # 80% of susceptible should be vaccinated (moved to recovered) assert sim.disease.recovered > 0 assert sim.disease.susceptible < initial_susceptible def test_vaccination_cost_charged(self): """Vaccination cost should be recorded even without active disease.""" sim = FishFarmSimulator(seed=42) sim.reset() sim.step(0.5, 0.5, 0.0, 0.02, False, "vaccination") assert sim.economics.total_treatment_cost > 0 def test_antibiotics_blocked_without_disease(self): """Non-vaccination treatments should NOT apply without active disease.""" sim = FishFarmSimulator(seed=42) sim.reset() sim.step(0.5, 0.5, 0.0, 0.02, False, "antibiotics") assert sim.economics.total_treatment_cost == 0.0 class TestCostBreakdown: """Test that cost breakdown is exposed in state dict.""" def test_state_includes_cost_breakdown(self): sim = FishFarmSimulator(seed=42) sim.reset() state = sim.step(0.5, 0.5, 0.0, 0.02, False, "none") assert "cost_breakdown" in state["economics"] breakdown = state["economics"]["cost_breakdown"] assert "feed" in breakdown assert "energy" in breakdown assert "total" in breakdown def test_cost_breakdown_components_sum(self): sim = FishFarmSimulator(seed=42) sim.reset() for _ in range(24): state = sim.step(0.5, 0.5, 0.0, 0.02, False, "none") breakdown = state["economics"]["cost_breakdown"] component_sum = sum( v["amount"] for v in breakdown.values() if isinstance(v, dict) ) assert abs(component_sum - breakdown["total"]) < 0.1 class TestHarvestRevenue: """Test weight-dependent harvest revenue.""" def test_harvest_revenue_uses_weight_premium(self): """Harvest revenue should reflect weight-dependent pricing.""" from agentic_rl.engine.economics import EconomicsEngine econ = EconomicsEngine() econ.reset() # Underweight fish should get less revenue than market-weight fish rev_small = econ.calculate_harvest_revenue(100.0, avg_weight_g=100.0) rev_large = econ.calculate_harvest_revenue(100.0, avg_weight_g=500.0) assert rev_large > rev_small def test_harvest_matches_fish_value(self): """Harvest revenue should equal fish value (same pricing curve).""" from agentic_rl.engine.economics import EconomicsEngine econ = EconomicsEngine() econ.reset() value = econ.calculate_fish_value(200.0, avg_weight_g=350.0) revenue = econ.calculate_harvest_revenue(200.0, avg_weight_g=350.0) assert abs(value - revenue) < 0.01 class TestTaskSpecificHeuristics: """Test task-specific heuristic strategies in inference.py.""" def _base_obs(self, **overrides): obs = { "dissolved_oxygen": 6.5, "ammonia_toxic": 0.01, "ammonia": 0.2, "nitrite": 0.05, "temperature": 28.0, "stress_level": 0.1, "feeding_response": "normal", "avg_fish_weight": 150.0, "population": 5000, "feed_remaining_kg": 200.0, "biofilter_working": True, "aerator_working": True, "disease_suspected": False, "is_daytime": True, "market_price_multiplier": 1.0, "mortality_today": 0, "nighttime_do_risk": 0.1, "feed_price_per_kg": 0.50, "water_quality_score": 0.85, "algae_bloom": False, } obs.update(overrides) return obs def test_storm_pre_positioning(self): """Storm response: pre-storm phase should boost aeration and reduce feeding.""" from inference import heuristic_action obs = self._base_obs() action = heuristic_action(obs, "storm_response", step=10, max_hours=120) assert action["aeration_rate"] >= 0.8 assert action["water_exchange_rate"] >= 0.04 def test_storm_power_outage_minimal_feeding(self): """During power outage (h24-36), feeding should be minimal.""" from inference import heuristic_action obs = self._base_obs(aerator_working=False) action = heuristic_action(obs, "storm_response", step=28, max_hours=120) assert action["feeding_rate"] <= 0.1 def test_ammonia_crisis_aggressive_exchange(self): """Ammonia crisis with high UIA should trigger aggressive water exchange.""" from inference import heuristic_action obs = self._base_obs(ammonia_toxic=0.08, ammonia=1.5, biofilter_working=False) action = heuristic_action(obs, "ammonia_crisis", step=5, max_hours=72) assert action["water_exchange_rate"] >= 0.06 assert action["feeding_rate"] <= 0.15 def test_disease_outbreak_early_vaccination(self): """Disease outbreak task: should vaccinate at step 1 (before h12 trigger).""" from inference import heuristic_action obs = self._base_obs() action = heuristic_action(obs, "disease_outbreak", step=1, max_hours=240) assert action["treatment"] == "vaccination" def test_multi_objective_stress_reduction(self): """Multi-objective: high stress should reduce feeding for welfare.""" from inference import heuristic_action obs = self._base_obs(stress_level=0.35) action = heuristic_action(obs, "multi_objective", step=100, max_hours=720) assert action["feeding_rate"] <= 0.35 # Multi-objective minimizes aeration when DO is good to save costs assert action["aeration_rate"] <= 0.4 def test_temperature_stress_cooling(self): """Temperature stress: hot temps should trigger cooling + more aeration.""" from inference import heuristic_action obs = self._base_obs(temperature=35.0) action = heuristic_action(obs, "temperature_stress", step=30, max_hours=120) assert action["aeration_rate"] >= 0.85 assert action["feeding_rate"] <= 0.3 def test_nitrite_triggers_salt_treatment(self): """High nitrite should trigger salt treatment.""" from inference import heuristic_action obs = self._base_obs(nitrite=0.8) action = heuristic_action(obs, "water_quality_balance", step=10, max_hours=168) assert action["treatment"] == "salt" def test_full_growout_harvest_at_market_weight(self): """Full growout: harvest when weight >= 400 near episode end.""" from inference import heuristic_action obs = self._base_obs(avg_fish_weight=460.0, market_price_multiplier=1.05) # hours_left = 1440 - 1420 = 20 → triggers weight >= 400 and hours_left <= 24 action = heuristic_action(obs, "full_growout", step=1420, max_hours=1440) assert action["harvest_decision"] is True def test_season_management_conserve_low_feed(self): """Season management: conserve feed when inventory critically low.""" from inference import heuristic_action obs = self._base_obs(feed_remaining_kg=25.0) # < 30 triggers min 0.2 action = heuristic_action(obs, "season_management", step=500, max_hours=2160) assert action["feeding_rate"] <= 0.25 def test_feed_price_sensitivity(self): """Expensive feed should reduce feeding rate by 15%.""" from inference import heuristic_action obs_cheap = self._base_obs(feed_price_per_kg=0.40) obs_expensive = self._base_obs(feed_price_per_kg=0.70) action_cheap = heuristic_action(obs_cheap, "feeding_basics", step=10, max_hours=168) action_expensive = heuristic_action(obs_expensive, "feeding_basics", step=10, max_hours=168) assert action_expensive["feeding_rate"] < action_cheap["feeding_rate"] def test_catastrophe_survives_through_crises(self): """Catastrophe: endure crises, harvest after engagement threshold (≥84h).""" from inference import heuristic_action obs = self._base_obs(avg_fish_weight=250.0) # Early: should NOT harvest (engagement penalty in grader) action_early = heuristic_action(obs, "catastrophe_prevention", step=2, max_hours=336) assert action_early["harvest_decision"] is False # After engagement threshold: harvest to lock in survival action_late = heuristic_action(obs, "catastrophe_prevention", step=90, max_hours=336) assert action_late["harvest_decision"] is True