rafia_stpa/stpa/
scenarios.rs

1// *****************************************************************************
2// Copyright (c) 2025 Codethink
3//
4// This program and the accompanying materials are made available under the
5// terms of the Eclipse Public License 2.0 which is available at
6// http://www.eclipse.org/legal/epl-2.0.
7//
8// This Source Code may also be made available under the following Secondary
9// Licenses when the conditions for such availability set forth in the Eclipse
10// Public License, v. 2.0 are satisfied: GNU General Public License, version 2
11// with the GNU Classpath Exception which is
12// available at https://www.gnu.org/software/classpath/license.html.
13//
14// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15// *****************************************************************************
16use std::{
17    ffi::OsStr,
18    fs::{self, File},
19    path::Path,
20};
21
22use csv;
23use serde::{Deserialize, Serialize};
24use serde_yaml;
25use strum::EnumIter;
26
27use crate::stpa::{LoadError, CYCLE_DETECTED_MSG};
28
29use super::{constraints::UcaLink, ConstraintLink, ControlLoopSequenceLink, HazardLink, StpaData};
30
31//"Scenario Id","Seq Ref","CS Type","Analysis result","Causal Scenario Definition","UCA Links","Link to Hazard(s)","Constraint Id","Notes"
32#[derive(Debug, Serialize, Deserialize)]
33pub struct ScenarioCsv {
34    #[serde(rename = "Scenario Id")]
35    pub id: String,
36    #[serde(rename = "Seq Ref")]
37    pub sequence: String,
38    #[serde(rename = "CS Type")]
39    pub causal_scenario: String,
40    #[serde(rename = "Analysis result")]
41    pub analysis_result: String,
42    #[serde(rename = "Causal Scenario Definition")]
43    pub causal_scenario_defintion: String,
44    #[serde(rename = "UCA Links")]
45    pub ucas: String,
46    #[serde(rename = "Link to Hazard(s)")]
47    pub hazards: String,
48    #[serde(rename = "Constraint Id")]
49    pub constraint: String,
50    #[serde(rename = "Notes")]
51    pub notes: String,
52}
53
54#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
55pub struct Scenario {
56    pub id: String,
57    pub sequence: ControlLoopSequenceLink,
58    pub causal_scenario: CausalScenario,
59    pub causal_scenario_prompt: String,
60    pub analysis_result: CausalScenarioResult,
61    pub causal_scenario_definition: String,
62    pub ucas: Vec<UcaLink>,
63    pub hazards: Vec<HazardLink>,
64    pub constraint: Vec<ConstraintLink>,
65    pub notes: String,
66}
67
68impl Scenario {
69    pub fn describe(
70        &self,
71        stpa_data: &StpaData,
72        currently_expanding: &mut Vec<String>,
73    ) -> Result<serde_json::Value, String> {
74        // check we aren't currently expanding this scenario
75        if currently_expanding.contains(&self.id) {
76            return Ok(serde_json::json!({
77                "scenario": {
78                    "id": self.id,
79                    "message": CYCLE_DETECTED_MSG,
80                }
81            }));
82        } else {
83            currently_expanding.push(self.id.clone());
84        }
85
86        let mut obj = serde_json::json!({"scenario": self});
87
88        obj["scenario"]["sequence"] =
89            stpa_data.describe_inner(self.sequence.0.as_ref(), currently_expanding)?;
90
91        let ucas = self
92            .constraint
93            .iter()
94            .map(|constraint| {
95                stpa_data
96                    .describe_inner(constraint.0.as_ref(), currently_expanding)
97                    .map_err(|e| {
98                        format!(
99                            "Failed while resolving constraint id {constraint} of uca {}: {e}",
100                            self.id
101                        )
102                    })
103            })
104            .collect::<Result<Vec<_>, _>>()?;
105
106        obj["scenario"]["constraint"] = serde_json::json!(ucas);
107
108        let hazards = self
109            .hazards
110            .iter()
111            .map(|hazard| {
112                stpa_data
113                    .describe_inner(hazard.0.as_ref(), currently_expanding)
114                    .map_err(|e| {
115                        format!(
116                            "Failed while resolving hazard id {hazard:?} of scenario {:?}: {e}",
117                            self.id
118                        )
119                    })
120            })
121            .collect::<Result<Vec<_>, _>>()?;
122        obj["scenario"]["hazards"] = serde_json::json!(hazards);
123
124        let ucas = self
125            .ucas
126            .iter()
127            .map(|uca| {
128                stpa_data
129                    .describe_inner(uca.as_ref(), currently_expanding)
130                    .map_err(|e| {
131                        format!(
132                            "Failed while resolving UCA id {uca:?} of scenario {:?}: {e}",
133                            self.id
134                        )
135                    })
136            })
137            .collect::<Result<Vec<_>, _>>()?;
138        obj["scenario"]["ucas"] = serde_json::json!(ucas);
139
140        let popped = currently_expanding.pop();
141        debug_assert_eq!(popped.as_ref(), Some(&self.id));
142        Ok(obj)
143    }
144}
145
146impl From<ScenarioCsv> for Scenario {
147    fn from(csv: ScenarioCsv) -> Self {
148        Scenario {
149            id: csv.id,
150            sequence: ControlLoopSequenceLink::from(csv.sequence),
151            causal_scenario: CausalScenario::from(csv.causal_scenario.as_str()),
152            causal_scenario_prompt: "".to_string(),
153            causal_scenario_definition: csv.causal_scenario_defintion,
154            analysis_result: CausalScenarioResult::from(csv.analysis_result.as_str()),
155            ucas: csv
156                .ucas
157                .split(",")
158                .filter(|e| !e.is_empty())
159                .map(|s| UcaLink::from(s.trim().to_string()))
160                .collect(),
161            hazards: csv
162                .hazards
163                .split(",")
164                .filter(|e| !e.is_empty())
165                .map(|s| HazardLink::from(s.trim().to_string()))
166                .collect(),
167            constraint: csv
168                .constraint
169                .split(",")
170                .filter(|e| !e.is_empty())
171                .map(|s| ConstraintLink::from(s.trim().to_string()))
172                .collect(),
173            notes: csv.notes,
174        }
175    }
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
179pub struct Scenarios {
180    pub scenarios: Vec<Scenario>,
181}
182
183impl Scenarios {
184    pub fn from_yaml(text: &str) -> Result<Scenarios, LoadError> {
185        serde_yaml::from_str::<Scenarios>(text).map_err(LoadError::Yaml)
186    }
187    pub fn to_yaml(&self) -> Result<String, LoadError> {
188        serde_yaml::to_string(&self).map_err(LoadError::Yaml)
189    }
190    pub fn from_csv(filename: &Path) -> Result<Scenarios, LoadError> {
191        let mut scenarios = vec![];
192        let file = File::open(filename)?;
193        for scenario in csv::ReaderBuilder::new()
194            .from_reader(file)
195            .deserialize::<ScenarioCsv>()
196        {
197            scenarios.push(scenario?.into());
198        }
199        Ok(Scenarios { scenarios })
200    }
201    pub fn from_file(filename: &Path) -> Result<Scenarios, LoadError> {
202        match filename.extension().and_then(OsStr::to_str) {
203            Some("csv") => Self::from_csv(filename),
204            Some("yml") | Some("yaml") => {
205                let text = fs::read_to_string(filename)?;
206                Self::from_yaml(&text)
207            }
208            _ => Err(LoadError::NotSupportedFormat),
209        }
210    }
211    pub fn find(&self, id: &str) -> Option<&Scenario> {
212        self.scenarios.iter().find(|scenario| scenario.id == id)
213    }
214}
215
216#[derive(Debug, Serialize, Deserialize, PartialEq, EnumIter, Clone, Copy)]
217pub enum CausalScenario {
218    Controller,
219    ControlAlgorithm,
220    UnsafetControlInput,
221    ProcessModel,
222    ControllerDisturbance,
223    Feedback,
224    FeedbackPath,
225    UnsafeDate,
226    ControlAction,
227    ControlPath,
228    Process,
229    ConflictingControl,
230    ProcessInputs,
231    ProcessOutputs,
232    ProcessDisturbance,
233    Missing,
234}
235
236impl From<&str> for CausalScenario {
237    fn from(raw: &str) -> Self {
238        match raw.trim() {
239            "CS1-C" => CausalScenario::Controller,
240            "CS1-A" => CausalScenario::ControlAlgorithm,
241            "CS1-I" => CausalScenario::UnsafetControlInput,
242            "CS1-M" => CausalScenario::ProcessModel,
243            "CS1-D" => CausalScenario::ControllerDisturbance,
244            "CS2-F" => CausalScenario::Feedback,
245            "CS2-P" => CausalScenario::FeedbackPath,
246            "CS2-U" => CausalScenario::UnsafeDate,
247            "CS3-A" => CausalScenario::ControlAction,
248            "CS3-P" => CausalScenario::ControlPath,
249            "CS4-C" => CausalScenario::ConflictingControl,
250            "CS4-I" => CausalScenario::ProcessInputs,
251            "CS4-O" => CausalScenario::ProcessOutputs,
252            "CS4-D" => CausalScenario::ProcessDisturbance,
253            _ => CausalScenario::Missing,
254        }
255    }
256}
257
258#[derive(Debug, Serialize, Deserialize, PartialEq, EnumIter, Clone, Copy)]
259pub enum CausalScenarioResult {
260    UCA,
261    Hazard,
262    UcaAndHazard,
263    OutOfScope,
264    ScenarioAlreadyFound,
265    NotApplicable,
266    NotYetAnalysed,
267    Missing,
268}
269
270impl From<&str> for CausalScenarioResult {
271    fn from(raw: &str) -> Self {
272        match raw.trim() {
273            "UCA" => CausalScenarioResult::UCA,
274            "Hazard" => CausalScenarioResult::Hazard,
275            "Both" => CausalScenarioResult::UcaAndHazard,
276            "OOS" => CausalScenarioResult::OutOfScope,
277            "SAF" => CausalScenarioResult::ScenarioAlreadyFound,
278            "N/A" => CausalScenarioResult::NotApplicable,
279            "TBD" | "" => CausalScenarioResult::NotYetAnalysed,
280            _ => CausalScenarioResult::Missing,
281        }
282    }
283}