Melikshah commited on
Commit
91495a2
·
verified ·
1 Parent(s): aedaf74

Upload folder using huggingface_hub

Browse files
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
- self._verified = True
 
 
 
 
 
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 10
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, so 30s per step lets agent observe transitions
323
- return 30.0
 
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
- resolved = self._stable_count >= self._CONSECUTIVE_STABLE_STEPS
 
 
 
 
 
 
 
 
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 # Default config is fine
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
- resolved = self._stable_count >= self._CONSECUTIVE_STABLE_STEPS
 
 
 
 
 
 
 
 
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. Advance simulation
 
 
 
 
265
  thermal_alarms, power_alarms = self._advance_simulation()
266
 
267
- # 3. Build alert from alarms
268
  self._update_alert(thermal_alarms, power_alarms)
269
 
270
- # 4. Evaluate scenario (before reward, so progress is available)
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
- # 5. Compute reward via RewardFunction
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
- # 6. Check termination
289
  self._check_termination(thermal_alarms, power_alarms)
290
 
291
- # 6b. Scenario resolution
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 &lt; 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 &lt;unit&gt;</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 &lt;crac&gt; &lt;°C&gt;</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 &lt;crac&gt; &lt;%&gt;</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 &lt;rack&gt; &lt;kW&gt;</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 &lt;crac&gt;</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 &lt;crac&gt;</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 &lt;ups&gt; &lt;mode&gt;</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 == 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 == 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
- obs1a = 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: 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 test_uses_30s_steps(self) -> None:
298
  s = get_scenario("B3")
299
- assert s.game_time_per_step_s == 30.0
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
  # ===========================================================================