Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- actions/parser.py +48 -0
- scenarios/power_scenarios.py +28 -5
- scenarios/thermal_scenarios.py +33 -9
- server/dc_ops_env_environment.py +11 -6
- server/static/index.html +538 -8
- simulation/thermal.py +4 -2
- tests/test_scenarios.py +7 -5
actions/parser.py
CHANGED
|
@@ -24,6 +24,7 @@ from ..simulation.power import PowerSimulation
|
|
| 24 |
from ..simulation.types import (
|
| 25 |
CRACFaultType,
|
| 26 |
CRACStatus,
|
|
|
|
| 27 |
UPSMode,
|
| 28 |
)
|
| 29 |
|
|
@@ -134,6 +135,53 @@ def _handle_diagnose(
|
|
| 134 |
]
|
| 135 |
return CommandResult(True, "\n".join(lines), "diagnose", target)
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
return CommandResult(False, f"Unit '{target}' not found.", "diagnose", target)
|
| 138 |
|
| 139 |
|
|
|
|
| 24 |
from ..simulation.types import (
|
| 25 |
CRACFaultType,
|
| 26 |
CRACStatus,
|
| 27 |
+
GeneratorState,
|
| 28 |
UPSMode,
|
| 29 |
)
|
| 30 |
|
|
|
|
| 135 |
]
|
| 136 |
return CommandResult(True, "\n".join(lines), "diagnose", target)
|
| 137 |
|
| 138 |
+
# Check Generator
|
| 139 |
+
if power:
|
| 140 |
+
gen = power.state.generator
|
| 141 |
+
if gen.gen_id.lower() == target.lower():
|
| 142 |
+
lines = [
|
| 143 |
+
f"=== Diagnostic Report: {gen.gen_id} ===",
|
| 144 |
+
f"State: {gen.state.value}",
|
| 145 |
+
f"Output: {gen.output_power_kw:.1f} kW",
|
| 146 |
+
f"Load: {gen.load_fraction * 100:.1f}%",
|
| 147 |
+
f"Fuel Level: {gen.fuel_level_liters:.0f} L / {gen.fuel_tank_liters:.0f} L",
|
| 148 |
+
f"Fuel Consumption: {gen.fuel_consumption_lph:.1f} L/hr",
|
| 149 |
+
f"Fuel Remaining: {gen.fuel_remaining_hours:.1f} hrs",
|
| 150 |
+
f"Rated Capacity: {gen.rated_capacity_kw:.0f} kW",
|
| 151 |
+
]
|
| 152 |
+
if gen.state == GeneratorState.OFF:
|
| 153 |
+
lines.append(">> Generator is OFF.")
|
| 154 |
+
elif gen.state in (GeneratorState.START_DELAY, GeneratorState.CRANKING, GeneratorState.WARMING):
|
| 155 |
+
lines.append(f">> Generator starting ({gen.state.value}), elapsed {gen.state_elapsed_s:.1f}s.")
|
| 156 |
+
elif gen.state == GeneratorState.READY:
|
| 157 |
+
lines.append(">> Generator READY to accept load.")
|
| 158 |
+
elif gen.state == GeneratorState.LOADED:
|
| 159 |
+
lines.append(f">> Generator LOADED at {gen.load_fraction * 100:.0f}%. Operating normally.")
|
| 160 |
+
elif gen.state == GeneratorState.COOLDOWN:
|
| 161 |
+
lines.append(f">> Generator in COOLDOWN, elapsed {gen.state_elapsed_s:.1f}s.")
|
| 162 |
+
return CommandResult(True, "\n".join(lines), "diagnose", target)
|
| 163 |
+
|
| 164 |
+
# Check PDUs
|
| 165 |
+
if power:
|
| 166 |
+
for pdu in power.state.pdus:
|
| 167 |
+
if pdu.pdu_id.lower() == target.lower():
|
| 168 |
+
lines = [
|
| 169 |
+
f"=== Diagnostic Report: {pdu.pdu_id} ===",
|
| 170 |
+
f"Input Power: {pdu.input_power_kw:.1f} kW",
|
| 171 |
+
f"Output Power: {pdu.output_power_kw:.1f} kW",
|
| 172 |
+
f"Load: {pdu.load_fraction * 100:.1f}%",
|
| 173 |
+
f"Phase Imbalance: {pdu.phase_imbalance_pct:.1f}%",
|
| 174 |
+
f"Breaker Tripped: {pdu.breaker_tripped}",
|
| 175 |
+
f"Overload: {pdu.overload}",
|
| 176 |
+
]
|
| 177 |
+
if pdu.breaker_tripped:
|
| 178 |
+
lines.append(">> FAULT: Breaker tripped!")
|
| 179 |
+
elif pdu.overload:
|
| 180 |
+
lines.append(">> WARNING: PDU overloaded.")
|
| 181 |
+
else:
|
| 182 |
+
lines.append(">> No faults detected. PDU operating normally.")
|
| 183 |
+
return CommandResult(True, "\n".join(lines), "diagnose", target)
|
| 184 |
+
|
| 185 |
return CommandResult(False, f"Unit '{target}' not found.", "diagnose", target)
|
| 186 |
|
| 187 |
|
scenarios/power_scenarios.py
CHANGED
|
@@ -215,7 +215,12 @@ class GeneratorTestProtocol(Scenario):
|
|
| 215 |
if cmd.startswith("start_generator"):
|
| 216 |
self._started = True
|
| 217 |
if self._started and cmd.startswith("diagnose") and "gen" in cmd:
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
if cmd.startswith("stop_generator"):
|
| 220 |
if self._started and self._verified:
|
| 221 |
self._stopped = True
|
|
@@ -289,7 +294,7 @@ class GeneratorTestProtocol(Scenario):
|
|
| 289 |
|
| 290 |
@property
|
| 291 |
def step_budget(self) -> int:
|
| 292 |
-
return
|
| 293 |
|
| 294 |
@property
|
| 295 |
def alert_message(self) -> str:
|
|
@@ -319,8 +324,9 @@ class GeneratorTestProtocol(Scenario):
|
|
| 319 |
|
| 320 |
@property
|
| 321 |
def game_time_per_step_s(self) -> float:
|
| 322 |
-
# Generator startup is ~17s
|
| 323 |
-
|
|
|
|
| 324 |
|
| 325 |
|
| 326 |
# ===========================================================================
|
|
@@ -346,13 +352,16 @@ class PowerFailureCascade(Scenario):
|
|
| 346 |
"""
|
| 347 |
|
| 348 |
_CONSECUTIVE_STABLE_STEPS = 2
|
|
|
|
| 349 |
|
| 350 |
def __init__(self) -> None:
|
| 351 |
super().__init__()
|
| 352 |
self._stable_count = 0
|
|
|
|
| 353 |
|
| 354 |
def reset_state(self) -> None:
|
| 355 |
self._stable_count = 0
|
|
|
|
| 356 |
|
| 357 |
def configure(self, base_config: DatacenterConfig) -> DatacenterConfig:
|
| 358 |
# Extend generator warmup to make it more challenging
|
|
@@ -378,6 +387,12 @@ class PowerFailureCascade(Scenario):
|
|
| 378 |
) -> ScenarioResult:
|
| 379 |
dc = thermal_sim.state
|
| 380 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
# Check temperatures
|
| 382 |
all_within_allowable = True
|
| 383 |
max_over = 0.0
|
|
@@ -404,7 +419,15 @@ class PowerFailureCascade(Scenario):
|
|
| 404 |
else:
|
| 405 |
self._stable_count = 0
|
| 406 |
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
|
| 409 |
# Reward shaping
|
| 410 |
scenario_reward = 0.0
|
|
|
|
| 215 |
if cmd.startswith("start_generator"):
|
| 216 |
self._started = True
|
| 217 |
if self._started and cmd.startswith("diagnose") and "gen" in cmd:
|
| 218 |
+
# Only count as verified if generator is actually running
|
| 219 |
+
if power_sim and power_sim.state.generator.state in (
|
| 220 |
+
GeneratorState.READY, GeneratorState.LOADED,
|
| 221 |
+
GeneratorState.WARMING, GeneratorState.CRANKING,
|
| 222 |
+
):
|
| 223 |
+
self._verified = True
|
| 224 |
if cmd.startswith("stop_generator"):
|
| 225 |
if self._started and self._verified:
|
| 226 |
self._stopped = True
|
|
|
|
| 294 |
|
| 295 |
@property
|
| 296 |
def step_budget(self) -> int:
|
| 297 |
+
return 15
|
| 298 |
|
| 299 |
@property
|
| 300 |
def alert_message(self) -> str:
|
|
|
|
| 324 |
|
| 325 |
@property
|
| 326 |
def game_time_per_step_s(self) -> float:
|
| 327 |
+
# Generator startup is ~17s. At 10s/step the agent can observe
|
| 328 |
+
# intermediate states (CRANKING, WARMING) across 2 steps.
|
| 329 |
+
return 10.0
|
| 330 |
|
| 331 |
|
| 332 |
# ===========================================================================
|
|
|
|
| 352 |
"""
|
| 353 |
|
| 354 |
_CONSECUTIVE_STABLE_STEPS = 2
|
| 355 |
+
_MIN_STEPS_BEFORE_RESOLUTION = 4 # Hard scenario needs investigation
|
| 356 |
|
| 357 |
def __init__(self) -> None:
|
| 358 |
super().__init__()
|
| 359 |
self._stable_count = 0
|
| 360 |
+
self._diagnosed_ups = False
|
| 361 |
|
| 362 |
def reset_state(self) -> None:
|
| 363 |
self._stable_count = 0
|
| 364 |
+
self._diagnosed_ups = False
|
| 365 |
|
| 366 |
def configure(self, base_config: DatacenterConfig) -> DatacenterConfig:
|
| 367 |
# Extend generator warmup to make it more challenging
|
|
|
|
| 387 |
) -> ScenarioResult:
|
| 388 |
dc = thermal_sim.state
|
| 389 |
|
| 390 |
+
# Track if agent diagnosed UPS
|
| 391 |
+
cmd_parts = action_command.strip().split()
|
| 392 |
+
if (len(cmd_parts) >= 2 and cmd_parts[0].lower() == "diagnose"
|
| 393 |
+
and "ups" in cmd_parts[1].lower()):
|
| 394 |
+
self._diagnosed_ups = True
|
| 395 |
+
|
| 396 |
# Check temperatures
|
| 397 |
all_within_allowable = True
|
| 398 |
max_over = 0.0
|
|
|
|
| 419 |
else:
|
| 420 |
self._stable_count = 0
|
| 421 |
|
| 422 |
+
# Resolution requires:
|
| 423 |
+
# 1. Agent diagnosed UPS status (proper incident response)
|
| 424 |
+
# 2. Stable for N consecutive steps
|
| 425 |
+
# 3. At least _MIN_STEPS_BEFORE_RESOLUTION steps taken
|
| 426 |
+
resolved = (
|
| 427 |
+
self._stable_count >= self._CONSECUTIVE_STABLE_STEPS
|
| 428 |
+
and self._diagnosed_ups
|
| 429 |
+
and step >= self._MIN_STEPS_BEFORE_RESOLUTION
|
| 430 |
+
)
|
| 431 |
|
| 432 |
# Reward shaping
|
| 433 |
scenario_reward = 0.0
|
scenarios/thermal_scenarios.py
CHANGED
|
@@ -161,16 +161,19 @@ class ThermalEventResponse(Scenario):
|
|
| 161 |
|
| 162 |
_FAILED_UNIT = "CRAC-3"
|
| 163 |
_CONSECUTIVE_STABLE_STEPS = 2
|
|
|
|
| 164 |
|
| 165 |
def __init__(self) -> None:
|
| 166 |
super().__init__()
|
| 167 |
self._stable_count = 0
|
|
|
|
| 168 |
|
| 169 |
def reset_state(self) -> None:
|
| 170 |
self._stable_count = 0
|
|
|
|
| 171 |
|
| 172 |
def configure(self, base_config: DatacenterConfig) -> DatacenterConfig:
|
| 173 |
-
return base_config
|
| 174 |
|
| 175 |
def inject_fault(
|
| 176 |
self,
|
|
@@ -189,6 +192,12 @@ class ThermalEventResponse(Scenario):
|
|
| 189 |
) -> ScenarioResult:
|
| 190 |
dc = thermal_sim.state
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
# Check if all zones within recommended
|
| 193 |
all_within_recommended = True
|
| 194 |
max_over = 0.0
|
|
@@ -205,7 +214,15 @@ class ThermalEventResponse(Scenario):
|
|
| 205 |
else:
|
| 206 |
self._stable_count = 0
|
| 207 |
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
# Scenario reward: penalty proportional to temperature overshoot
|
| 211 |
scenario_reward = -max_over * 0.5 if max_over > 0 else 0.1
|
|
@@ -304,6 +321,7 @@ class CRACFailureCascade(Scenario):
|
|
| 304 |
("CRAC-3", CRACFaultType.FAN),
|
| 305 |
]
|
| 306 |
_CONSECUTIVE_STABLE_STEPS = 2
|
|
|
|
| 307 |
|
| 308 |
def __init__(self) -> None:
|
| 309 |
super().__init__()
|
|
@@ -348,19 +366,25 @@ class CRACFailureCascade(Scenario):
|
|
| 348 |
else:
|
| 349 |
self._stable_count = 0
|
| 350 |
|
| 351 |
-
resolved = self._stable_count >= self._CONSECUTIVE_STABLE_STEPS
|
| 352 |
-
|
| 353 |
-
# Heavy penalty for being over allowable
|
| 354 |
-
scenario_reward = -max_over * 2.0 if max_over > 0 else 0.2
|
| 355 |
-
|
| 356 |
-
procedure_reward = self.check_procedure(action_command, action_history)
|
| 357 |
-
|
| 358 |
# Bonus for diagnosing both units
|
| 359 |
diagnosed_units = set()
|
| 360 |
for h in action_history:
|
| 361 |
parts = h.strip().split()
|
| 362 |
if len(parts) >= 2 and parts[0].lower() == "diagnose":
|
| 363 |
diagnosed_units.add(parts[1].upper())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
if "CRAC-1" in diagnosed_units and "CRAC-3" in diagnosed_units:
|
| 365 |
procedure_reward += 0.2 # Bonus for thorough diagnosis
|
| 366 |
|
|
|
|
| 161 |
|
| 162 |
_FAILED_UNIT = "CRAC-3"
|
| 163 |
_CONSECUTIVE_STABLE_STEPS = 2
|
| 164 |
+
_MIN_STEPS_BEFORE_RESOLUTION = 5 # Agent must take at least 5 actions
|
| 165 |
|
| 166 |
def __init__(self) -> None:
|
| 167 |
super().__init__()
|
| 168 |
self._stable_count = 0
|
| 169 |
+
self._diagnosed_fault = False # Must diagnose the faulty unit
|
| 170 |
|
| 171 |
def reset_state(self) -> None:
|
| 172 |
self._stable_count = 0
|
| 173 |
+
self._diagnosed_fault = False
|
| 174 |
|
| 175 |
def configure(self, base_config: DatacenterConfig) -> DatacenterConfig:
|
| 176 |
+
return base_config
|
| 177 |
|
| 178 |
def inject_fault(
|
| 179 |
self,
|
|
|
|
| 192 |
) -> ScenarioResult:
|
| 193 |
dc = thermal_sim.state
|
| 194 |
|
| 195 |
+
# Track if agent diagnosed the faulty unit
|
| 196 |
+
cmd_parts = action_command.strip().split()
|
| 197 |
+
if (len(cmd_parts) >= 2 and cmd_parts[0].lower() == "diagnose"
|
| 198 |
+
and cmd_parts[1].upper() == self._FAILED_UNIT):
|
| 199 |
+
self._diagnosed_fault = True
|
| 200 |
+
|
| 201 |
# Check if all zones within recommended
|
| 202 |
all_within_recommended = True
|
| 203 |
max_over = 0.0
|
|
|
|
| 214 |
else:
|
| 215 |
self._stable_count = 0
|
| 216 |
|
| 217 |
+
# Resolution requires:
|
| 218 |
+
# 1. Agent diagnosed the faulty unit (proper procedure)
|
| 219 |
+
# 2. Temps stable for N consecutive steps
|
| 220 |
+
# 3. At least _MIN_STEPS_BEFORE_RESOLUTION steps taken
|
| 221 |
+
resolved = (
|
| 222 |
+
self._diagnosed_fault
|
| 223 |
+
and self._stable_count >= self._CONSECUTIVE_STABLE_STEPS
|
| 224 |
+
and step >= self._MIN_STEPS_BEFORE_RESOLUTION
|
| 225 |
+
)
|
| 226 |
|
| 227 |
# Scenario reward: penalty proportional to temperature overshoot
|
| 228 |
scenario_reward = -max_over * 0.5 if max_over > 0 else 0.1
|
|
|
|
| 321 |
("CRAC-3", CRACFaultType.FAN),
|
| 322 |
]
|
| 323 |
_CONSECUTIVE_STABLE_STEPS = 2
|
| 324 |
+
_MIN_STEPS_BEFORE_RESOLUTION = 5 # Hard scenario needs investigation time
|
| 325 |
|
| 326 |
def __init__(self) -> None:
|
| 327 |
super().__init__()
|
|
|
|
| 366 |
else:
|
| 367 |
self._stable_count = 0
|
| 368 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
# Bonus for diagnosing both units
|
| 370 |
diagnosed_units = set()
|
| 371 |
for h in action_history:
|
| 372 |
parts = h.strip().split()
|
| 373 |
if len(parts) >= 2 and parts[0].lower() == "diagnose":
|
| 374 |
diagnosed_units.add(parts[1].upper())
|
| 375 |
+
|
| 376 |
+
resolved = (
|
| 377 |
+
self._stable_count >= self._CONSECUTIVE_STABLE_STEPS
|
| 378 |
+
and "CRAC-1" in diagnosed_units
|
| 379 |
+
and "CRAC-3" in diagnosed_units
|
| 380 |
+
and step >= self._MIN_STEPS_BEFORE_RESOLUTION
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
# Heavy penalty for being over allowable
|
| 384 |
+
scenario_reward = -max_over * 2.0 if max_over > 0 else 0.2
|
| 385 |
+
|
| 386 |
+
procedure_reward = self.check_procedure(action_command, action_history)
|
| 387 |
+
|
| 388 |
if "CRAC-1" in diagnosed_units and "CRAC-3" in diagnosed_units:
|
| 389 |
procedure_reward += 0.2 # Bonus for thorough diagnosis
|
| 390 |
|
server/dc_ops_env_environment.py
CHANGED
|
@@ -179,6 +179,7 @@ class DcOpsEnvironment(Environment):
|
|
| 179 |
|
| 180 |
# Initialize reward function with scenario-type-aware weights
|
| 181 |
self._reward_fn = RewardFunction(scenario_type=self._scenario_type)
|
|
|
|
| 182 |
|
| 183 |
# Initialize simulations
|
| 184 |
self._thermal_sim = ThermalSimulation(self._config)
|
|
@@ -261,13 +262,17 @@ class DcOpsEnvironment(Environment):
|
|
| 261 |
reward=reward,
|
| 262 |
)
|
| 263 |
|
| 264 |
-
# 2.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
thermal_alarms, power_alarms = self._advance_simulation()
|
| 266 |
|
| 267 |
-
#
|
| 268 |
self._update_alert(thermal_alarms, power_alarms)
|
| 269 |
|
| 270 |
-
#
|
| 271 |
scenario_result = None
|
| 272 |
if self._scenario:
|
| 273 |
scenario_result = self._scenario.evaluate_step(
|
|
@@ -276,7 +281,7 @@ class DcOpsEnvironment(Environment):
|
|
| 276 |
self._state.step_count,
|
| 277 |
)
|
| 278 |
|
| 279 |
-
#
|
| 280 |
components = self._reward_fn.compute(
|
| 281 |
self._thermal_sim, self._power_sim, cmd_result,
|
| 282 |
action.command, self._action_history, scenario_result,
|
|
@@ -285,10 +290,10 @@ class DcOpsEnvironment(Environment):
|
|
| 285 |
|
| 286 |
self._cumulative_reward += reward
|
| 287 |
|
| 288 |
-
#
|
| 289 |
self._check_termination(thermal_alarms, power_alarms)
|
| 290 |
|
| 291 |
-
#
|
| 292 |
if scenario_result and scenario_result.resolved and not self._done:
|
| 293 |
self._done = True
|
| 294 |
# Speed bonus: fraction of budget remaining
|
|
|
|
| 179 |
|
| 180 |
# Initialize reward function with scenario-type-aware weights
|
| 181 |
self._reward_fn = RewardFunction(scenario_type=self._scenario_type)
|
| 182 |
+
self._reward_fn.reset() # Defensive: ensure clean state
|
| 183 |
|
| 184 |
# Initialize simulations
|
| 185 |
self._thermal_sim = ThermalSimulation(self._config)
|
|
|
|
| 262 |
reward=reward,
|
| 263 |
)
|
| 264 |
|
| 265 |
+
# 2. Handle acknowledge_alarm — clear alert before new alarms overwrite
|
| 266 |
+
if cmd_result.command_name == "acknowledge_alarm" and cmd_result.success:
|
| 267 |
+
self._alert = ""
|
| 268 |
+
|
| 269 |
+
# 3. Advance simulation
|
| 270 |
thermal_alarms, power_alarms = self._advance_simulation()
|
| 271 |
|
| 272 |
+
# 4. Build alert from alarms (only new critical/warning alarms override)
|
| 273 |
self._update_alert(thermal_alarms, power_alarms)
|
| 274 |
|
| 275 |
+
# 5. Evaluate scenario (before reward, so progress is available)
|
| 276 |
scenario_result = None
|
| 277 |
if self._scenario:
|
| 278 |
scenario_result = self._scenario.evaluate_step(
|
|
|
|
| 281 |
self._state.step_count,
|
| 282 |
)
|
| 283 |
|
| 284 |
+
# 6. Compute reward via RewardFunction
|
| 285 |
components = self._reward_fn.compute(
|
| 286 |
self._thermal_sim, self._power_sim, cmd_result,
|
| 287 |
action.command, self._action_history, scenario_result,
|
|
|
|
| 290 |
|
| 291 |
self._cumulative_reward += reward
|
| 292 |
|
| 293 |
+
# 7. Check termination
|
| 294 |
self._check_termination(thermal_alarms, power_alarms)
|
| 295 |
|
| 296 |
+
# 7b. Scenario resolution
|
| 297 |
if scenario_result and scenario_result.resolved and not self._done:
|
| 298 |
self._done = True
|
| 299 |
# Speed bonus: fraction of budget remaining
|
server/static/index.html
CHANGED
|
@@ -219,6 +219,106 @@ body{background:var(--bg);color:var(--text);font-family:var(--font-sans);min-hei
|
|
| 219 |
@media(max-width:1100px){.mobile-toggles{display:flex}}
|
| 220 |
.toggle-btn{padding:0.25rem 0.625rem;background:transparent;border:1px solid var(--border);border-radius:var(--radius);color:var(--text-dim);font-size:0.7rem;cursor:pointer;font-family:var(--font-sans);transition:all 0.15s}
|
| 221 |
.toggle-btn.active{border-color:var(--accent);color:var(--accent);background:rgba(59,130,246,0.08)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
</style>
|
| 223 |
</head>
|
| 224 |
<body>
|
|
@@ -233,13 +333,18 @@ body{background:var(--bg);color:var(--text);font-family:var(--font-sans);min-hei
|
|
| 233 |
<span id="statusText">Disconnected</span>
|
| 234 |
</div>
|
| 235 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
<div class="mobile-toggles">
|
| 237 |
<button class="toggle-btn active" id="toggleScenarios" onclick="togglePanel('sidebar')">Scenarios</button>
|
| 238 |
<button class="toggle-btn active" id="toggleMetrics" onclick="togglePanel('right-panel')">Metrics</button>
|
| 239 |
</div>
|
| 240 |
</header>
|
| 241 |
|
| 242 |
-
<!-- Main Layout -->
|
|
|
|
| 243 |
<div class="main">
|
| 244 |
<!-- Left: Scenario Browser -->
|
| 245 |
<aside class="sidebar" id="sidebar">
|
|
@@ -346,13 +451,6 @@ Pick a scenario to start
|
|
| 346 |
<button id="sendBtn" class="btn btn-primary" onclick="sendCommand()" disabled>Send</button>
|
| 347 |
</div>
|
| 348 |
<div class="quick-actions" id="quickActions">
|
| 349 |
-
<button class="quick-btn" disabled onclick="quickCmd('check_status')">check_status</button>
|
| 350 |
-
<button class="quick-btn" disabled onclick="quickCmd('diagnose CRAC-1')">diagnose CRAC-1</button>
|
| 351 |
-
<button class="quick-btn" disabled onclick="quickCmd('diagnose CRAC-3')">diagnose CRAC-3</button>
|
| 352 |
-
<button class="quick-btn" disabled onclick="quickCmd('acknowledge_alarm')">ack_alarm</button>
|
| 353 |
-
<button class="quick-btn" disabled onclick="quickCmd('start_generator')">start_gen</button>
|
| 354 |
-
<button class="quick-btn" disabled onclick="quickCmd('wait')">wait</button>
|
| 355 |
-
<button class="quick-btn" disabled onclick="quickCmd('escalate')">escalate</button>
|
| 356 |
</div>
|
| 357 |
</div>
|
| 358 |
</div>
|
|
@@ -424,6 +522,350 @@ Pick a scenario to start
|
|
| 424 |
</div>
|
| 425 |
</aside>
|
| 426 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
</div>
|
| 428 |
|
| 429 |
<script>
|
|
@@ -449,6 +891,39 @@ const SCENARIOS = {
|
|
| 449 |
B4: { name: 'Power Failure Cascade', type: 'power', diff: 'Hard' },
|
| 450 |
};
|
| 451 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
// ─── WebSocket connection ────────────────────────────────────────────
|
| 453 |
function connectWebSocket() {
|
| 454 |
return new Promise((resolve, reject) => {
|
|
@@ -523,6 +998,7 @@ function selectScenario(id) {
|
|
| 523 |
const btn = document.getElementById('startBtn');
|
| 524 |
btn.disabled = false;
|
| 525 |
btn.textContent = `Start ${id}: ${SCENARIOS[id].name}`;
|
|
|
|
| 526 |
}
|
| 527 |
|
| 528 |
function togglePanel(id) {
|
|
@@ -536,6 +1012,8 @@ function setControlsEnabled(enabled) {
|
|
| 536 |
document.getElementById('commandInput').disabled = !enabled;
|
| 537 |
document.getElementById('sendBtn').disabled = !enabled;
|
| 538 |
document.querySelectorAll('.quick-btn').forEach(b => b.disabled = !enabled);
|
|
|
|
|
|
|
| 539 |
}
|
| 540 |
|
| 541 |
function quickCmd(cmd) {
|
|
@@ -893,6 +1371,57 @@ function updateRewardHistory() {
|
|
| 893 |
}
|
| 894 |
}
|
| 895 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 896 |
// ─── Health check ────────────────────────────────────────────────────
|
| 897 |
async function checkHealth() {
|
| 898 |
try {
|
|
@@ -906,6 +1435,7 @@ async function checkHealth() {
|
|
| 906 |
|
| 907 |
// ─── Init ────────────────────────────────────────────────────────────
|
| 908 |
checkHealth();
|
|
|
|
| 909 |
</script>
|
| 910 |
</body>
|
| 911 |
</html>
|
|
|
|
| 219 |
@media(max-width:1100px){.mobile-toggles{display:flex}}
|
| 220 |
.toggle-btn{padding:0.25rem 0.625rem;background:transparent;border:1px solid var(--border);border-radius:var(--radius);color:var(--text-dim);font-size:0.7rem;cursor:pointer;font-family:var(--font-sans);transition:all 0.15s}
|
| 221 |
.toggle-btn.active{border-color:var(--accent);color:var(--accent);background:rgba(59,130,246,0.08)}
|
| 222 |
+
|
| 223 |
+
/* ─── Tab Navigation ─── */
|
| 224 |
+
.header-tabs{display:flex;gap:0.25rem;background:var(--terminal-bg);border-radius:var(--radius);padding:0.2rem}
|
| 225 |
+
.tab-btn{padding:0.375rem 1rem;border-radius:6px;border:none;background:transparent;color:var(--text-dim);font-size:0.78rem;font-weight:600;font-family:var(--font-sans);cursor:pointer;transition:all 0.2s;white-space:nowrap;position:relative}
|
| 226 |
+
.tab-btn:hover{color:var(--text)}
|
| 227 |
+
.tab-btn.active{background:var(--accent);color:#fff;box-shadow:0 1px 4px rgba(59,130,246,0.3)}
|
| 228 |
+
.tab-content{display:none;min-height:0}
|
| 229 |
+
.tab-content.active{display:block;overflow:hidden;min-height:0}
|
| 230 |
+
.tab-content.active > .main{height:100%}
|
| 231 |
+
|
| 232 |
+
/* ─── Guide Page ─── */
|
| 233 |
+
.guide-page{display:none;overflow-y:auto;padding:2rem 1.5rem;background:var(--bg)}
|
| 234 |
+
.guide-page.active{display:block}
|
| 235 |
+
.guide-inner{max-width:920px;margin:0 auto}
|
| 236 |
+
.guide-hero{text-align:center;padding:2.5rem 1rem 2rem;margin-bottom:2rem}
|
| 237 |
+
.guide-hero h1{font-size:2rem;font-weight:800;letter-spacing:-0.03em;margin-bottom:0.5rem}
|
| 238 |
+
.guide-hero h1 span{color:var(--accent)}
|
| 239 |
+
.guide-hero p{color:var(--text-dim);font-size:0.95rem;max-width:560px;margin:0 auto;line-height:1.7}
|
| 240 |
+
|
| 241 |
+
/* Guide sections */
|
| 242 |
+
.guide-section{margin-bottom:2.5rem}
|
| 243 |
+
.guide-section-header{display:flex;align-items:center;gap:0.75rem;margin-bottom:1.25rem;padding-bottom:0.75rem;border-bottom:1px solid var(--border)}
|
| 244 |
+
.guide-section-icon{width:36px;height:36px;border-radius:var(--radius);display:flex;align-items:center;justify-content:center;font-size:1.1rem;flex-shrink:0}
|
| 245 |
+
.guide-section-icon.blue{background:rgba(59,130,246,0.12);color:var(--accent)}
|
| 246 |
+
.guide-section-icon.green{background:rgba(34,197,94,0.12);color:var(--green)}
|
| 247 |
+
.guide-section-icon.orange{background:rgba(249,115,22,0.12);color:var(--orange)}
|
| 248 |
+
.guide-section-icon.red{background:rgba(239,68,68,0.12);color:var(--red)}
|
| 249 |
+
.guide-section-icon.cyan{background:rgba(6,182,212,0.12);color:var(--cyan)}
|
| 250 |
+
.guide-section-icon.yellow{background:rgba(234,179,8,0.12);color:var(--yellow)}
|
| 251 |
+
.guide-section h2{font-size:1.15rem;font-weight:700;letter-spacing:-0.01em}
|
| 252 |
+
.guide-section p,.guide-section li{font-size:0.85rem;color:var(--text-dim);line-height:1.75}
|
| 253 |
+
.guide-section strong{color:var(--text)}
|
| 254 |
+
.guide-section ul,.guide-section ol{padding-left:1.25rem;margin:0.75rem 0}
|
| 255 |
+
.guide-section li{margin-bottom:0.35rem}
|
| 256 |
+
|
| 257 |
+
/* Guide cards grid */
|
| 258 |
+
.guide-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem}
|
| 259 |
+
.guide-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:1rem 1.25rem;transition:border-color 0.2s}
|
| 260 |
+
.guide-card:hover{border-color:var(--border-active)}
|
| 261 |
+
.guide-card .gc-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem}
|
| 262 |
+
.guide-card .gc-id{font-family:var(--font-mono);font-weight:800;font-size:0.9rem;color:var(--accent)}
|
| 263 |
+
.guide-card .gc-diff{font-size:0.6rem;font-weight:700;padding:0.1rem 0.5rem;border-radius:999px;text-transform:uppercase;letter-spacing:0.05em}
|
| 264 |
+
.guide-card .gc-name{font-weight:600;font-size:0.85rem;margin-bottom:0.375rem;color:var(--text)}
|
| 265 |
+
.guide-card .gc-desc{font-size:0.78rem;color:var(--text-dim);line-height:1.6;margin-bottom:0.625rem}
|
| 266 |
+
.guide-card .gc-hint{font-size:0.72rem;color:var(--cyan);font-family:var(--font-mono);background:var(--terminal-bg);padding:0.375rem 0.625rem;border-radius:4px;border:1px solid var(--border)}
|
| 267 |
+
.guide-card .gc-hint strong{color:var(--text);font-size:0.68rem;text-transform:uppercase;letter-spacing:0.05em;display:block;margin-bottom:0.2rem}
|
| 268 |
+
|
| 269 |
+
/* Guide table */
|
| 270 |
+
.guide-table{width:100%;border-collapse:collapse;font-size:0.78rem;margin:0.75rem 0;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
|
| 271 |
+
.guide-table thead{background:var(--bg-card)}
|
| 272 |
+
.guide-table th{text-align:left;padding:0.625rem 0.875rem;font-weight:600;color:var(--text);border-bottom:1px solid var(--border);font-size:0.72rem;text-transform:uppercase;letter-spacing:0.05em}
|
| 273 |
+
.guide-table td{padding:0.5rem 0.875rem;border-bottom:1px solid var(--border);color:var(--text-dim);vertical-align:top}
|
| 274 |
+
.guide-table tr:last-child td{border-bottom:none}
|
| 275 |
+
.guide-table tbody tr:hover{background:var(--bg-card-hover)}
|
| 276 |
+
.guide-table code{font-family:var(--font-mono);font-size:0.75rem;color:var(--cyan);background:var(--terminal-bg);padding:0.1rem 0.375rem;border-radius:3px}
|
| 277 |
+
.guide-table .tag{display:inline-block;font-size:0.6rem;font-weight:700;padding:0.1rem 0.4rem;border-radius:999px;text-transform:uppercase}
|
| 278 |
+
.tag-pos{background:var(--green-dim);color:var(--green)}
|
| 279 |
+
.tag-neg{background:var(--red-dim);color:var(--red)}
|
| 280 |
+
.tag-range{background:rgba(6,182,212,0.12);color:var(--cyan)}
|
| 281 |
+
|
| 282 |
+
/* Guide code block */
|
| 283 |
+
.guide-code{background:var(--terminal-bg);border:1px solid var(--border);border-radius:var(--radius);padding:0.875rem 1rem;font-family:var(--font-mono);font-size:0.75rem;line-height:1.6;color:var(--green);overflow-x:auto;margin:0.75rem 0;white-space:pre}
|
| 284 |
+
|
| 285 |
+
/* Reward component cards */
|
| 286 |
+
.reward-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:0.75rem;margin:1rem 0}
|
| 287 |
+
.reward-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:1rem 1.25rem}
|
| 288 |
+
.reward-card h4{font-size:0.85rem;font-weight:700;margin-bottom:0.375rem;display:flex;align-items:center;gap:0.5rem}
|
| 289 |
+
.reward-card h4 .rc-range{font-size:0.65rem;font-family:var(--font-mono);color:var(--text-muted);font-weight:500}
|
| 290 |
+
.reward-card p{font-size:0.78rem;color:var(--text-dim);line-height:1.6}
|
| 291 |
+
.reward-card .rc-formula{font-family:var(--font-mono);font-size:0.72rem;color:var(--cyan);background:var(--terminal-bg);padding:0.35rem 0.625rem;border-radius:4px;margin-top:0.5rem;border:1px solid var(--border);display:inline-block}
|
| 292 |
+
|
| 293 |
+
/* Guide weight profile inline table */
|
| 294 |
+
.weight-profiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:0.75rem;margin:1rem 0}
|
| 295 |
+
.weight-profile{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:0.875rem 1rem}
|
| 296 |
+
.weight-profile h4{font-size:0.8rem;font-weight:700;margin-bottom:0.625rem;color:var(--accent)}
|
| 297 |
+
.weight-bar-row{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.35rem;font-size:0.72rem}
|
| 298 |
+
.weight-bar-label{width:72px;color:var(--text-dim);flex-shrink:0}
|
| 299 |
+
.weight-bar-track{flex:1;height:8px;background:var(--terminal-bg);border-radius:4px;overflow:hidden}
|
| 300 |
+
.weight-bar-fill{height:100%;border-radius:4px;background:var(--accent);transition:width 0.3s}
|
| 301 |
+
.weight-bar-val{width:32px;text-align:right;font-family:var(--font-mono);color:var(--text-muted);font-weight:600;flex-shrink:0}
|
| 302 |
+
|
| 303 |
+
/* ASHRAE visual */
|
| 304 |
+
.ashrae-visual{display:flex;flex-direction:column;gap:0.75rem;margin:1rem 0}
|
| 305 |
+
.ashrae-row{display:flex;align-items:center;gap:0.75rem;font-size:0.78rem}
|
| 306 |
+
.ashrae-label{width:32px;font-family:var(--font-mono);font-weight:700;color:var(--accent);flex-shrink:0}
|
| 307 |
+
.ashrae-bar-container{flex:1;position:relative;height:24px}
|
| 308 |
+
.ashrae-bar-bg{position:absolute;inset:0;background:var(--terminal-bg);border-radius:4px;border:1px solid var(--border)}
|
| 309 |
+
.ashrae-bar-rec{position:absolute;height:100%;background:rgba(34,197,94,0.2);border:1px solid rgba(34,197,94,0.4);border-radius:4px}
|
| 310 |
+
.ashrae-bar-allow{position:absolute;height:100%;background:rgba(234,179,8,0.1);border:1px solid rgba(234,179,8,0.25);border-radius:4px}
|
| 311 |
+
.ashrae-bar-label{position:absolute;top:50%;transform:translateY(-50%);font-size:0.65rem;font-family:var(--font-mono);color:var(--text-dim)}
|
| 312 |
+
|
| 313 |
+
@media(max-width:640px){
|
| 314 |
+
.guide-page{padding:1rem 0.75rem}
|
| 315 |
+
.guide-hero h1{font-size:1.5rem}
|
| 316 |
+
.guide-cards{grid-template-columns:1fr}
|
| 317 |
+
.reward-grid{grid-template-columns:1fr}
|
| 318 |
+
.weight-profiles{grid-template-columns:1fr}
|
| 319 |
+
.guide-table{font-size:0.7rem}
|
| 320 |
+
.guide-table th,.guide-table td{padding:0.375rem 0.5rem}
|
| 321 |
+
}
|
| 322 |
</style>
|
| 323 |
</head>
|
| 324 |
<body>
|
|
|
|
| 333 |
<span id="statusText">Disconnected</span>
|
| 334 |
</div>
|
| 335 |
</div>
|
| 336 |
+
<div class="header-tabs">
|
| 337 |
+
<button class="tab-btn active" data-tab="console" onclick="switchTab('console')">Console</button>
|
| 338 |
+
<button class="tab-btn" data-tab="guide" onclick="switchTab('guide')">Guide</button>
|
| 339 |
+
</div>
|
| 340 |
<div class="mobile-toggles">
|
| 341 |
<button class="toggle-btn active" id="toggleScenarios" onclick="togglePanel('sidebar')">Scenarios</button>
|
| 342 |
<button class="toggle-btn active" id="toggleMetrics" onclick="togglePanel('right-panel')">Metrics</button>
|
| 343 |
</div>
|
| 344 |
</header>
|
| 345 |
|
| 346 |
+
<!-- Main Layout (Console Tab) -->
|
| 347 |
+
<div class="tab-content active" id="tabConsole">
|
| 348 |
<div class="main">
|
| 349 |
<!-- Left: Scenario Browser -->
|
| 350 |
<aside class="sidebar" id="sidebar">
|
|
|
|
| 451 |
<button id="sendBtn" class="btn btn-primary" onclick="sendCommand()" disabled>Send</button>
|
| 452 |
</div>
|
| 453 |
<div class="quick-actions" id="quickActions">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
</div>
|
| 455 |
</div>
|
| 456 |
</div>
|
|
|
|
| 522 |
</div>
|
| 523 |
</aside>
|
| 524 |
</div>
|
| 525 |
+
</div><!-- /tabConsole -->
|
| 526 |
+
|
| 527 |
+
<!-- Guide Tab -->
|
| 528 |
+
<div class="guide-page" id="tabGuide">
|
| 529 |
+
<div class="guide-inner">
|
| 530 |
+
|
| 531 |
+
<div class="guide-hero">
|
| 532 |
+
<h1>DC<span>-Ops</span> Operations Guide</h1>
|
| 533 |
+
<p>A comprehensive reference for operating the physics-based datacenter simulation. Master thermal management, power systems, and incident response.</p>
|
| 534 |
+
</div>
|
| 535 |
+
|
| 536 |
+
<!-- ── Getting Started ── -->
|
| 537 |
+
<div class="guide-section">
|
| 538 |
+
<div class="guide-section-header">
|
| 539 |
+
<div class="guide-section-icon blue">▶</div>
|
| 540 |
+
<h2>Getting Started</h2>
|
| 541 |
+
</div>
|
| 542 |
+
<ol>
|
| 543 |
+
<li><strong>Select a scenario</strong> from the sidebar — each presents a unique datacenter challenge.</li>
|
| 544 |
+
<li><strong>Choose a facility config</strong> (Default 160 kW, Small 80 kW, or Large 600 kW).</li>
|
| 545 |
+
<li>Click <strong>Start</strong> to begin the episode. You'll see the NOC dashboard.</li>
|
| 546 |
+
<li><strong>Issue commands</strong> in the command bar — diagnose equipment, adjust setpoints, manage power.</li>
|
| 547 |
+
<li>Each command advances simulation time. You have a limited <strong>step budget</strong>.</li>
|
| 548 |
+
<li>Maximize your <strong>cumulative reward</strong> by resolving the scenario efficiently.</li>
|
| 549 |
+
</ol>
|
| 550 |
+
<p style="margin-top:0.75rem"><strong>Pro tip:</strong> Always <code style="color:var(--cyan);background:var(--terminal-bg);padding:0.1rem 0.35rem;border-radius:3px;font-family:var(--font-mono);font-size:0.8rem">diagnose</code> before making changes — the reward system gives a bonus for proper diagnostic procedures and penalizes blind interventions.</p>
|
| 551 |
+
</div>
|
| 552 |
+
|
| 553 |
+
<!-- ── Scenarios ── -->
|
| 554 |
+
<div class="guide-section">
|
| 555 |
+
<div class="guide-section-header">
|
| 556 |
+
<div class="guide-section-icon orange">⚡</div>
|
| 557 |
+
<h2>Scenarios</h2>
|
| 558 |
+
</div>
|
| 559 |
+
<p>Six operational scenarios across two categories and three difficulty levels:</p>
|
| 560 |
+
|
| 561 |
+
<h3 style="font-size:0.85rem;font-weight:700;margin:1.25rem 0 0.625rem;color:var(--text)">Thermal (Category A)</h3>
|
| 562 |
+
<div class="guide-cards">
|
| 563 |
+
<div class="guide-card">
|
| 564 |
+
<div class="gc-header">
|
| 565 |
+
<span class="gc-id">A1</span>
|
| 566 |
+
<span class="gc-diff" style="background:var(--green-dim);color:var(--green)">Easy</span>
|
| 567 |
+
</div>
|
| 568 |
+
<div class="gc-name">Cooling Setpoint Optimization</div>
|
| 569 |
+
<div class="gc-desc">CRACs are overcooling at 15°C — wasting energy. Optimize setpoints for efficiency while keeping all zones within ASHRAE recommended range (18–27°C).</div>
|
| 570 |
+
<div class="gc-hint"><strong>Strategy</strong>Raise setpoints to ~22°C. Monitor temps. Target PUE < 1.6. Check that all zones stay in recommended range for 2+ steps.</div>
|
| 571 |
+
</div>
|
| 572 |
+
<div class="guide-card">
|
| 573 |
+
<div class="gc-header">
|
| 574 |
+
<span class="gc-id">A2</span>
|
| 575 |
+
<span class="gc-diff" style="background:var(--yellow-dim);color:var(--yellow)">Medium</span>
|
| 576 |
+
</div>
|
| 577 |
+
<div class="gc-name">Thermal Event Response</div>
|
| 578 |
+
<div class="gc-desc">CRAC-3 compressor failure. Zone B temps are rising. Diagnose the fault and redistribute cooling to stabilize all zones.</div>
|
| 579 |
+
<div class="gc-hint"><strong>Strategy</strong>Diagnose CRAC-3 first. Lower setpoints on remaining CRACs. Boost fan speeds. Keep all zones in recommended range for 2+ steps.</div>
|
| 580 |
+
</div>
|
| 581 |
+
<div class="guide-card">
|
| 582 |
+
<div class="gc-header">
|
| 583 |
+
<span class="gc-id">A4</span>
|
| 584 |
+
<span class="gc-diff" style="background:var(--red-dim);color:var(--red)">Hard</span>
|
| 585 |
+
</div>
|
| 586 |
+
<div class="gc-name">CRAC Failure Cascade</div>
|
| 587 |
+
<div class="gc-desc">CRAC-1 compressor failure and CRAC-3 fan failure simultaneously. A cascading thermal event threatens multiple zones.</div>
|
| 588 |
+
<div class="gc-hint"><strong>Strategy</strong>Diagnose both CRACs. Aggressively lower setpoints on CRAC-2/4. Max fan speeds. Consider load shedding on hot racks. Keep zones in allowable range.</div>
|
| 589 |
+
</div>
|
| 590 |
+
</div>
|
| 591 |
+
|
| 592 |
+
<h3 style="font-size:0.85rem;font-weight:700;margin:1.25rem 0 0.625rem;color:var(--text)">Power (Category B)</h3>
|
| 593 |
+
<div class="guide-cards">
|
| 594 |
+
<div class="guide-card">
|
| 595 |
+
<div class="gc-header">
|
| 596 |
+
<span class="gc-id">B1</span>
|
| 597 |
+
<span class="gc-diff" style="background:var(--yellow-dim);color:var(--yellow)">Medium</span>
|
| 598 |
+
</div>
|
| 599 |
+
<div class="gc-name">UPS Alarm Response</div>
|
| 600 |
+
<div class="gc-desc">UPS transferred to battery after a utility event (now restored). Diagnose the situation and acknowledge the alarm to resolve.</div>
|
| 601 |
+
<div class="gc-hint"><strong>Strategy</strong>Diagnose UPS-1 first. Verify utility is restored. Acknowledge the alarm. The UPS should return to normal operation.</div>
|
| 602 |
+
</div>
|
| 603 |
+
<div class="guide-card">
|
| 604 |
+
<div class="gc-header">
|
| 605 |
+
<span class="gc-id">B3</span>
|
| 606 |
+
<span class="gc-diff" style="background:var(--green-dim);color:var(--green)">Easy</span>
|
| 607 |
+
</div>
|
| 608 |
+
<div class="gc-name">Generator Test Protocol</div>
|
| 609 |
+
<div class="gc-desc">Routine monthly generator test. Follow the proper 5-step protocol: diagnose → start → verify → stop → confirm shutdown.</div>
|
| 610 |
+
<div class="gc-hint"><strong>Strategy</strong>1. diagnose GEN-1 → 2. start_generator → 3. wait (let it warm) → 4. diagnose GEN-1 (verify running) → 5. stop_generator</div>
|
| 611 |
+
</div>
|
| 612 |
+
<div class="guide-card">
|
| 613 |
+
<div class="gc-header">
|
| 614 |
+
<span class="gc-id">B4</span>
|
| 615 |
+
<span class="gc-diff" style="background:var(--red-dim);color:var(--red)">Hard</span>
|
| 616 |
+
</div>
|
| 617 |
+
<div class="gc-name">Power Failure Cascade</div>
|
| 618 |
+
<div class="gc-desc">Utility power lost with extended generator warmup. UPS running on battery. Manage battery life and thermal conditions until generator loads.</div>
|
| 619 |
+
<div class="gc-hint"><strong>Strategy</strong>Start generator immediately. Shed non-critical rack loads to preserve battery. Monitor SOC. Once generator loads, restore loads. Keep temps stable.</div>
|
| 620 |
+
</div>
|
| 621 |
+
</div>
|
| 622 |
+
</div>
|
| 623 |
+
|
| 624 |
+
<!-- ── Available Commands ── -->
|
| 625 |
+
<div class="guide-section">
|
| 626 |
+
<div class="guide-section-header">
|
| 627 |
+
<div class="guide-section-icon cyan">⌨</div>
|
| 628 |
+
<h2>Available Commands</h2>
|
| 629 |
+
</div>
|
| 630 |
+
<table class="guide-table">
|
| 631 |
+
<thead>
|
| 632 |
+
<tr>
|
| 633 |
+
<th>Command</th>
|
| 634 |
+
<th>Description</th>
|
| 635 |
+
<th>Example</th>
|
| 636 |
+
</tr>
|
| 637 |
+
</thead>
|
| 638 |
+
<tbody>
|
| 639 |
+
<tr>
|
| 640 |
+
<td><code>diagnose <unit></code></td>
|
| 641 |
+
<td>Inspect a CRAC, UPS, Generator, or PDU for faults and status</td>
|
| 642 |
+
<td><code>diagnose CRAC-3</code></td>
|
| 643 |
+
</tr>
|
| 644 |
+
<tr>
|
| 645 |
+
<td><code>adjust_setpoint <crac> <°C></code></td>
|
| 646 |
+
<td>Change CRAC supply air setpoint (10–35°C). Supply temp converges over ~30s.</td>
|
| 647 |
+
<td><code>adjust_setpoint CRAC-1 22</code></td>
|
| 648 |
+
</tr>
|
| 649 |
+
<tr>
|
| 650 |
+
<td><code>set_fan_speed <crac> <%></code></td>
|
| 651 |
+
<td>Set CRAC fan speed (0–100%). Fan power follows cubic law.</td>
|
| 652 |
+
<td><code>set_fan_speed CRAC-2 100</code></td>
|
| 653 |
+
</tr>
|
| 654 |
+
<tr>
|
| 655 |
+
<td><code>set_rack_load <rack> <kW></code></td>
|
| 656 |
+
<td>Adjust rack IT load (0–30 kW) — simulates workload migration.</td>
|
| 657 |
+
<td><code>set_rack_load B-05 4</code></td>
|
| 658 |
+
</tr>
|
| 659 |
+
<tr>
|
| 660 |
+
<td><code>start_crac <crac></code></td>
|
| 661 |
+
<td>Start a standby CRAC unit.</td>
|
| 662 |
+
<td><code>start_crac CRAC-3</code></td>
|
| 663 |
+
</tr>
|
| 664 |
+
<tr>
|
| 665 |
+
<td><code>stop_crac <crac></code></td>
|
| 666 |
+
<td>Put a CRAC into standby mode.</td>
|
| 667 |
+
<td><code>stop_crac CRAC-4</code></td>
|
| 668 |
+
</tr>
|
| 669 |
+
<tr>
|
| 670 |
+
<td><code>start_generator</code></td>
|
| 671 |
+
<td>Initiate diesel generator start sequence (OFF → CRANKING → WARMING → READY → LOADED).</td>
|
| 672 |
+
<td><code>start_generator</code></td>
|
| 673 |
+
</tr>
|
| 674 |
+
<tr>
|
| 675 |
+
<td><code>stop_generator</code></td>
|
| 676 |
+
<td>Initiate generator cooldown sequence (300s).</td>
|
| 677 |
+
<td><code>stop_generator</code></td>
|
| 678 |
+
</tr>
|
| 679 |
+
<tr>
|
| 680 |
+
<td><code>set_ups_mode <ups> <mode></code></td>
|
| 681 |
+
<td>Set UPS mode: <code>eco</code>, <code>double_conversion</code>, <code>line_interactive</code>, or <code>bypass</code>.</td>
|
| 682 |
+
<td><code>set_ups_mode UPS-1 eco</code></td>
|
| 683 |
+
</tr>
|
| 684 |
+
<tr>
|
| 685 |
+
<td><code>refuel_generator [liters]</code></td>
|
| 686 |
+
<td>Refuel the generator. Omit liters to fill tank.</td>
|
| 687 |
+
<td><code>refuel_generator 500</code></td>
|
| 688 |
+
</tr>
|
| 689 |
+
<tr>
|
| 690 |
+
<td><code>acknowledge_alarm</code></td>
|
| 691 |
+
<td>Acknowledge the current alert — clears the alert banner.</td>
|
| 692 |
+
<td><code>acknowledge_alarm</code></td>
|
| 693 |
+
</tr>
|
| 694 |
+
<tr>
|
| 695 |
+
<td><code>check_status</code></td>
|
| 696 |
+
<td>Request full status report. Refreshes the dashboard.</td>
|
| 697 |
+
<td><code>check_status</code></td>
|
| 698 |
+
</tr>
|
| 699 |
+
<tr>
|
| 700 |
+
<td><code>escalate</code></td>
|
| 701 |
+
<td>Escalate to senior engineer. Ends the episode.</td>
|
| 702 |
+
<td><code>escalate</code></td>
|
| 703 |
+
</tr>
|
| 704 |
+
<tr>
|
| 705 |
+
<td><code>wait</code></td>
|
| 706 |
+
<td>Take no action — advances simulation time by one step.</td>
|
| 707 |
+
<td><code>wait</code></td>
|
| 708 |
+
</tr>
|
| 709 |
+
</tbody>
|
| 710 |
+
</table>
|
| 711 |
+
</div>
|
| 712 |
+
|
| 713 |
+
<!-- ── Reward System ── -->
|
| 714 |
+
<div class="guide-section">
|
| 715 |
+
<div class="guide-section-header">
|
| 716 |
+
<div class="guide-section-icon green">★</div>
|
| 717 |
+
<h2>Reward System</h2>
|
| 718 |
+
</div>
|
| 719 |
+
<p>The environment uses a <strong>6-component, research-informed</strong> reward function. Each component is bounded to [−1, 1]. The total reward is a weighted sum, clamped to [−1, 1]. Weights auto-adjust based on scenario type.</p>
|
| 720 |
+
|
| 721 |
+
<div class="reward-grid">
|
| 722 |
+
<div class="reward-card">
|
| 723 |
+
<h4>🌡️ Thermal Safety <span class="rc-range">[−1, +0.1]</span></h4>
|
| 724 |
+
<p>Dual softplus barriers at ASHRAE recommended and allowable limits. Violations are penalized smoothly — the closer to the limit, the stronger the gradient. Returns <strong>+0.1 baseline</strong> when all zones are ≥3°C below recommended max (DCRL-Green).</p>
|
| 725 |
+
<div class="rc-formula">penalty = softplus((T − T_rec) / 2.0) + 3.0 · softplus((T − T_allow) / 1.5)</div>
|
| 726 |
+
</div>
|
| 727 |
+
<div class="reward-card">
|
| 728 |
+
<h4>⚡ Power Safety <span class="rc-range">[−1, 0]</span></h4>
|
| 729 |
+
<p>Penalizes low UPS battery state-of-charge (SOC) via softplus barrier at 50% threshold. UPS fault adds a fixed penalty of 5.0. Compounds across multiple UPS units.</p>
|
| 730 |
+
<div class="rc-formula">penalty = softplus((0.5 − SOC) / 0.15) + 5.0 · [fault]</div>
|
| 731 |
+
</div>
|
| 732 |
+
<div class="reward-card">
|
| 733 |
+
<h4>📊 Efficiency <span class="rc-range">[−1, 0]</span></h4>
|
| 734 |
+
<p>PUE-based energy efficiency. PUE 1.0 (ideal) → 0, PUE 2.0 → −0.46, PUE 3.0 → −0.76. <strong>Suppressed to 0</strong> during power emergencies (UPS on battery or fault) so the agent isn't penalized for correct load shedding.</p>
|
| 735 |
+
<div class="rc-formula">reward = −tanh((PUE − 1.0) / 2.0)</div>
|
| 736 |
+
</div>
|
| 737 |
+
<div class="reward-card">
|
| 738 |
+
<h4>🎯 Scenario Progress <span class="rc-range">[−1, +1]</span></h4>
|
| 739 |
+
<p>Delta-based: rewards the <em>change</em> in progress. This provides credit assignment — only the action that actually caused forward progress gets rewarded. Each scenario defines a normalized [0, 1] progress metric.</p>
|
| 740 |
+
<div class="rc-formula">reward = progress_now − progress_prev</div>
|
| 741 |
+
</div>
|
| 742 |
+
<div class="reward-card">
|
| 743 |
+
<h4>📋 Procedure <span class="rc-range">[−1, +1]</span></h4>
|
| 744 |
+
<p>Scenario-defined procedural correctness rules. For example, diagnosing before adjusting setpoints earns a bonus (+0.2), while skipping diagnosis incurs a penalty (−0.1). Encourages proper operational procedures.</p>
|
| 745 |
+
<div class="rc-formula">reward = scenario.procedure_reward (clamped)</div>
|
| 746 |
+
</div>
|
| 747 |
+
<div class="reward-card">
|
| 748 |
+
<h4>🎮 Action Quality <span class="rc-range">[−1, +1]</span></h4>
|
| 749 |
+
<p>Context-aware assessment: <strong>−0.5</strong> invalid command, <strong>−0.2</strong> repeat (except <code style="font-size:0.7rem">wait</code>/<code style="font-size:0.7rem">check_status</code>), <strong>+0.3</strong> diagnose/check_status, <strong>+0.2</strong> interventions, <strong>+0.1</strong> acknowledge, <strong>−0.1</strong> escalate. Waiting during generator startup: +0.1.</p>
|
| 750 |
+
<div class="rc-formula">Heuristic scoring per action type + context</div>
|
| 751 |
+
</div>
|
| 752 |
+
</div>
|
| 753 |
+
|
| 754 |
+
<h3 style="font-size:0.85rem;font-weight:700;margin:1.5rem 0 0.75rem;color:var(--text)">Weight Profiles</h3>
|
| 755 |
+
<p>Weights auto-select based on scenario type. Components sum to 1.0.</p>
|
| 756 |
+
<div class="weight-profiles" id="weightProfiles"></div>
|
| 757 |
+
</div>
|
| 758 |
+
|
| 759 |
+
<!-- ── ASHRAE Guidelines ── -->
|
| 760 |
+
<div class="guide-section">
|
| 761 |
+
<div class="guide-section-header">
|
| 762 |
+
<div class="guide-section-icon yellow">🏛</div>
|
| 763 |
+
<h2>ASHRAE Thermal Guidelines</h2>
|
| 764 |
+
</div>
|
| 765 |
+
<p>All safety thresholds follow <strong>ASHRAE TC 9.9, 5th Edition (2021)</strong>. The <span style="color:var(--green)">recommended</span> range is optimal for equipment longevity. The <span style="color:var(--yellow)">allowable</span> range permits short-term operation during incidents.</p>
|
| 766 |
+
<table class="guide-table">
|
| 767 |
+
<thead>
|
| 768 |
+
<tr>
|
| 769 |
+
<th>Class</th>
|
| 770 |
+
<th>Recommended</th>
|
| 771 |
+
<th>Allowable</th>
|
| 772 |
+
<th>Application</th>
|
| 773 |
+
</tr>
|
| 774 |
+
</thead>
|
| 775 |
+
<tbody>
|
| 776 |
+
<tr>
|
| 777 |
+
<td><strong>A1</strong></td>
|
| 778 |
+
<td><span style="color:var(--green)">18–27°C</span></td>
|
| 779 |
+
<td><span style="color:var(--yellow)">15–32°C</span></td>
|
| 780 |
+
<td>Enterprise servers</td>
|
| 781 |
+
</tr>
|
| 782 |
+
<tr>
|
| 783 |
+
<td><strong>A2</strong></td>
|
| 784 |
+
<td><span style="color:var(--green)">18–27°C</span></td>
|
| 785 |
+
<td><span style="color:var(--yellow)">10–35°C</span></td>
|
| 786 |
+
<td>Volume servers (most common)</td>
|
| 787 |
+
</tr>
|
| 788 |
+
<tr>
|
| 789 |
+
<td><strong>A3</strong></td>
|
| 790 |
+
<td><span style="color:var(--green)">18–27°C</span></td>
|
| 791 |
+
<td><span style="color:var(--yellow)">5–40°C</span></td>
|
| 792 |
+
<td>Extended temperature range</td>
|
| 793 |
+
</tr>
|
| 794 |
+
<tr>
|
| 795 |
+
<td><strong>A4</strong></td>
|
| 796 |
+
<td><span style="color:var(--green)">18–27°C</span></td>
|
| 797 |
+
<td><span style="color:var(--yellow)">5–45°C</span></td>
|
| 798 |
+
<td>Maximum flexibility</td>
|
| 799 |
+
</tr>
|
| 800 |
+
<tr>
|
| 801 |
+
<td><strong>H1</strong></td>
|
| 802 |
+
<td><span style="color:var(--green)">18–22°C</span></td>
|
| 803 |
+
<td><span style="color:var(--yellow)">5–25°C</span></td>
|
| 804 |
+
<td>High-density / AI / HPC (GPU servers)</td>
|
| 805 |
+
</tr>
|
| 806 |
+
</tbody>
|
| 807 |
+
</table>
|
| 808 |
+
<p style="margin-top:0.75rem;font-size:0.78rem;color:var(--text-dim)"><strong>Key insight:</strong> The reward system uses softplus barriers at both recommended and allowable limits. Staying ≥3°C below recommended max yields a +0.1 thermal safety bonus. Exceeding allowable limits incurs 3× the per-degree penalty of recommended violations.</p>
|
| 809 |
+
</div>
|
| 810 |
+
|
| 811 |
+
<!-- ── Physics Engine ── -->
|
| 812 |
+
<div class="guide-section">
|
| 813 |
+
<div class="guide-section-header">
|
| 814 |
+
<div class="guide-section-icon red">⚙</div>
|
| 815 |
+
<h2>Physics Engine</h2>
|
| 816 |
+
</div>
|
| 817 |
+
|
| 818 |
+
<h3 style="font-size:0.85rem;font-weight:700;margin:0.75rem 0 0.5rem;color:var(--text)">Thermal Model — RC Network</h3>
|
| 819 |
+
<p>The simulation uses a <strong>lumped-capacitance RC thermal network</strong> — the standard approach for datacenter transient thermal analysis. Each zone's temperature evolves according to:</p>
|
| 820 |
+
<div class="guide-code">C_total · dT/dt = Q_IT − Q_cooling + Q_envelope + Q_internal
|
| 821 |
+
|
| 822 |
+
Where:
|
| 823 |
+
C_total = C_air + C_equipment (dominated by server thermal mass)
|
| 824 |
+
Q_IT = Σ rack IT loads [W] — all electrical power converts to heat
|
| 825 |
+
Q_cooling = Σ CRAC outputs [W] — capacity varies with return air temp
|
| 826 |
+
Q_envelope = (T_outside − T_zone) / R_envelope [W]</div>
|
| 827 |
+
|
| 828 |
+
<p>Important CRAC characteristics:</p>
|
| 829 |
+
<ul>
|
| 830 |
+
<li><strong>Capacity vs. return temp:</strong> Q_actual = Q_rated × [1 + 0.03 × (T_return − T_rated)], so capacity increases when a zone heats up</li>
|
| 831 |
+
<li><strong>Fan power:</strong> Cubic law (affinity laws) — P_fan = P_rated × (speed%)³</li>
|
| 832 |
+
<li><strong>Supply temp lag:</strong> 30-second time constant between setpoint change and actual supply temp</li>
|
| 833 |
+
<li><strong>Recirculation:</strong> Hot air mixing caused by dominant airflow imbalance</li>
|
| 834 |
+
</ul>
|
| 835 |
+
|
| 836 |
+
<h3 style="font-size:0.85rem;font-weight:700;margin:1.25rem 0 0.5rem;color:var(--text)">Power Model</h3>
|
| 837 |
+
<p><strong>UPS quadratic loss model</strong> (APC White Paper 108):</p>
|
| 838 |
+
<div class="guide-code">η(x) = x / (x + 0.013 + 0.006x + 0.011x²)
|
| 839 |
+
|
| 840 |
+
90.5% efficient at 25% load
|
| 841 |
+
93.6% efficient at 50% load
|
| 842 |
+
94.0% efficient at 75% load</div>
|
| 843 |
+
<p><strong>Battery discharge:</strong> SOC depletes based on load, UPS efficiency, and temperature derating.</p>
|
| 844 |
+
|
| 845 |
+
<h3 style="font-size:0.85rem;font-weight:700;margin:1.25rem 0 0.5rem;color:var(--text)">Generator State Machine</h3>
|
| 846 |
+
<div class="guide-code">OFF ─→ START_DELAY (4s) ─→ CRANKING (5s) ─→ WARMING (8s) ─→ READY ─→ LOADED
|
| 847 |
+
↓
|
| 848 |
+
COOLDOWN (300s) ─→ OFF</div>
|
| 849 |
+
<p>ATS (Automatic Transfer Switch) performs mechanical transfer in 100ms. Retransfer delay is 300 seconds to prevent rapid switching.</p>
|
| 850 |
+
</div>
|
| 851 |
+
|
| 852 |
+
<!-- ── Research References ── -->
|
| 853 |
+
<div class="guide-section">
|
| 854 |
+
<div class="guide-section-header">
|
| 855 |
+
<div class="guide-section-icon blue">📚</div>
|
| 856 |
+
<h2>Research Foundation</h2>
|
| 857 |
+
</div>
|
| 858 |
+
<ul>
|
| 859 |
+
<li><strong>Google/DeepMind (2017):</strong> Demonstrated 40% cooling energy reduction using RL with softplus barrier functions for safety constraints.</li>
|
| 860 |
+
<li><strong>DCRL-Green (ICLR 2025):</strong> Multi-objective reward with softplus barriers and positive safe-state baseline for safe RL in datacenters.</li>
|
| 861 |
+
<li><strong>ASHRAE TC 9.9, 5th Edition (2021):</strong> Industry-standard thermal guidelines used for all safety thresholds.</li>
|
| 862 |
+
<li><strong>APC White Paper 108:</strong> UPS quadratic loss model with experimentally calibrated coefficients.</li>
|
| 863 |
+
<li><strong>Process Reward Models:</strong> Delta-based progress rewards for improved credit assignment in multi-step reasoning.</li>
|
| 864 |
+
</ul>
|
| 865 |
+
</div>
|
| 866 |
+
|
| 867 |
+
</div>
|
| 868 |
+
</div><!-- /tabGuide -->
|
| 869 |
</div>
|
| 870 |
|
| 871 |
<script>
|
|
|
|
| 891 |
B4: { name: 'Power Failure Cascade', type: 'power', diff: 'Hard' },
|
| 892 |
};
|
| 893 |
|
| 894 |
+
// Scenario-adaptive quick action definitions
|
| 895 |
+
const QUICK_ACTIONS = {
|
| 896 |
+
_common: ['check_status', 'wait', 'acknowledge_alarm', 'escalate'],
|
| 897 |
+
A1: ['adjust_setpoint CRAC-1 22', 'adjust_setpoint CRAC-2 22', 'adjust_setpoint CRAC-3 22', 'adjust_setpoint CRAC-4 22', 'diagnose CRAC-1'],
|
| 898 |
+
A2: ['diagnose CRAC-3', 'diagnose CRAC-1', 'adjust_setpoint CRAC-1 20', 'adjust_setpoint CRAC-2 20', 'set_fan_speed CRAC-1 100', 'set_fan_speed CRAC-2 100'],
|
| 899 |
+
A4: ['diagnose CRAC-1', 'diagnose CRAC-3', 'adjust_setpoint CRAC-2 16', 'adjust_setpoint CRAC-4 16', 'set_fan_speed CRAC-2 100', 'set_fan_speed CRAC-4 100', 'set_rack_load B-05 4'],
|
| 900 |
+
B1: ['diagnose UPS-1', 'diagnose GEN-1', 'start_generator', 'stop_generator'],
|
| 901 |
+
B3: ['start_generator', 'diagnose GEN-1', 'stop_generator'],
|
| 902 |
+
B4: ['diagnose UPS-1', 'diagnose GEN-1', 'start_generator', 'set_rack_load A-05 4', 'set_rack_load B-05 4'],
|
| 903 |
+
};
|
| 904 |
+
|
| 905 |
+
function buildQuickActions(scenarioId) {
|
| 906 |
+
const container = document.getElementById('quickActions');
|
| 907 |
+
container.innerHTML = '';
|
| 908 |
+
const specific = QUICK_ACTIONS[scenarioId] || [];
|
| 909 |
+
const common = QUICK_ACTIONS._common;
|
| 910 |
+
const all = [...specific, ...common];
|
| 911 |
+
for (const cmd of all) {
|
| 912 |
+
const btn = document.createElement('button');
|
| 913 |
+
btn.className = 'quick-btn';
|
| 914 |
+
btn.disabled = !episodeActive;
|
| 915 |
+
// Short display label
|
| 916 |
+
let label = cmd;
|
| 917 |
+
if (cmd === 'acknowledge_alarm') label = 'ack_alarm';
|
| 918 |
+
else if (cmd === 'check_status') label = 'check_status';
|
| 919 |
+
else if (cmd === 'start_generator') label = 'start_gen';
|
| 920 |
+
else if (cmd === 'stop_generator') label = 'stop_gen';
|
| 921 |
+
btn.textContent = label;
|
| 922 |
+
btn.onclick = () => quickCmd(cmd);
|
| 923 |
+
container.appendChild(btn);
|
| 924 |
+
}
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
// ─── WebSocket connection ────────────────────────────────────────────
|
| 928 |
function connectWebSocket() {
|
| 929 |
return new Promise((resolve, reject) => {
|
|
|
|
| 998 |
const btn = document.getElementById('startBtn');
|
| 999 |
btn.disabled = false;
|
| 1000 |
btn.textContent = `Start ${id}: ${SCENARIOS[id].name}`;
|
| 1001 |
+
buildQuickActions(id);
|
| 1002 |
}
|
| 1003 |
|
| 1004 |
function togglePanel(id) {
|
|
|
|
| 1012 |
document.getElementById('commandInput').disabled = !enabled;
|
| 1013 |
document.getElementById('sendBtn').disabled = !enabled;
|
| 1014 |
document.querySelectorAll('.quick-btn').forEach(b => b.disabled = !enabled);
|
| 1015 |
+
// Rebuild quick actions if scenario changed while disabled
|
| 1016 |
+
if (enabled && selectedScenario) buildQuickActions(selectedScenario);
|
| 1017 |
}
|
| 1018 |
|
| 1019 |
function quickCmd(cmd) {
|
|
|
|
| 1371 |
}
|
| 1372 |
}
|
| 1373 |
|
| 1374 |
+
// ─── Tab switching ───────────────────────────────────────────────────
|
| 1375 |
+
function switchTab(tab) {
|
| 1376 |
+
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
| 1377 |
+
document.querySelector(`.tab-btn[data-tab="${tab}"]`).classList.add('active');
|
| 1378 |
+
|
| 1379 |
+
// Console tab uses display:contents, guide uses display:block
|
| 1380 |
+
const consoleEl = document.getElementById('tabConsole');
|
| 1381 |
+
const guideEl = document.getElementById('tabGuide');
|
| 1382 |
+
if (tab === 'console') {
|
| 1383 |
+
consoleEl.classList.add('active');
|
| 1384 |
+
guideEl.classList.remove('active');
|
| 1385 |
+
} else {
|
| 1386 |
+
consoleEl.classList.remove('active');
|
| 1387 |
+
guideEl.classList.add('active');
|
| 1388 |
+
guideEl.scrollTop = 0;
|
| 1389 |
+
}
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
// ─── Build weight profile visualizations ─────────────────────────────
|
| 1393 |
+
function buildWeightProfiles() {
|
| 1394 |
+
const profiles = {
|
| 1395 |
+
'Thermal Scenarios': { thermal_safety: 0.30, power_safety: 0.05, efficiency: 0.10, progress: 0.30, procedure: 0.20, action: 0.05 },
|
| 1396 |
+
'Power Scenarios': { thermal_safety: 0.10, power_safety: 0.25, efficiency: 0.05, progress: 0.30, procedure: 0.25, action: 0.05 },
|
| 1397 |
+
'Default': { thermal_safety: 0.30, power_safety: 0.15, efficiency: 0.25, progress: 0.00, procedure: 0.00, action: 0.30 },
|
| 1398 |
+
};
|
| 1399 |
+
const colors = {
|
| 1400 |
+
thermal_safety: 'var(--red)', power_safety: 'var(--yellow)', efficiency: 'var(--green)',
|
| 1401 |
+
progress: 'var(--accent)', procedure: 'var(--cyan)', action: 'var(--orange)'
|
| 1402 |
+
};
|
| 1403 |
+
const labels = {
|
| 1404 |
+
thermal_safety: 'Thermal', power_safety: 'Power', efficiency: 'Efficiency',
|
| 1405 |
+
progress: 'Progress', procedure: 'Procedure', action: 'Action'
|
| 1406 |
+
};
|
| 1407 |
+
const container = document.getElementById('weightProfiles');
|
| 1408 |
+
for (const [name, weights] of Object.entries(profiles)) {
|
| 1409 |
+
const card = document.createElement('div');
|
| 1410 |
+
card.className = 'weight-profile';
|
| 1411 |
+
let html = `<h4>${name}</h4>`;
|
| 1412 |
+
for (const [key, val] of Object.entries(weights)) {
|
| 1413 |
+
const pct = val * 100;
|
| 1414 |
+
html += `<div class="weight-bar-row">
|
| 1415 |
+
<span class="weight-bar-label">${labels[key]}</span>
|
| 1416 |
+
<div class="weight-bar-track"><div class="weight-bar-fill" style="width:${pct * 3.33}%;background:${colors[key]}"></div></div>
|
| 1417 |
+
<span class="weight-bar-val">${pct.toFixed(0)}%</span>
|
| 1418 |
+
</div>`;
|
| 1419 |
+
}
|
| 1420 |
+
card.innerHTML = html;
|
| 1421 |
+
container.appendChild(card);
|
| 1422 |
+
}
|
| 1423 |
+
}
|
| 1424 |
+
|
| 1425 |
// ─── Health check ────────────────────────────────────────────────────
|
| 1426 |
async function checkHealth() {
|
| 1427 |
try {
|
|
|
|
| 1435 |
|
| 1436 |
// ─── Init ────────────────────────────────────────────────────────────
|
| 1437 |
checkHealth();
|
| 1438 |
+
buildWeightProfiles();
|
| 1439 |
</script>
|
| 1440 |
</body>
|
| 1441 |
</html>
|
simulation/thermal.py
CHANGED
|
@@ -485,16 +485,18 @@ class ThermalSimulation:
|
|
| 485 |
self._state.outside_temp_c = temp_c
|
| 486 |
|
| 487 |
def _find_crac(self, unit_id: str) -> CRACState | None:
|
|
|
|
| 488 |
for zone in self._state.zones:
|
| 489 |
for crac in zone.crac_units:
|
| 490 |
-
if crac.unit_id ==
|
| 491 |
return crac
|
| 492 |
return None
|
| 493 |
|
| 494 |
def _find_rack(self, rack_id: str) -> RackState | None:
|
|
|
|
| 495 |
for zone in self._state.zones:
|
| 496 |
for rack in zone.racks:
|
| 497 |
-
if rack.rack_id ==
|
| 498 |
return rack
|
| 499 |
return None
|
| 500 |
|
|
|
|
| 485 |
self._state.outside_temp_c = temp_c
|
| 486 |
|
| 487 |
def _find_crac(self, unit_id: str) -> CRACState | None:
|
| 488 |
+
target = unit_id.lower()
|
| 489 |
for zone in self._state.zones:
|
| 490 |
for crac in zone.crac_units:
|
| 491 |
+
if crac.unit_id.lower() == target:
|
| 492 |
return crac
|
| 493 |
return None
|
| 494 |
|
| 495 |
def _find_rack(self, rack_id: str) -> RackState | None:
|
| 496 |
+
target = rack_id.lower()
|
| 497 |
for zone in self._state.zones:
|
| 498 |
for rack in zone.racks:
|
| 499 |
+
if rack.rack_id.lower() == target:
|
| 500 |
return rack
|
| 501 |
return None
|
| 502 |
|
tests/test_scenarios.py
CHANGED
|
@@ -180,16 +180,18 @@ class TestA2ThermalEvent:
|
|
| 180 |
|
| 181 |
def test_procedure_bonus_for_diagnose_first(self) -> None:
|
| 182 |
"""Diagnosing before adjusting should yield higher reward."""
|
| 183 |
-
# Run 1: diagnose first, then adjust
|
| 184 |
env1 = DcOpsEnvironment()
|
| 185 |
env1.reset(scenario="A2")
|
| 186 |
-
|
| 187 |
obs1b = env1.step(DcOpsAction(command="adjust_setpoint CRAC-4 20"))
|
| 188 |
r_with_diagnose = obs1b.reward
|
| 189 |
|
| 190 |
-
# Run 2: adjust without diagnosing
|
|
|
|
| 191 |
env2 = DcOpsEnvironment()
|
| 192 |
env2.reset(scenario="A2")
|
|
|
|
| 193 |
obs2 = env2.step(DcOpsAction(command="adjust_setpoint CRAC-4 20"))
|
| 194 |
r_without_diagnose = obs2.reward
|
| 195 |
|
|
@@ -294,9 +296,9 @@ class TestB3GeneratorTest:
|
|
| 294 |
|
| 295 |
assert obs.done is True
|
| 296 |
|
| 297 |
-
def
|
| 298 |
s = get_scenario("B3")
|
| 299 |
-
assert s.game_time_per_step_s ==
|
| 300 |
|
| 301 |
|
| 302 |
# ===========================================================================
|
|
|
|
| 180 |
|
| 181 |
def test_procedure_bonus_for_diagnose_first(self) -> None:
|
| 182 |
"""Diagnosing before adjusting should yield higher reward."""
|
| 183 |
+
# Run 1: diagnose first, then adjust (procedure bonus on step 2)
|
| 184 |
env1 = DcOpsEnvironment()
|
| 185 |
env1.reset(scenario="A2")
|
| 186 |
+
env1.step(DcOpsAction(command="diagnose CRAC-3"))
|
| 187 |
obs1b = env1.step(DcOpsAction(command="adjust_setpoint CRAC-4 20"))
|
| 188 |
r_with_diagnose = obs1b.reward
|
| 189 |
|
| 190 |
+
# Run 2: wait, then adjust without diagnosing (procedure penalty on step 2)
|
| 191 |
+
# Using wait keeps physics comparable so only the procedure bonus differs
|
| 192 |
env2 = DcOpsEnvironment()
|
| 193 |
env2.reset(scenario="A2")
|
| 194 |
+
env2.step(DcOpsAction(command="wait"))
|
| 195 |
obs2 = env2.step(DcOpsAction(command="adjust_setpoint CRAC-4 20"))
|
| 196 |
r_without_diagnose = obs2.reward
|
| 197 |
|
|
|
|
| 296 |
|
| 297 |
assert obs.done is True
|
| 298 |
|
| 299 |
+
def test_uses_10s_steps(self) -> None:
|
| 300 |
s = get_scenario("B3")
|
| 301 |
+
assert s.game_time_per_step_s == 10.0
|
| 302 |
|
| 303 |
|
| 304 |
# ===========================================================================
|