Skip to main content

rscm_core/python/
component.rs

1/// Macros for exposing a component to Python and using python-defined modules in rust
2use crate::component::{Component, InputState, OutputState};
3use crate::errors::RSCMResult;
4use crate::python::state::{
5    PyFourBoxTimeseriesWindow, PyHemisphericTimeseriesWindow, PyStateValue, PyTimeseriesWindow,
6};
7use crate::timeseries::{FloatValue, Time};
8use crate::timeseries_collection::TimeseriesData;
9use pyo3::prelude::*;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::ffi::CString;
13use std::sync::Arc;
14// Reexport the Requirement Definition and GridType
15pub use crate::component::{GridType, RequirementDefinition, RequirementType};
16
17/// Create a component builder that can be used by python to instantiate components created Rust.
18#[macro_export]
19macro_rules! create_component_builder {
20    ($builder_name:ident, $rust_component:ty, $component_parameters:ty) => {
21        #[pyclass]
22        pub struct $builder_name {
23            parameters: $component_parameters,
24        }
25
26        #[pymethods]
27        impl $builder_name {
28            #[staticmethod]
29            pub fn from_parameters(parameters: Bound<PyAny>) -> PyResult<Self> {
30                use pyo3::exceptions::PyValueError;
31
32                // todo: figure out how to use an attrs class as parameters instead of a dict
33                let parameters = pythonize::depythonize::<$component_parameters>(&parameters);
34                match parameters {
35                    Ok(parameters) => Ok(Self { parameters }),
36                    Err(e) => Err(PyValueError::new_err(format!("{}", e))),
37                }
38            }
39            pub fn build(&self) -> PyRustComponent {
40                PyRustComponent(std::sync::Arc::new(<$rust_component>::from_parameters(
41                    self.parameters.clone(),
42                )))
43            }
44        }
45    };
46}
47
48/// Expose component-related functionality to python
49#[macro_export]
50macro_rules! impl_component {
51    ($py_component:ty) => {
52        #[pymethods]
53        impl $py_component {
54            fn definitions(&self) -> Vec<RequirementDefinition> {
55                self.0.definitions()
56            }
57
58            fn input_names(&self) -> Vec<String> {
59                self.0.input_names()
60            }
61
62            fn output_names(&self) -> Vec<String> {
63                self.0.output_names()
64            }
65
66            pub fn solve(
67                &mut self,
68                t_current: Time,
69                t_next: Time,
70                collection: $crate::python::timeseries_collection::PyTimeseriesCollection,
71            ) -> PyResult<HashMap<String, $crate::python::state::PyStateValue>> {
72                let input_state =
73                    $crate::model::extract_state(&collection.0, self.0.input_names(), t_current);
74
75                let output_state = self.0.solve(t_current, t_next, &input_state)?;
76                // Return StateValue wrapped in PyStateValue for Python interoperability
77                let py_output = output_state
78                    .into_iter()
79                    .map(|(key, state_value)| {
80                        (key, $crate::python::state::PyStateValue(state_value))
81                    })
82                    .collect();
83                Ok(py_output)
84            }
85        }
86    };
87}
88
89#[pymethods]
90impl RequirementDefinition {
91    #[new]
92    #[pyo3(signature = (name, unit, requirement_type, grid_type=GridType::Scalar))]
93    pub fn new_python(
94        name: String,
95        unit: String,
96        requirement_type: RequirementType,
97        grid_type: GridType,
98    ) -> Self {
99        Self {
100            name,
101            unit,
102            requirement_type,
103            grid_type,
104        }
105    }
106}
107
108/// Python wrapper for a Component defined in Rust
109///
110/// Instances of ['PyRustComponent'] are created via an associated ComponentBuilder for each
111/// component of interest.
112#[derive(Debug, Clone)]
113#[pyclass]
114#[pyo3{name = "RustComponent"}]
115pub struct PyRustComponent(pub Arc<dyn Component + Send + Sync>);
116
117impl_component!(PyRustComponent);
118
119/// Wrapper to convert a Py<PyAny> (Python Class) into a Component
120#[derive(Debug)]
121pub struct PythonComponent {
122    pub component: Py<PyAny>,
123}
124
125#[typetag::serde]
126impl Component for PythonComponent {
127    fn definitions(&self) -> Vec<RequirementDefinition> {
128        Python::attach(|py| {
129            let py_result = self
130                .component
131                .bind(py)
132                .call_method("definitions", (), None)
133                .unwrap();
134            let py_result: Vec<RequirementDefinition> = py_result.extract().unwrap();
135            py_result
136        })
137    }
138
139    fn solve(
140        &self,
141        t_current: Time,
142        t_next: Time,
143        input_state: &InputState,
144    ) -> RSCMResult<OutputState> {
145        Python::attach(|py| {
146            let component = self.component.bind(py);
147
148            // Check if this is a typed component (has _component_inputs attribute)
149            let is_typed = component.hasattr("_component_inputs").unwrap_or(false);
150
151            let py_result = if is_typed {
152                // New typed component - pass TimeseriesWindow dict
153                let windows = input_state_to_py_windows(py, input_state)
154                    .expect("Failed to create Python window objects");
155
156                // Get the Inputs class and construct typed inputs
157                let inputs_class = component.getattr("Inputs").unwrap();
158                let typed_inputs = inputs_class
159                    .call_method("from_input_state", (windows,), None)
160                    .unwrap();
161
162                let result = component
163                    .call_method("solve", (t_current, t_next, typed_inputs), None)
164                    .unwrap();
165
166                // Convert typed outputs to dict
167                result.call_method("to_dict", (), None).unwrap()
168            } else {
169                // Legacy component - pass raw hashmap
170                component
171                    .call_method(
172                        "solve",
173                        (t_current, t_next, input_state.clone().to_hashmap()),
174                        None,
175                    )
176                    .unwrap()
177            };
178
179            // Extract the result dict
180            let py_dict = py_result
181                .cast::<pyo3::types::PyDict>()
182                .expect("solve() must return a dict");
183
184            let mut output_state = OutputState::new();
185            for (key, value) in py_dict.iter() {
186                let key: String = key.extract().expect("dict key must be a string");
187
188                // Try to extract as PyStateValue first
189                let state_value = if let Ok(py_state_value) = value.extract::<PyStateValue>() {
190                    py_state_value.0
191                } else if let Ok(float_value) = value.extract::<FloatValue>() {
192                    // Legacy component returning raw floats
193                    crate::state::StateValue::Scalar(float_value)
194                } else {
195                    panic!(
196                        "Component output value for '{}' must be StateValue or float, got: {:?}",
197                        key, value
198                    );
199                };
200
201                output_state.insert(key, state_value);
202            }
203            Ok(output_state)
204        })
205    }
206}
207
208impl Serialize for PythonComponent {
209    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
210    where
211        S: serde::Serializer,
212    {
213        Python::attach(|py| {
214            let py_result = self
215                .component
216                .bind(py)
217                .call_method("to_json", (), None)
218                .unwrap();
219            let py_result: String = py_result.extract().unwrap();
220            serializer.serialize_str(&py_result)
221        })
222    }
223}
224
225impl<'de> Deserialize<'de> for PythonComponent {
226    fn deserialize<D>(deserializer: D) -> Result<PythonComponent, D::Error>
227    where
228        D: serde::Deserializer<'de>,
229    {
230        let s: String = Deserialize::deserialize(deserializer)?;
231        Python::attach(|py| {
232            let code = CString::new(format!("Component.from_json('{}')", s)).unwrap();
233            let component = py.eval(code.as_c_str(), None, None).unwrap().unbind();
234            Ok(PythonComponent { component })
235        })
236    }
237}
238
239/// Interface for creating Components from Python
240#[pyclass]
241#[pyo3(name = "PythonComponent")]
242pub struct PyPythonComponent(pub Arc<PythonComponent>);
243
244#[pymethods]
245impl PyPythonComponent {
246    #[staticmethod]
247    pub fn build(component: Py<PyAny>) -> Self {
248        Self(Arc::new(PythonComponent { component }))
249    }
250}
251
252impl_component!(PyPythonComponent);
253
254/// Convert an InputState to a Python dict of TimeseriesWindow objects
255///
256/// This creates a dictionary mapping variable names to typed TimeseriesWindow
257/// objects that provide access to current, previous, and historical values.
258fn input_state_to_py_windows(py: Python<'_>, input_state: &InputState) -> PyResult<Py<PyAny>> {
259    let dict = pyo3::types::PyDict::new(py);
260    let current_time = input_state.current_time();
261
262    for item in input_state.clone().into_iter() {
263        let name = &item.name;
264        // Use the actual current time to determine the index, not the latest populated index
265        let current_index = item
266            .data
267            .index_of_time(current_time)
268            .unwrap_or_else(|| item.data.latest());
269
270        let window: Py<PyAny> = match &item.data {
271            TimeseriesData::Scalar(ts) => {
272                // Extract scalar values up to and including current_index
273                let values: Vec<FloatValue> = (0..=current_index)
274                    .filter_map(|i| ts.at_scalar(i))
275                    .collect();
276                PyTimeseriesWindow::new(values, current_index)
277                    .map(|w| w.into_pyobject(py).unwrap().into_any().unbind())?
278            }
279            TimeseriesData::FourBox(ts) => {
280                // Extract all regional values for each timestep
281                let values: Vec<[FloatValue; 4]> = (0..=current_index)
282                    .filter_map(|i| {
283                        ts.at_time_index(i)
284                            .map(|vals| [vals[0], vals[1], vals[2], vals[3]])
285                    })
286                    .collect();
287                PyFourBoxTimeseriesWindow::new(values, current_index)
288                    .map(|w| w.into_pyobject(py).unwrap().into_any().unbind())?
289            }
290            TimeseriesData::Hemispheric(ts) => {
291                // Extract all regional values for each timestep
292                let values: Vec<[FloatValue; 2]> = (0..=current_index)
293                    .filter_map(|i| ts.at_time_index(i).map(|vals| [vals[0], vals[1]]))
294                    .collect();
295                PyHemisphericTimeseriesWindow::new(values, current_index)
296                    .map(|w| w.into_pyobject(py).unwrap().into_any().unbind())?
297            }
298        };
299
300        dict.set_item(name, window)?;
301    }
302
303    Ok(dict.into_any().unbind())
304}