use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ScenarioType {
Normal,
EmergencyFrequency,
EmergencyVoltage,
NMinusOneClearance,
BlackStart,
CascadePrevent,
TransientStability,
CyberIncident,
}
impl fmt::Display for ScenarioType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Normal => "Normal Operations",
Self::EmergencyFrequency => "Emergency – Frequency",
Self::EmergencyVoltage => "Emergency – Voltage",
Self::NMinusOneClearance => "N-1 Contingency Clearance",
Self::BlackStart => "Black Start",
Self::CascadePrevent => "Cascade Prevention",
Self::TransientStability => "Transient Stability",
Self::CyberIncident => "Cyber Incident",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum OtsEventType {
GeneratorTrip {
unit_id: String,
capacity_mw: f64,
},
LineTrip {
branch_id: String,
},
LoadStep {
bus_id: usize,
delta_mw: f64,
},
FrequencyAlert {
frequency_hz: f64,
},
VoltageAlert {
bus_id: usize,
voltage_pu: f64,
},
ProtectionOperation {
relay_id: String,
},
FaultCleared {
location: String,
},
ManualTrigger {
instruction: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum OtsOperatorAction {
CommitUnit {
unit_id: String,
},
DecommitUnit {
unit_id: String,
},
AdjustGeneration {
unit_id: String,
mw: f64,
},
OpenSwitch {
switch_id: String,
},
CloseSwitch {
switch_id: String,
},
ActivateReserve {
reserve_type: String,
},
IssueAlert {
alert_type: String,
},
VerifyData,
DoNothing,
}
impl OtsOperatorAction {
fn is_dangerous_for_frequency_emergency(&self) -> bool {
matches!(self, Self::DecommitUnit { .. } | Self::OpenSwitch { .. })
}
fn label(&self) -> String {
match self {
Self::CommitUnit { unit_id } => format!("CommitUnit({})", unit_id),
Self::DecommitUnit { unit_id } => format!("DecommitUnit({})", unit_id),
Self::AdjustGeneration { unit_id, mw } => {
format!("AdjustGeneration({}, {:.1} MW)", unit_id, mw)
}
Self::OpenSwitch { switch_id } => format!("OpenSwitch({})", switch_id),
Self::CloseSwitch { switch_id } => format!("CloseSwitch({})", switch_id),
Self::ActivateReserve { reserve_type } => {
format!("ActivateReserve({})", reserve_type)
}
Self::IssueAlert { alert_type } => format!("IssueAlert({})", alert_type),
Self::VerifyData => "VerifyData".to_string(),
Self::DoNothing => "DoNothing".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CorrectAction {
pub action_type: OtsOperatorAction,
pub target: String,
pub value: Option<f64>,
pub rationale: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScenarioEvent {
pub time_s: f64,
pub event_type: OtsEventType,
pub description: String,
pub requires_action: bool,
pub correct_action: CorrectAction,
pub time_limit_s: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemSnapshot {
pub bus_voltages_pu: Vec<f64>,
pub generation_mw: Vec<f64>,
pub load_mw: Vec<f64>,
pub frequency_hz: f64,
pub branch_loading_pct: Vec<f64>,
}
impl SystemSnapshot {
pub fn nominal(n_bus: usize) -> Self {
Self {
bus_voltages_pu: vec![1.0; n_bus],
generation_mw: vec![100.0; n_bus],
load_mw: vec![80.0; n_bus],
frequency_hz: 50.0,
branch_loading_pct: vec![60.0; n_bus.saturating_sub(1)],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraineeAction {
pub timestamp_s: f64,
pub action: OtsOperatorAction,
pub target: String,
pub value: Option<f64>,
pub event_idx: Option<usize>,
pub hint_used: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ScoringMethod {
TimeBased,
AccuracyOnly,
Weighted {
time_weight: f64,
accuracy_weight: f64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OtsConfig {
pub real_time_factor: f64,
pub hints_enabled: bool,
pub scoring_method: ScoringMethod,
pub minimum_pass_score: f64,
}
impl Default for OtsConfig {
fn default() -> Self {
Self {
real_time_factor: 1.0,
hints_enabled: true,
scoring_method: ScoringMethod::Weighted {
time_weight: 0.3,
accuracy_weight: 0.7,
},
minimum_pass_score: 70.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionFeedback {
pub correct: bool,
pub partial_credit: f64,
pub explanation: String,
pub time_taken_s: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResponseScore {
pub points: f64,
pub max_points: f64,
pub time_bonus: f64,
pub feedback: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionReport {
pub trainee_id: String,
pub total_score: f64,
pub max_score: f64,
pub pass_fail: bool,
pub event_results: Vec<(String, f64)>,
pub competency_gaps: Vec<String>,
pub recommended_training: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct EventGrade {
event_idx: usize,
score: ResponseScore,
hint_used: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingSession {
pub session_id: String,
pub scenario_type: ScenarioType,
pub trainee_id: String,
pub events: Vec<ScenarioEvent>,
pub system_state: SystemSnapshot,
pub actions_taken: Vec<TraineeAction>,
pub start_time_s: f64,
pub current_time_s: f64,
pub config: OtsConfig,
grades: Vec<EventGrade>,
hint_penalty: f64,
delivered_events: Vec<usize>,
}
fn action_match_score(submitted: &OtsOperatorAction, correct: &OtsOperatorAction) -> f64 {
if submitted == correct {
return 1.0;
}
match (submitted, correct) {
(
OtsOperatorAction::AdjustGeneration {
unit_id: su,
mw: sm,
},
OtsOperatorAction::AdjustGeneration {
unit_id: cu,
mw: cm,
},
) if su == cu => {
let rel_err = ((sm - cm).abs() / cm.abs().max(1.0)).min(1.0);
(0.7 * (1.0 - rel_err)).max(0.0)
}
(OtsOperatorAction::CommitUnit { .. }, OtsOperatorAction::ActivateReserve { .. })
| (OtsOperatorAction::ActivateReserve { .. }, OtsOperatorAction::CommitUnit { .. }) => 0.5,
(OtsOperatorAction::IssueAlert { .. }, OtsOperatorAction::IssueAlert { .. }) => 0.6,
_ => 0.0,
}
}
fn is_dangerous(submitted: &OtsOperatorAction, event: &ScenarioEvent) -> bool {
match &event.event_type {
OtsEventType::GeneratorTrip { .. } | OtsEventType::FrequencyAlert { .. } => {
submitted.is_dangerous_for_frequency_emergency()
}
_ => false,
}
}
impl TrainingSession {
pub fn new(
session_id: String,
scenario_type: ScenarioType,
trainee_id: String,
events: Vec<ScenarioEvent>,
system_state: SystemSnapshot,
config: OtsConfig,
) -> Self {
Self {
session_id,
scenario_type,
trainee_id,
events,
system_state,
actions_taken: Vec::new(),
start_time_s: 0.0,
current_time_s: 0.0,
config,
grades: Vec::new(),
hint_penalty: 0.0,
delivered_events: Vec::new(),
}
}
pub fn generator_trip_scenario(trainee_id: String) -> Self {
let events = vec![
ScenarioEvent {
time_s: 30.0,
event_type: OtsEventType::GeneratorTrip {
unit_id: "G3".to_string(),
capacity_mw: 300.0,
},
description: "Unit G3 (300 MW) has tripped offline. \
System frequency is falling."
.to_string(),
requires_action: true,
correct_action: CorrectAction {
action_type: OtsOperatorAction::ActivateReserve {
reserve_type: "spinning".to_string(),
},
target: "spinning_reserve".to_string(),
value: None,
rationale: "Spinning reserve must be activated immediately to arrest \
the frequency decline before AGC takes over."
.to_string(),
},
time_limit_s: 60.0,
},
ScenarioEvent {
time_s: 90.0,
event_type: OtsEventType::FrequencyAlert { frequency_hz: 49.3 },
description: "Frequency has dropped to 49.3 Hz. \
Commit peaking unit G5 to restore balance."
.to_string(),
requires_action: true,
correct_action: CorrectAction {
action_type: OtsOperatorAction::CommitUnit {
unit_id: "G5".to_string(),
},
target: "G5".to_string(),
value: None,
rationale: "With spinning reserve exhausted, the next correct step is \
to commit fast-start peaking generation."
.to_string(),
},
time_limit_s: 120.0,
},
];
Self::new(
"GEN-TRIP-001".to_string(),
ScenarioType::EmergencyFrequency,
trainee_id,
events,
SystemSnapshot {
bus_voltages_pu: vec![1.0, 0.98, 0.97, 0.99],
generation_mw: vec![500.0, 400.0, 300.0, 200.0],
load_mw: vec![350.0, 300.0, 250.0, 200.0],
frequency_hz: 50.0,
branch_loading_pct: vec![55.0, 62.0, 48.0],
},
OtsConfig::default(),
)
}
pub fn n1_contingency_scenario(trainee_id: String) -> Self {
let events = vec![ScenarioEvent {
time_s: 20.0,
event_type: OtsEventType::LineTrip {
branch_id: "L12".to_string(),
},
description: "Line L12 has tripped. Branch L13 is now loaded at 118 %. \
Re-dispatch generation to relieve the overload."
.to_string(),
requires_action: true,
correct_action: CorrectAction {
action_type: OtsOperatorAction::AdjustGeneration {
unit_id: "G1".to_string(),
mw: 150.0,
},
target: "G1".to_string(),
value: Some(150.0),
rationale: "Reducing G1 output redirects power flow away from L13 \
and returns it within thermal limits."
.to_string(),
},
time_limit_s: 90.0,
}];
Self::new(
"N1-CONT-001".to_string(),
ScenarioType::NMinusOneClearance,
trainee_id,
events,
SystemSnapshot {
bus_voltages_pu: vec![1.02, 1.0, 0.98],
generation_mw: vec![400.0, 300.0],
load_mw: vec![320.0, 280.0],
frequency_hz: 50.0,
branch_loading_pct: vec![118.0, 55.0],
},
OtsConfig::default(),
)
}
pub fn frequency_emergency_scenario(trainee_id: String) -> Self {
let events = vec![
ScenarioEvent {
time_s: 10.0,
event_type: OtsEventType::FrequencyAlert { frequency_hz: 48.8 },
description: "Frequency has collapsed to 48.8 Hz following a large generation \
loss. Immediate under-frequency load shedding required."
.to_string(),
requires_action: true,
correct_action: CorrectAction {
action_type: OtsOperatorAction::ActivateReserve {
reserve_type: "ufls".to_string(),
},
target: "ufls_scheme".to_string(),
value: None,
rationale: "Under-frequency load shedding (UFLS) is the fastest way to \
arrest frequency collapse below 49 Hz."
.to_string(),
},
time_limit_s: 30.0,
},
ScenarioEvent {
time_s: 60.0,
event_type: OtsEventType::ManualTrigger {
instruction: "Frequency has stabilised at 49.1 Hz. \
Restore shed load in controlled steps."
.to_string(),
},
description: "UFLS has arrested the decline. Restore load gradually.".to_string(),
requires_action: true,
correct_action: CorrectAction {
action_type: OtsOperatorAction::CloseSwitch {
switch_id: "LS_ZONE_A".to_string(),
},
target: "LS_ZONE_A".to_string(),
value: None,
rationale: "Restoring Zone A load first is correct because it carries \
the smallest load and allows frequency monitoring between steps."
.to_string(),
},
time_limit_s: 120.0,
},
];
Self::new(
"FREQ-EMRG-001".to_string(),
ScenarioType::EmergencyFrequency,
trainee_id,
events,
SystemSnapshot {
bus_voltages_pu: vec![1.0, 0.99, 0.97, 0.95],
generation_mw: vec![600.0, 400.0, 200.0, 100.0],
load_mw: vec![450.0, 380.0, 210.0, 120.0],
frequency_hz: 48.8,
branch_loading_pct: vec![72.0, 81.0, 44.0],
},
OtsConfig {
minimum_pass_score: 75.0,
..OtsConfig::default()
},
)
}
pub fn advance_time(&mut self, dt_s: f64) -> Vec<ScenarioEvent> {
let sim_dt = dt_s * self.config.real_time_factor;
let prev_time = self.current_time_s;
self.current_time_s += sim_dt;
let to_fire: Vec<(usize, ScenarioEvent)> = self
.events
.iter()
.enumerate()
.filter(|(idx, event)| {
event.time_s > prev_time
&& event.time_s <= self.current_time_s
&& !self.delivered_events.contains(idx)
})
.map(|(idx, event)| (idx, event.clone()))
.collect();
let mut fired = Vec::new();
for (idx, event) in to_fire {
self.delivered_events.push(idx);
self.apply_event_to_state(&event);
fired.push(event);
}
fired
}
fn apply_event_to_state(&mut self, event: &ScenarioEvent) {
match &event.event_type {
OtsEventType::GeneratorTrip { capacity_mw, .. } => {
let total_gen: f64 = self.system_state.generation_mw.iter().sum();
let s_base = total_gen.max(1.0);
let delta_f = -capacity_mw / (2.0 * 5.0 * s_base) * 50.0;
self.system_state.frequency_hz =
(self.system_state.frequency_hz + delta_f).clamp(47.0, 52.0);
if let Some(g) = self.system_state.generation_mw.first_mut() {
*g = (*g - capacity_mw).max(0.0);
}
}
OtsEventType::FrequencyAlert { frequency_hz } => {
self.system_state.frequency_hz = *frequency_hz;
}
OtsEventType::LoadStep { bus_id, delta_mw } => {
let n = self.system_state.load_mw.len();
let idx = *bus_id % n.max(1);
if let Some(l) = self.system_state.load_mw.get_mut(idx) {
*l = (*l + delta_mw).max(0.0);
}
}
OtsEventType::VoltageAlert { bus_id, voltage_pu } => {
let n = self.system_state.bus_voltages_pu.len();
let idx = *bus_id % n.max(1);
if let Some(v) = self.system_state.bus_voltages_pu.get_mut(idx) {
*v = *voltage_pu;
}
}
OtsEventType::LineTrip { .. } => {
if let Some(b) = self.system_state.branch_loading_pct.first_mut() {
*b = (*b * 1.3).min(150.0);
}
}
OtsEventType::ProtectionOperation { .. }
| OtsEventType::FaultCleared { .. }
| OtsEventType::ManualTrigger { .. } => {}
}
}
pub fn submit_action(
&mut self,
action: OtsOperatorAction,
target: String,
value: Option<f64>,
event_idx: Option<usize>,
hint_used: bool,
) -> ActionFeedback {
let resolved_idx = event_idx.or_else(|| self.next_unresolved_event_idx());
let trainee_action = TraineeAction {
timestamp_s: self.current_time_s,
action: action.clone(),
target: target.clone(),
value,
event_idx: resolved_idx,
hint_used,
};
self.actions_taken.push(trainee_action);
if let Some(idx) = resolved_idx {
let score = self.evaluate_response(idx, &action);
let feedback_text = score.feedback.clone();
let partial = (score.points / score.max_points.max(1.0) * 100.0).min(100.0);
let correct = score.points >= score.max_points;
self.grades.push(EventGrade {
event_idx: idx,
score,
hint_used,
});
let time_taken = self
.events
.get(idx)
.map(|e| (self.current_time_s - e.time_s).max(0.0))
.unwrap_or(0.0);
ActionFeedback {
correct,
partial_credit: partial,
explanation: feedback_text,
time_taken_s: time_taken,
}
} else {
ActionFeedback {
correct: action == OtsOperatorAction::DoNothing,
partial_credit: if action == OtsOperatorAction::DoNothing {
100.0
} else {
0.0
},
explanation: "No pending event requires action at this time.".to_string(),
time_taken_s: 0.0,
}
}
}
fn next_unresolved_event_idx(&self) -> Option<usize> {
let graded: Vec<usize> = self.grades.iter().map(|g| g.event_idx).collect();
self.events
.iter()
.enumerate()
.filter(|(i, e)| {
e.requires_action && e.time_s <= self.current_time_s && !graded.contains(i)
})
.map(|(i, _)| i)
.next()
}
pub fn evaluate_response(
&self,
event_idx: usize,
trainee_action: &OtsOperatorAction,
) -> ResponseScore {
let event = match self.events.get(event_idx) {
Some(e) => e,
None => {
return ResponseScore {
points: 0.0,
max_points: 100.0,
time_bonus: 0.0,
feedback: format!("Event index {} does not exist.", event_idx),
}
}
};
let correct = &event.correct_action.action_type;
let max_points = 100.0_f64;
if is_dangerous(trainee_action, event) {
return ResponseScore {
points: 0.0,
max_points,
time_bonus: 0.0,
feedback: format!(
"DANGEROUS ACTION: {} is contra-indicated during a frequency emergency. \
Rationale: {}",
trainee_action.label(),
event.correct_action.rationale
),
};
}
let match_frac = action_match_score(trainee_action, correct);
let (base_points, feedback_str) = if (match_frac - 1.0).abs() < 1e-9 {
(
max_points,
format!("Correct! {}", event.correct_action.rationale),
)
} else if match_frac > 0.0 {
let pts = max_points * match_frac;
(
pts,
format!(
"Partial credit ({:.0}/100). You chose '{}' but the model answer is '{}'. {}",
pts,
trainee_action.label(),
correct.label(),
event.correct_action.rationale
),
)
} else {
(
50.0,
format!(
"Incorrect (50/100 – safe action but not optimal). \
You chose '{}'; model answer: '{}'. {}",
trainee_action.label(),
correct.label(),
event.correct_action.rationale
),
)
};
let elapsed = (self.current_time_s - event.time_s).max(0.0);
let time_bonus = self.compute_time_bonus(elapsed, event.time_limit_s, match_frac);
let total = (base_points + time_bonus).min(120.0);
ResponseScore {
points: total,
max_points,
time_bonus,
feedback: feedback_str,
}
}
fn compute_time_bonus(&self, elapsed_s: f64, time_limit_s: f64, match_frac: f64) -> f64 {
if match_frac < 1.0 {
return 0.0;
}
match &self.config.scoring_method {
ScoringMethod::AccuracyOnly => 0.0,
ScoringMethod::TimeBased | ScoringMethod::Weighted { .. } => {
if elapsed_s <= 0.0 || time_limit_s <= 0.0 {
return 20.0;
}
let ratio = elapsed_s / time_limit_s;
if ratio <= 1.0 {
20.0 * (1.0 - ratio * 0.5) } else {
(20.0 * (1.0 - (ratio - 1.0) / 2.0)).max(0.0)
}
}
}
}
pub fn hint(&mut self, event_idx: usize) -> Option<String> {
if !self.config.hints_enabled {
return None;
}
let event = self.events.get(event_idx)?;
self.hint_penalty += 10.0;
let hint = format!(
"[HINT –10 pts] Consider action type: {}. Rationale: {}",
event.correct_action.action_type.label(),
event.correct_action.rationale
);
Some(hint)
}
pub fn get_next_event(&self) -> Option<&ScenarioEvent> {
let graded: Vec<usize> = self.grades.iter().map(|g| g.event_idx).collect();
self.events
.iter()
.enumerate()
.filter(|(i, e)| {
e.requires_action && e.time_s <= self.current_time_s && !graded.contains(i)
})
.map(|(_, e)| e)
.next()
}
pub fn simulate_system_response(&mut self, action: &OtsOperatorAction) -> SystemSnapshot {
match action {
OtsOperatorAction::CommitUnit { .. } => {
if let Some(g) = self.system_state.generation_mw.first_mut() {
*g += 100.0;
}
let deficit = 50.0 - self.system_state.frequency_hz;
self.system_state.frequency_hz += deficit * 0.6;
}
OtsOperatorAction::ActivateReserve { .. } => {
let deficit = 50.0 - self.system_state.frequency_hz;
self.system_state.frequency_hz += deficit * 0.3;
}
OtsOperatorAction::AdjustGeneration { mw, .. } => {
if let Some(g) = self.system_state.generation_mw.first_mut() {
*g = (*g + mw).max(0.0);
}
let total_gen: f64 = self.system_state.generation_mw.iter().sum();
let total_load: f64 = self.system_state.load_mw.iter().sum();
let mismatch_frac = (total_gen - total_load) / total_load.max(1.0);
for b in self.system_state.branch_loading_pct.iter_mut() {
*b = (*b * (1.0 - 0.1 * mismatch_frac)).clamp(0.0, 200.0);
}
}
OtsOperatorAction::CloseSwitch { .. } => {
self.system_state.frequency_hz = (self.system_state.frequency_hz - 0.05).max(47.0);
}
OtsOperatorAction::OpenSwitch { .. } => {
self.system_state.frequency_hz = (self.system_state.frequency_hz + 0.1).min(52.0);
}
OtsOperatorAction::IssueAlert { .. }
| OtsOperatorAction::VerifyData
| OtsOperatorAction::DoNothing
| OtsOperatorAction::DecommitUnit { .. } => {
}
}
self.system_state.clone()
}
pub fn generate_session_report(&self) -> SessionReport {
let max_score: f64 = self
.events
.iter()
.filter(|e| e.requires_action)
.map(|_| 100.0)
.sum();
let mut event_results: Vec<(String, f64)> = Vec::new();
let mut total_earned = 0.0_f64;
for (idx, event) in self.events.iter().enumerate() {
if !event.requires_action {
continue;
}
let earned = self
.grades
.iter()
.find(|g| g.event_idx == idx)
.map(|g| g.score.points.min(100.0))
.unwrap_or(0.0);
total_earned += earned;
event_results.push((event.description.clone(), earned));
}
let total_score = (total_earned - self.hint_penalty).max(0.0);
let pass_fail = total_score >= self.config.minimum_pass_score / 100.0 * max_score.max(1.0);
let mut competency_gaps = Vec::new();
let mut recommended_training = Vec::new();
for (idx, event) in self.events.iter().enumerate() {
if !event.requires_action {
continue;
}
let earned = self
.grades
.iter()
.find(|g| g.event_idx == idx)
.map(|g| g.score.points)
.unwrap_or(0.0);
if earned < 70.0 {
let gap = match &event.event_type {
OtsEventType::GeneratorTrip { .. } => "Generation trip response",
OtsEventType::FrequencyAlert { .. } => "Frequency emergency management",
OtsEventType::VoltageAlert { .. } => "Voltage emergency response",
OtsEventType::LineTrip { .. } => "N-1 contingency clearance",
OtsEventType::ProtectionOperation { .. } => "Protection coordination",
OtsEventType::FaultCleared { .. } => "Post-fault restoration",
OtsEventType::ManualTrigger { .. } => "Procedure adherence",
OtsEventType::LoadStep { .. } => "Load management",
};
if !competency_gaps.contains(&gap.to_string()) {
competency_gaps.push(gap.to_string());
}
}
}
for gap in &competency_gaps {
let module = match gap.as_str() {
"Generation trip response" => "Module 3: Generator Outage Management",
"Frequency emergency management" => "Module 4: Frequency Control & UFLS",
"Voltage emergency response" => "Module 5: Reactive Power & Voltage Control",
"N-1 contingency clearance" => "Module 6: Contingency Analysis & Re-dispatch",
"Protection coordination" => "Module 7: Protection Systems",
"Post-fault restoration" => "Module 8: System Restoration",
"Procedure adherence" => "Module 2: Standard Operating Procedures",
"Load management" => "Module 9: Demand-Side Management",
_ => "Module 1: Grid Fundamentals Review",
};
if !recommended_training.contains(&module.to_string()) {
recommended_training.push(module.to_string());
}
}
if !pass_fail && recommended_training.is_empty() {
recommended_training.push("Module 1: Grid Fundamentals Review".to_string());
}
SessionReport {
trainee_id: self.trainee_id.clone(),
total_score,
max_score,
pass_fail,
event_results,
competency_gaps,
recommended_training,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn trip_session() -> TrainingSession {
TrainingSession::generator_trip_scenario("tst_trainee".to_string())
}
#[test]
fn test_generator_trip_event_at_correct_time() {
let mut session = trip_session();
let early = session.advance_time(25.0);
assert!(
early.is_empty(),
"no event expected before t=30 s, got {:?}",
early
);
let fired = session.advance_time(10.0); assert_eq!(fired.len(), 1, "expected exactly 1 event");
assert!(
matches!(&fired[0].event_type, OtsEventType::GeneratorTrip { unit_id, .. } if unit_id == "G3")
);
}
#[test]
fn test_correct_action_scores_100() {
let mut session = trip_session();
let _fired = session.advance_time(35.0);
let feedback = session.submit_action(
OtsOperatorAction::ActivateReserve {
reserve_type: "spinning".to_string(),
},
"spinning_reserve".to_string(),
None,
Some(0), false,
);
assert!(feedback.correct, "exact match should be correct");
assert!(
(feedback.partial_credit - 100.0).abs() < 0.1,
"expected 100 credit, got {}",
feedback.partial_credit
);
}
#[test]
fn test_wrong_action_scores_lower() {
let mut session = trip_session();
let _fired = session.advance_time(35.0);
let feedback = session.submit_action(
OtsOperatorAction::DecommitUnit {
unit_id: "G1".to_string(),
},
"G1".to_string(),
None,
Some(0),
false,
);
assert!(!feedback.correct, "dangerous action should not be correct");
assert!(
feedback.partial_credit < 50.0,
"dangerous action should score 0, got {}",
feedback.partial_credit
);
}
#[test]
fn test_hint_reduces_score() {
let mut session = trip_session();
let _fired = session.advance_time(35.0);
let hint_text = session.hint(0);
assert!(hint_text.is_some(), "hints should be available by default");
session.submit_action(
OtsOperatorAction::ActivateReserve {
reserve_type: "spinning".to_string(),
},
"spinning_reserve".to_string(),
None,
Some(0),
true,
);
let report = session.generate_session_report();
assert!(
report.total_score < 200.0,
"hint penalty should reduce total score below max"
);
assert!(
session.hint_penalty >= 10.0,
"hint penalty should be at least 10"
);
}
#[test]
fn test_time_based_scoring_late_action() {
let mut session = trip_session();
session.config.scoring_method = ScoringMethod::TimeBased;
let _fired = session.advance_time(35.0);
session.current_time_s = 230.0;
let score_late = session.evaluate_response(
0,
&OtsOperatorAction::ActivateReserve {
reserve_type: "spinning".to_string(),
},
);
let mut session2 = trip_session();
session2.config.scoring_method = ScoringMethod::TimeBased;
let _fired2 = session2.advance_time(35.0);
session2.current_time_s = 35.0;
let score_fast = session2.evaluate_response(
0,
&OtsOperatorAction::ActivateReserve {
reserve_type: "spinning".to_string(),
},
);
assert!(
score_fast.points >= score_late.points,
"fast response ({}) should score >= late response ({})",
score_fast.points,
score_late.points
);
}
#[test]
fn test_session_report_score() {
let mut session = trip_session();
let _e1 = session.advance_time(35.0);
session.submit_action(
OtsOperatorAction::ActivateReserve {
reserve_type: "spinning".to_string(),
},
"spinning_reserve".to_string(),
None,
Some(0),
false,
);
let _e2 = session.advance_time(65.0);
session.submit_action(
OtsOperatorAction::CommitUnit {
unit_id: "G5".to_string(),
},
"G5".to_string(),
None,
Some(1),
false,
);
let report = session.generate_session_report();
assert_eq!(report.max_score, 200.0, "two required events × 100 pts");
assert!(report.total_score > 0.0, "earned score should be positive");
}
#[test]
fn test_advance_time_events_returned() {
let mut session = trip_session();
let fired = session.advance_time(100.0);
assert_eq!(fired.len(), 2, "both events should fire in a 100 s window");
let fired2 = session.advance_time(100.0);
assert!(fired2.is_empty(), "events should not be delivered twice");
}
#[test]
fn test_system_response_commit_unit_frequency_rises() {
let mut session = trip_session();
session.system_state.frequency_hz = 49.0;
let action = OtsOperatorAction::CommitUnit {
unit_id: "G5".to_string(),
};
let snapshot = session.simulate_system_response(&action);
assert!(
snapshot.frequency_hz > 49.0,
"CommitUnit should raise frequency, got {:.3} Hz",
snapshot.frequency_hz
);
assert!(
snapshot.frequency_hz <= 50.0,
"frequency should not overshoot nominal"
);
}
#[test]
fn test_pass_fail_threshold() {
let mut session = trip_session();
let _e1 = session.advance_time(100.0); session.submit_action(
OtsOperatorAction::ActivateReserve {
reserve_type: "spinning".to_string(),
},
"spinning_reserve".to_string(),
None,
Some(0),
false,
);
session.submit_action(
OtsOperatorAction::CommitUnit {
unit_id: "G5".to_string(),
},
"G5".to_string(),
None,
Some(1),
false,
);
let report = session.generate_session_report();
assert!(
report.pass_fail,
"perfect score should pass (threshold 70 %)"
);
let mut fail_session = trip_session();
let _e2 = fail_session.advance_time(100.0);
let fail_report = fail_session.generate_session_report();
assert!(!fail_report.pass_fail, "zero score should fail");
}
#[test]
fn test_n1_contingency_scenario_basic() {
let mut session = TrainingSession::n1_contingency_scenario("trainee_n1".to_string());
let fired = session.advance_time(25.0);
assert_eq!(fired.len(), 1);
assert!(matches!(
&fired[0].event_type,
OtsEventType::LineTrip { branch_id } if branch_id == "L12"
));
}
#[test]
fn test_hint_disabled_returns_none() {
let mut session = trip_session();
session.config.hints_enabled = false;
let hint = session.hint(0);
assert!(hint.is_none(), "hints should be disabled");
assert_eq!(session.hint_penalty, 0.0, "no penalty when hints disabled");
}
#[test]
fn test_get_next_event() {
let mut session = trip_session();
assert!(
session.get_next_event().is_none(),
"no event due before time advance"
);
let _fired = session.advance_time(35.0);
let next = session.get_next_event();
assert!(next.is_some(), "after advance event should be available");
}
#[test]
fn test_activate_reserve_raises_frequency() {
let mut session = frequency_emergency_session();
let action = OtsOperatorAction::ActivateReserve {
reserve_type: "ufls".to_string(),
};
let before = session.system_state.frequency_hz;
let after = session.simulate_system_response(&action);
assert!(
after.frequency_hz > before,
"ActivateReserve should raise frequency from {:.3} Hz",
before
);
}
fn frequency_emergency_session() -> TrainingSession {
TrainingSession::frequency_emergency_scenario("trainee_freq".to_string())
}
#[test]
fn test_accuracy_only_no_time_bonus() {
let mut session = trip_session();
session.config.scoring_method = ScoringMethod::AccuracyOnly;
let _fired = session.advance_time(35.0);
session.current_time_s = 31.0;
let score = session.evaluate_response(
0,
&OtsOperatorAction::ActivateReserve {
reserve_type: "spinning".to_string(),
},
);
assert_eq!(
score.time_bonus, 0.0,
"AccuracyOnly scoring must yield zero time bonus, got {}",
score.time_bonus
);
assert!(
(score.points - 100.0).abs() < 1e-9,
"base points should be 100 for exact match, got {}",
score.points
);
}
#[test]
fn test_adjust_generation_partial_credit_same_unit() {
let mut session = TrainingSession::n1_contingency_scenario("trainee_adj".to_string());
let _fired = session.advance_time(25.0);
let score = session.evaluate_response(
0,
&OtsOperatorAction::AdjustGeneration {
unit_id: "G1".to_string(),
mw: 300.0,
},
);
assert!(
score.points > 0.0 && score.points < 100.0,
"same unit different MW should yield partial credit, got {:.1}",
score.points
);
assert!(
score.points <= 70.0,
"partial AdjustGeneration credit capped at 70, got {:.1}",
score.points
);
}
#[test]
fn test_commit_vs_activate_reserve_partial_credit() {
let mut session = trip_session();
let _fired = session.advance_time(35.0);
let score = session.evaluate_response(
0,
&OtsOperatorAction::CommitUnit {
unit_id: "G4".to_string(),
},
);
let expected = 100.0 * 0.5;
assert!(
(score.points - expected).abs() < 1.0,
"CommitUnit vs ActivateReserve should yield ~50 pts, got {:.1}",
score.points
);
}
#[test]
fn test_evaluate_response_invalid_event_index() {
let session = trip_session();
let score = session.evaluate_response(999, &OtsOperatorAction::DoNothing);
assert_eq!(
score.points, 0.0,
"invalid event index should yield 0 points"
);
assert!(
score.feedback.contains("999"),
"feedback should mention the missing index"
);
}
#[test]
fn test_system_snapshot_nominal() {
let snap = SystemSnapshot::nominal(5);
assert_eq!(snap.bus_voltages_pu.len(), 5, "should have 5 bus voltages");
assert!(
snap.bus_voltages_pu.iter().all(|&v| (v - 1.0).abs() < 1e-9),
"all voltages should be 1.0 pu"
);
assert_eq!(
snap.branch_loading_pct.len(),
4,
"branch count should be n_bus − 1"
);
assert!(
(snap.frequency_hz - 50.0).abs() < 1e-9,
"nominal frequency should be 50 Hz"
);
}
#[test]
fn test_close_switch_dips_frequency_open_switch_raises() {
let mut session = trip_session();
let initial_freq = session.system_state.frequency_hz;
let snap_close = session.simulate_system_response(&OtsOperatorAction::CloseSwitch {
switch_id: "SW1".to_string(),
});
assert!(
snap_close.frequency_hz < initial_freq,
"CloseSwitch should dip frequency from {:.3} Hz, got {:.3} Hz",
initial_freq,
snap_close.frequency_hz
);
let freq_after_close = snap_close.frequency_hz;
let snap_open = session.simulate_system_response(&OtsOperatorAction::OpenSwitch {
switch_id: "SW1".to_string(),
});
assert!(
snap_open.frequency_hz > freq_after_close,
"OpenSwitch should raise frequency from {:.3} Hz, got {:.3} Hz",
freq_after_close,
snap_open.frequency_hz
);
}
#[test]
fn test_frequency_emergency_scenario_structure_and_gaps() {
let session = frequency_emergency_session();
assert_eq!(
session.events.len(),
2,
"frequency emergency should have exactly 2 events"
);
assert!(
matches!(session.scenario_type, ScenarioType::EmergencyFrequency),
"scenario type should be EmergencyFrequency"
);
assert!(
(session.system_state.frequency_hz - 48.8).abs() < 1e-6,
"initial frequency should be 48.8 Hz"
);
let mut active = frequency_emergency_session();
let _fired = active.advance_time(120.0);
let report = active.generate_session_report();
assert!(
!report.competency_gaps.is_empty(),
"unanswered events should produce competency gaps"
);
assert!(
!report.recommended_training.is_empty(),
"competency gaps should map to recommended training modules"
);
assert!(
!report.pass_fail,
"zero score should not pass the frequency emergency scenario"
);
}
#[test]
fn test_issue_alert_partial_credit() {
let event = ScenarioEvent {
time_s: 5.0,
event_type: OtsEventType::ProtectionOperation {
relay_id: "R1".to_string(),
},
description: "Relay R1 operated.".to_string(),
requires_action: true,
correct_action: CorrectAction {
action_type: OtsOperatorAction::IssueAlert {
alert_type: "protection".to_string(),
},
target: "control_room".to_string(),
value: None,
rationale: "Notify control room of relay operation.".to_string(),
},
time_limit_s: 60.0,
};
let session = TrainingSession::new(
"ALERT-TEST".to_string(),
ScenarioType::Normal,
"trainee_alert".to_string(),
vec![event],
SystemSnapshot::nominal(3),
OtsConfig::default(),
);
let score = session.evaluate_response(
0,
&OtsOperatorAction::IssueAlert {
alert_type: "voltage".to_string(),
},
);
let expected = 100.0 * 0.6;
assert!(
(score.points - expected).abs() < 1.0,
"IssueAlert variant mismatch should yield ~60 pts, got {:.1}",
score.points
);
}
}