Spaces:
Running
Running
| # Copyright (c) Meta Platforms, Inc. and affiliates. | |
| # All rights reserved. | |
| # | |
| # This source code is licensed under the BSD-style license found in the | |
| # LICENSE file in the root directory of this source tree. | |
| """Tests for the DC-Ops environment, action parser, and dashboard renderer. | |
| Validates: | |
| - OpenEnv interface contract (reset/step/state) | |
| - Action parsing (valid and invalid commands) | |
| - Dashboard rendering output format | |
| - Episode termination conditions | |
| - Fault injection | |
| - Reward computation | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| from dc_ops_env.models import DcOpsAction, DcOpsObservation | |
| from dc_ops_env.server.dc_ops_env_environment import DcOpsEnvironment | |
| # =========================================================================== | |
| # OpenEnv Interface Contract | |
| # =========================================================================== | |
| class TestOpenEnvContract: | |
| """Verify the environment satisfies the OpenEnv Environment ABC.""" | |
| def test_reset_returns_observation(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert isinstance(obs, DcOpsObservation) | |
| assert obs.done is False | |
| assert obs.reward == 0.0 | |
| def test_reset_has_dashboard(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert len(obs.dashboard) > 100 | |
| assert "DC-OPS MONITORING DASHBOARD" in obs.dashboard | |
| def test_reset_has_available_actions(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert len(obs.available_actions) > 5 | |
| def test_step_returns_observation(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="check_status")) | |
| assert isinstance(obs, DcOpsObservation) | |
| assert obs.done is False | |
| def test_step_advances_step_count(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| assert env.state.step_count == 0 | |
| env.step(DcOpsAction(command="wait")) | |
| assert env.state.step_count == 1 | |
| env.step(DcOpsAction(command="wait")) | |
| assert env.state.step_count == 2 | |
| def test_state_has_episode_id(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| assert env.state.episode_id is not None | |
| assert len(env.state.episode_id) > 0 | |
| def test_reset_changes_episode_id(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs1 = env.reset() | |
| ep1 = env.state.episode_id | |
| obs2 = env.reset() | |
| ep2 = env.state.episode_id | |
| assert ep1 != ep2 | |
| def test_observation_metadata_populated(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert "total_it_load_kw" in obs.metadata | |
| assert "pue" in obs.metadata | |
| assert "zones" in obs.metadata | |
| assert obs.metadata["total_it_load_kw"] == pytest.approx(160.0, rel=0.01) | |
| def test_observation_has_power_metadata(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert "power" in obs.metadata | |
| assert obs.metadata["power"]["utility_available"] is True | |
| # =========================================================================== | |
| # Action Parser Tests | |
| # =========================================================================== | |
| class TestActionParser: | |
| """Test command parsing and execution.""" | |
| def test_diagnose_crac(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="diagnose CRAC-1")) | |
| assert "Diagnostic Report" in obs.action_result | |
| assert "CRAC-1" in obs.action_result | |
| assert obs.reward > -0.5 # Valid action should not be heavily penalized | |
| def test_diagnose_ups(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="diagnose UPS-1")) | |
| assert "Diagnostic Report" in obs.action_result | |
| assert "UPS-1" in obs.action_result | |
| def test_diagnose_nonexistent(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="diagnose CRAC-99")) | |
| assert "not found" in obs.action_result | |
| def test_adjust_setpoint_valid(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="adjust_setpoint CRAC-1 22")) | |
| assert "adjusted" in obs.action_result.lower() | |
| assert "22.0" in obs.action_result | |
| def test_adjust_setpoint_out_of_range(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="adjust_setpoint CRAC-1 50")) | |
| assert "out of safe range" in obs.action_result.lower() or "out of" in obs.action_result.lower() | |
| def test_set_fan_speed(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="set_fan_speed CRAC-2 80")) | |
| assert "fan speed" in obs.action_result.lower() | |
| assert "80" in obs.action_result | |
| def test_set_rack_load(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="set_rack_load A-01 12")) | |
| assert "12.0" in obs.action_result | |
| def test_start_generator(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="start_generator")) | |
| assert "generator" in obs.action_result.lower() | |
| def test_wait_command(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="wait")) | |
| assert "no action" in obs.action_result.lower() | |
| def test_check_status(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="check_status")) | |
| assert "status" in obs.action_result.lower() | |
| def test_invalid_command(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="fly_to_the_moon")) | |
| assert "unknown" in obs.action_result.lower() | |
| def test_empty_command(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="")) | |
| assert "empty" in obs.action_result.lower() | |
| def test_case_insensitive(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="DIAGNOSE CRAC-1")) | |
| assert "Diagnostic Report" in obs.action_result | |
| def test_start_stop_crac(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="stop_crac CRAC-2")) | |
| assert "standby" in obs.action_result.lower() | |
| obs = env.step(DcOpsAction(command="start_crac CRAC-2")) | |
| assert "started" in obs.action_result.lower() | |
| # =========================================================================== | |
| # Dashboard Rendering Tests | |
| # =========================================================================== | |
| class TestDashboardRendering: | |
| """Test dashboard output format and content.""" | |
| def test_dashboard_has_cooling_section(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert "COOLING UNITS" in obs.dashboard | |
| assert "CRAC-1" in obs.dashboard | |
| def test_dashboard_has_zone_temps(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert "ZONE TEMPERATURES" in obs.dashboard | |
| assert "zone_a" in obs.dashboard | |
| def test_dashboard_has_rack_temps(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert "RACK TEMPERATURES" in obs.dashboard | |
| def test_dashboard_has_power(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert "POWER" in obs.dashboard | |
| assert "PUE" in obs.dashboard | |
| def test_dashboard_has_environment(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert "ENVIRONMENT" in obs.dashboard | |
| assert "35.0°C" in obs.dashboard | |
| def test_dashboard_shows_alert(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset(alert="Test alert message") | |
| assert "ALERT" in obs.dashboard | |
| assert "Test alert message" in obs.dashboard | |
| def test_dashboard_shows_step_count(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert "Step: 0/15" in obs.dashboard | |
| obs = env.step(DcOpsAction(command="wait")) | |
| assert "Step: 1/15" in obs.dashboard | |
| def test_dashboard_shows_ups_status(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| assert "UPS-1" in obs.dashboard | |
| assert "UPS-2" in obs.dashboard | |
| # =========================================================================== | |
| # Episode Termination Tests | |
| # =========================================================================== | |
| class TestEpisodeTermination: | |
| """Test episode termination conditions.""" | |
| def test_step_budget_exhaustion(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset(step_budget=3) | |
| obs = env.step(DcOpsAction(command="wait")) | |
| assert obs.done is False | |
| obs = env.step(DcOpsAction(command="wait")) | |
| assert obs.done is False | |
| obs = env.step(DcOpsAction(command="wait")) | |
| assert obs.done is True # Step 3/3 | |
| def test_escalation_terminates(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="escalate")) | |
| assert obs.done is True | |
| assert obs.reward < 0 # Penalty for escalating | |
| def test_step_after_done_is_noop(self) -> None: | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="escalate")) | |
| assert obs.done is True | |
| obs2 = env.step(DcOpsAction(command="wait")) | |
| assert obs2.done is True | |
| assert "already ended" in obs2.action_result.lower() | |
| # =========================================================================== | |
| # Fault Injection Tests | |
| # =========================================================================== | |
| class TestFaultInjection: | |
| """Test scenario fault injection at reset.""" | |
| def test_crac_fault_injection(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset( | |
| fault_injection={ | |
| "type": "crac_fault", | |
| "unit_id": "CRAC-3", | |
| "fault": "compressor", | |
| }, | |
| ) | |
| # Dashboard should show the fault | |
| assert "COMPRESSOR" in obs.dashboard or "FAULT" in obs.dashboard | |
| def test_utility_loss_injection(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset( | |
| fault_injection={"type": "utility_loss"}, | |
| ) | |
| assert "DOWN" in obs.dashboard or "BATTERY" in obs.dashboard | |
| def test_outside_temp_injection(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset( | |
| fault_injection={"type": "outside_temp", "temp_c": 45.0}, | |
| ) | |
| assert "45.0°C" in obs.dashboard | |
| def test_alert_in_observation(self) -> None: | |
| env = DcOpsEnvironment() | |
| obs = env.reset( | |
| alert="HIGH TEMPERATURE in Zone B", | |
| scenario_type="thermal", | |
| ) | |
| assert obs.alert == "HIGH TEMPERATURE in Zone B" | |
| assert obs.scenario_type == "thermal" | |
| # =========================================================================== | |
| # Reward Tests | |
| # =========================================================================== | |
| class TestReward: | |
| """Test reward computation.""" | |
| def test_valid_action_positive_component(self) -> None: | |
| """Valid actions should get a positive action reward component.""" | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs_valid = env.step(DcOpsAction(command="check_status")) | |
| r_valid = obs_valid.reward | |
| env.reset() | |
| obs_invalid = env.step(DcOpsAction(command="nonsense_command")) | |
| r_invalid = obs_invalid.reward | |
| # Valid action should yield higher reward than invalid | |
| assert r_valid > r_invalid | |
| def test_pue_affects_reward(self) -> None: | |
| """Reward should be sensitive to PUE.""" | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| # Just verify PUE is in metadata and reward is computed | |
| pue = obs.metadata["pue"] | |
| assert pue > 1.0 # PUE should always be > 1 | |
| def test_cumulative_reward_tracked(self) -> None: | |
| """Cumulative reward should be tracked in metadata.""" | |
| env = DcOpsEnvironment() | |
| env.reset() | |
| obs = env.step(DcOpsAction(command="wait")) | |
| assert "cumulative_reward" in obs.metadata | |
| r1 = obs.metadata["cumulative_reward"] | |
| obs = env.step(DcOpsAction(command="wait")) | |
| r2 = obs.metadata["cumulative_reward"] | |
| # Cumulative should change (it's the sum of per-step rewards) | |
| assert r2 != 0 or r1 != 0 # At least one should be non-zero | |
| # =========================================================================== | |
| # Simulation Integration Tests | |
| # =========================================================================== | |
| class TestSimulationIntegration: | |
| """Test that the environment properly advances the simulation.""" | |
| def test_simulation_time_advances(self) -> None: | |
| """Each step should advance sim time by game_time_per_step.""" | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| t0 = obs.metadata["sim_time_s"] | |
| obs = env.step(DcOpsAction(command="wait")) | |
| t1 = obs.metadata["sim_time_s"] | |
| # Default: 60s per step | |
| assert t1 - t0 == pytest.approx(60.0, rel=0.01) | |
| def test_custom_game_time_per_step(self) -> None: | |
| """Custom game_time_per_step should be respected.""" | |
| env = DcOpsEnvironment() | |
| obs = env.reset(game_time_per_step_s=120.0) | |
| t0 = obs.metadata["sim_time_s"] | |
| obs = env.step(DcOpsAction(command="wait")) | |
| t1 = obs.metadata["sim_time_s"] | |
| assert t1 - t0 == pytest.approx(120.0, rel=0.01) | |
| def test_setpoint_change_affects_temperature(self) -> None: | |
| """Changing setpoint should cause temperature change over steps.""" | |
| env = DcOpsEnvironment() | |
| obs = env.reset() | |
| t_cold_before = obs.metadata["zones"]["zone_a"]["cold_aisle_temp_c"] | |
| # Raise setpoint significantly | |
| env.step(DcOpsAction(command="adjust_setpoint CRAC-1 25")) | |
| env.step(DcOpsAction(command="adjust_setpoint CRAC-2 25")) | |
| # Wait a few steps for temp to change | |
| for _ in range(3): | |
| obs = env.step(DcOpsAction(command="wait")) | |
| t_cold_after = obs.metadata["zones"]["zone_a"]["cold_aisle_temp_c"] | |
| # Cold aisle should have increased | |
| assert t_cold_after > t_cold_before + 0.5, \ | |
| f"Expected temp increase: {t_cold_before:.1f} → {t_cold_after:.1f}" | |
| # =========================================================================== | |
| # Performance Test | |
| # =========================================================================== | |
| class TestPerformance: | |
| """Ensure full environment steps are fast enough.""" | |
| def test_episode_performance(self) -> None: | |
| """Full 15-step episode should complete in < 5 seconds.""" | |
| import time | |
| env = DcOpsEnvironment() | |
| start = time.perf_counter() | |
| env.reset() | |
| for _ in range(15): | |
| env.step(DcOpsAction(command="wait")) | |
| elapsed = time.perf_counter() - start | |
| assert elapsed < 5.0, f"Episode took {elapsed:.2f}s, should be < 5s" | |