///|
/// One deduplicated outage candidate and its modeled operational impact.
pub(all) struct OutageScenarioScore {
failed_node_id : String
affected_asset_count : Int
affected_workstations_count : Int
capacity_loss_percent : Double
estimated_hourly_loss_cny : Double
resilience_score : Double
risk_band : String
} derive(Debug, Eq)
///|
/// Aggregate result for a batch of outage candidates.
pub(all) struct OutagePortfolioReport {
valid_scenario_count : Int
invalid_node_ids : Array[String]
scenarios : Array[OutageScenarioScore]
worst_case_node_id : String
max_capacity_loss_percent : Double
average_capacity_loss_percent : Double
} derive(Debug, Eq)
///|
fn risk_band(score : Double) -> String {
if score >= 80.0 {
"low"
} else if score >= 50.0 {
"moderate"
} else {
"high"
}
}
///|
fn scenario_score(
graph : @graph.FactoryGraph,
node_id : String,
hourly_downtime_cost : Double,
) -> OutageScenarioScore {
let result = simulate_device_outage(graph, node_id, hourly_downtime_cost~)
let resilience = if result.production_capacity_loss_percent >= 100.0 {
0.0
} else {
100.0 - result.production_capacity_loss_percent
}
{
failed_node_id: node_id,
affected_asset_count: result.all_impacted_nodes.length(),
affected_workstations_count: result.affected_workstations_count,
capacity_loss_percent: result.production_capacity_loss_percent,
estimated_hourly_loss_cny: result.estimated_hourly_loss_cny,
resilience_score: resilience,
risk_band: risk_band(resilience),
}
}
///|
/// Evaluate unique outage candidates while retaining unknown IDs for input QA.
///
/// Scenarios are evaluated in first-seen order. Duplicate IDs are ignored so
/// a caller can safely concatenate candidate lists from several asset teams.
pub fn evaluate_outage_portfolio(
graph : @graph.FactoryGraph,
candidate_node_ids : Array[String],
hourly_downtime_cost? : Double = 50000.0,
) -> OutagePortfolioReport {
let seen_valid : Map[String, Bool] = Map([])
let seen_invalid : Map[String, Bool] = Map([])
let scenarios = []
let invalid = []
for node_id in candidate_node_ids {
if graph.has_node(node_id) {
if !seen_valid.contains(node_id) {
seen_valid[node_id] = true
scenarios.push(scenario_score(graph, node_id, hourly_downtime_cost))
}
} else if !seen_invalid.contains(node_id) {
seen_invalid[node_id] = true
invalid.push(node_id)
}
}
let mut total_loss = 0.0
let mut max_loss = 0.0
let mut worst_case = ""
for scenario in scenarios {
total_loss = total_loss + scenario.capacity_loss_percent
if scenario.capacity_loss_percent > max_loss || worst_case == "" {
max_loss = scenario.capacity_loss_percent
worst_case = scenario.failed_node_id
}
}
let average_loss = if scenarios.length() > 0 {
total_loss / scenarios.length().to_double()
} else {
0.0
}
{
valid_scenario_count: scenarios.length(),
invalid_node_ids: invalid,
scenarios,
worst_case_node_id: worst_case,
max_capacity_loss_percent: max_loss,
average_capacity_loss_percent: average_loss,
}
}