1use std::sync::LazyLock;
19
20use ahash::AHashMap;
21use jiff::civil::{Time, Weekday};
22use nautilus_core::python::{
23 clone_py_object, to_pynotimplemented_err, to_pytype_err, to_pyvalue_err,
24};
25use nautilus_model::{
26 data::Data,
27 identifiers::{InstrumentId, Venue},
28 instruments::{Instrument, InstrumentAny},
29 orderbook::OrderBook,
30 position::Position,
31 python::{data::data_to_pyobject, instruments::instrument_any_to_pyobject},
32 types::{Currency, Money},
33};
34use parking_lot::Mutex;
35use pyo3::{
36 PyClass,
37 prelude::*,
38 types::{PyDict, PyTuple},
39};
40use rust_decimal::Decimal;
41
42use crate::modules::{
43 AccountAdjustmentOutcome, CfdSwapModule, CfdSwapRate, ExchangeContext,
44 FXRolloverInterestModule, SimulationModule, SimulationModuleAny, SimulationModuleHandle,
45 SimulationModuleResult, fx_rollover::InterestRateRecord,
46};
47
48pub type SimulationModuleExtractor =
50 for<'py> fn(Python<'py>, &Bound<'py, PyAny>) -> PyResult<SimulationModuleHandle>;
51
52static SIMULATION_MODULE_EXTRACTORS: LazyLock<Mutex<AHashMap<usize, SimulationModuleExtractor>>> =
53 LazyLock::new(|| Mutex::new(AHashMap::new()));
54
55pub fn register_simulation_module_extractor<T: PyClass>(
63 py: Python<'_>,
64 extractor: SimulationModuleExtractor,
65) -> anyhow::Result<()> {
66 let type_object = py.get_type::<T>();
67 let type_id = type_object.as_ptr() as usize;
68 let type_name = type_object.name()?;
69 let mut extractors = SIMULATION_MODULE_EXTRACTORS.lock();
70 if let Some(registered) = extractors.get(&type_id) {
71 if std::ptr::fn_addr_eq(*registered, extractor) {
72 return Ok(());
73 }
74 anyhow::bail!(
75 "A different simulation module extractor is already registered for '{type_name}'"
76 );
77 }
78 extractors.insert(type_id, extractor);
79 Ok(())
80}
81
82#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")]
83#[pyclass(
84 module = "nautilus_trader.backtest",
85 name = "SimulationModule",
86 subclass
87)]
88#[derive(Debug)]
89pub struct PySimulationModule;
90
91#[pyo3_stub_gen::derive::gen_stub_pymethods]
92#[pymethods]
93#[allow(
94 clippy::unused_self,
95 reason = "PyO3 exposes these hooks as overridable instance methods"
96)]
97impl PySimulationModule {
98 #[new]
99 #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
100 #[pyo3(signature = (*_args, **_kwargs))]
101 fn py_new(_args: &Bound<'_, PyTuple>, _kwargs: Option<&Bound<'_, PyDict>>) -> Self {
102 Self
103 }
104
105 fn pre_process(&self, _data: &Bound<'_, PyAny>) {}
106
107 fn process(
108 &self,
109 _ts_now: u64,
110 _context: &PySimulationModuleContext,
111 ) -> PyResult<Option<Vec<Money>>> {
112 Err(to_pynotimplemented_err(
113 "Method 'process' must be implemented in a subclass.",
114 ))
115 }
116
117 fn acknowledge(&self, _outcomes: &Bound<'_, PyAny>) {}
118
119 fn log_diagnostics(&self) {}
120
121 fn reset(&self) {}
122}
123
124#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")]
126#[pyclass(
127 module = "nautilus_trader.backtest",
128 name = "SimulationModuleContext",
129 frozen,
130 unsendable
131)]
132#[derive(Debug)]
133pub struct PySimulationModuleContext {
134 venue: Venue,
135 base_currency: Option<Currency>,
136 instruments: Vec<InstrumentAny>,
137 order_books: Vec<OrderBook>,
138 positions: Vec<Position>,
139}
140
141impl PySimulationModuleContext {
142 fn from_exchange(ctx: &ExchangeContext<'_>) -> Self {
143 let mut instruments = ctx.instruments.values().cloned().collect::<Vec<_>>();
144 instruments.sort_unstable_by_key(Instrument::id);
145
146 let mut order_books = ctx
147 .matching_engines
148 .values()
149 .map(|engine| engine.get_book().clone())
150 .collect::<Vec<_>>();
151 order_books.sort_unstable_by_key(|book| book.instrument_id);
152
153 let mut positions = ctx
154 .cache
155 .positions_open(Some(&ctx.venue), None, None, None, None)
156 .into_iter()
157 .map(|position| (*position).clone())
158 .collect::<Vec<_>>();
159 positions.sort_unstable_by_key(|position| position.id);
160
161 Self {
162 venue: ctx.venue,
163 base_currency: ctx.base_currency,
164 instruments,
165 order_books,
166 positions,
167 }
168 }
169}
170
171#[pyo3_stub_gen::derive::gen_stub_pymethods]
172#[pymethods]
173impl PySimulationModuleContext {
174 #[getter]
175 fn venue(&self) -> Venue {
176 self.venue
177 }
178
179 #[getter]
180 fn base_currency(&self) -> Option<Currency> {
181 self.base_currency
182 }
183
184 #[getter]
185 fn instruments(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
186 self.instruments
187 .iter()
188 .cloned()
189 .map(|instrument| instrument_any_to_pyobject(py, instrument))
190 .collect()
191 }
192
193 #[getter]
194 fn order_books(&self) -> Vec<OrderBook> {
195 self.order_books.clone()
196 }
197
198 #[getter]
199 fn positions(&self) -> Vec<Position> {
200 self.positions.clone()
201 }
202}
203
204#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.backtest")]
206#[pyclass(
207 module = "nautilus_trader.backtest",
208 name = "AccountAdjustmentOutcome",
209 frozen,
210 skip_from_py_object
211)]
212#[derive(Debug, Clone)]
213pub struct PyAccountAdjustmentOutcome {
214 applied: bool,
215 error: Option<String>,
216}
217
218impl From<&AccountAdjustmentOutcome> for PyAccountAdjustmentOutcome {
219 fn from(outcome: &AccountAdjustmentOutcome) -> Self {
220 match outcome {
221 AccountAdjustmentOutcome::Applied => Self {
222 applied: true,
223 error: None,
224 },
225 AccountAdjustmentOutcome::Failed(error) => Self {
226 applied: false,
227 error: Some(error.to_string()),
228 },
229 }
230 }
231}
232
233#[pyo3_stub_gen::derive::gen_stub_pymethods]
234#[pymethods]
235impl PyAccountAdjustmentOutcome {
236 #[getter]
237 const fn applied(&self) -> bool {
238 self.applied
239 }
240
241 #[getter]
242 fn error(&self) -> Option<String> {
243 self.error.clone()
244 }
245}
246
247#[derive(Debug)]
248pub struct PythonSimulationModule {
249 obj: Py<PyAny>,
250}
251
252impl Clone for PythonSimulationModule {
253 fn clone(&self) -> Self {
254 Self::new(clone_py_object(&self.obj))
255 }
256}
257
258impl PythonSimulationModule {
259 #[must_use]
260 pub const fn new(obj: Py<PyAny>) -> Self {
261 Self { obj }
262 }
263
264 pub(crate) fn clone_ref(&self, py: Python<'_>) -> Py<PyAny> {
265 self.obj.clone_ref(py)
266 }
267}
268
269impl SimulationModule for PythonSimulationModule {
270 fn pre_process(&self, data: &Data) -> anyhow::Result<()> {
271 Python::attach(|py| -> anyhow::Result<()> {
272 let data = data_to_pyobject(py, data.clone())?;
273 self.obj.bind(py).call_method1("pre_process", (data,))?;
274 Ok(())
275 })
276 .map_err(|e| anyhow::anyhow!("Python SimulationModule.pre_process failed: {e}"))
277 }
278
279 fn process(
280 &self,
281 ts_now: nautilus_core::UnixNanos,
282 ctx: &ExchangeContext,
283 ) -> anyhow::Result<SimulationModuleResult> {
284 Python::attach(|py| -> anyhow::Result<SimulationModuleResult> {
285 let context = Py::new(py, PySimulationModuleContext::from_exchange(ctx))?;
286 let adjustments = self
287 .obj
288 .bind(py)
289 .call_method1("process", (ts_now.as_u64(), context))?
290 .extract::<Option<Vec<Money>>>()?;
291 Ok(adjustments.map_or(
292 SimulationModuleResult::NotReady,
293 SimulationModuleResult::Completed,
294 ))
295 })
296 .map_err(|e| anyhow::anyhow!("Python SimulationModule.process failed: {e}"))
297 }
298
299 fn acknowledge(&self, outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()> {
300 Python::attach(|py| -> anyhow::Result<()> {
301 let outcomes = outcomes
302 .iter()
303 .map(PyAccountAdjustmentOutcome::from)
304 .collect::<Vec<_>>();
305 self.obj.bind(py).call_method1("acknowledge", (outcomes,))?;
306 Ok(())
307 })
308 .map_err(|e| anyhow::anyhow!("Python SimulationModule.acknowledge failed: {e}"))
309 }
310
311 fn log_diagnostics(&self) -> anyhow::Result<()> {
312 Python::attach(|py| -> anyhow::Result<()> {
313 self.obj.bind(py).call_method0("log_diagnostics")?;
314 Ok(())
315 })
316 .map_err(|e| anyhow::anyhow!("Python SimulationModule.log_diagnostics failed: {e}"))
317 }
318
319 fn reset(&self) -> anyhow::Result<()> {
320 Python::attach(|py| -> anyhow::Result<()> {
321 self.obj.bind(py).call_method0("reset")?;
322 Ok(())
323 })
324 .map_err(|e| anyhow::anyhow!("Python SimulationModule.reset failed: {e}"))
325 }
326}
327
328fn pyobject_to_builtin_simulation_module_any(
329 obj: &Bound<'_, PyAny>,
330) -> Option<SimulationModuleAny> {
331 if let Ok(module) = obj.extract::<PyRef<'_, CfdSwapModule>>() {
332 return Some(SimulationModuleAny::CfdSwap((*module).clone()));
333 }
334
335 if let Ok(module) = obj.extract::<PyRef<'_, FXRolloverInterestModule>>() {
336 return Some(SimulationModuleAny::FXRolloverInterest((*module).clone()));
337 }
338 None
339}
340
341pub fn pyobject_to_simulation_module_any(obj: &Bound<'_, PyAny>) -> PyResult<SimulationModuleAny> {
347 if let Some(module) = pyobject_to_builtin_simulation_module_any(obj) {
348 return Ok(module);
349 }
350
351 if obj.is_instance_of::<PySimulationModule>() {
352 return Ok(SimulationModuleAny::Python(PythonSimulationModule::new(
353 obj.clone().unbind(),
354 )));
355 }
356
357 let type_name = obj.get_type().name()?;
358 Err(to_pytype_err(format!(
359 "Cannot convert {type_name} to SimulationModule"
360 )))
361}
362
363pub fn pyobject_to_simulation_module_handle(
371 py: Python<'_>,
372 obj: &Bound<'_, PyAny>,
373) -> PyResult<SimulationModuleHandle> {
374 if let Some(module) = pyobject_to_builtin_simulation_module_any(obj) {
375 return Ok(module.into());
376 }
377
378 let type_object = obj.get_type();
379 let type_id = type_object.as_ptr() as usize;
380 let extractor = SIMULATION_MODULE_EXTRACTORS.lock().get(&type_id).copied();
381
382 if let Some(extractor) = extractor {
383 return extractor(py, obj);
384 }
385
386 if obj.is_instance_of::<PySimulationModule>() {
387 return Ok(SimulationModuleHandle::new(PythonSimulationModule::new(
388 obj.clone().unbind(),
389 )));
390 }
391
392 Err(to_pytype_err(format!(
393 "Cannot convert {} to SimulationModule",
394 type_object.name()?
395 )))
396}
397
398pub fn simulation_module_any_to_pyobject(
404 py: Python<'_>,
405 module: &SimulationModuleAny,
406) -> PyResult<Py<PyAny>> {
407 match module {
408 SimulationModuleAny::CfdSwap(module) => Ok(Py::new(
409 py,
410 PyClassInitializer::from(PySimulationModule).add_subclass(module.clone()),
411 )?
412 .into_any()),
413 SimulationModuleAny::FXRolloverInterest(module) => Ok(Py::new(
414 py,
415 PyClassInitializer::from(PySimulationModule).add_subclass(module.clone()),
416 )?
417 .into_any()),
418 SimulationModuleAny::Python(module) => Ok(module.clone_ref(py)),
419 }
420}
421
422#[pyo3_stub_gen::derive::gen_stub_pymethods]
423#[pymethods]
424impl InterestRateRecord {
425 #[new]
427 fn py_new(location: String, time: String, value: f64) -> PyResult<Self> {
428 let record = Self {
429 location,
430 time,
431 value,
432 };
433 record.validate().map_err(to_pyvalue_err)?;
434 Ok(record)
435 }
436
437 fn __repr__(&self) -> String {
438 format!("{self:?}")
439 }
440}
441
442#[pyo3_stub_gen::derive::gen_stub_pymethods]
443#[pymethods]
444impl FXRolloverInterestModule {
445 #[new]
451 #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
452 fn py_new(records: Vec<InterestRateRecord>) -> PyResult<PyClassInitializer<Self>> {
453 let module = Self::new(records).map_err(to_pyvalue_err)?;
454 Ok(PyClassInitializer::from(PySimulationModule).add_subclass(module))
455 }
456
457 fn __repr__(&self) -> String {
458 format!("{self:?}")
459 }
460}
461
462#[pyo3_stub_gen::derive::gen_stub_pymethods]
463#[pymethods]
464impl CfdSwapRate {
465 #[new]
467 fn py_new(instrument_id: InstrumentId, long_rate: Decimal, short_rate: Decimal) -> Self {
468 Self::new(instrument_id, long_rate, short_rate)
469 }
470
471 #[getter]
472 fn instrument_id(&self) -> InstrumentId {
473 self.instrument_id
474 }
475
476 #[getter]
477 fn long_rate(&self) -> Decimal {
478 self.long_rate
479 }
480
481 #[getter]
482 fn short_rate(&self) -> Decimal {
483 self.short_rate
484 }
485
486 fn __repr__(&self) -> String {
487 format!("{self:?}")
488 }
489}
490
491#[pyo3_stub_gen::derive::gen_stub_pymethods]
492#[pymethods]
493impl CfdSwapModule {
494 #[new]
496 #[gen_stub(override_return_type(type_repr = "typing.Self", imports = ("typing",)))]
497 #[pyo3(signature = (rates, rollover_hour=17, rollover_minute=0, triple_roll_weekday=5))]
498 fn py_new(
499 rates: Vec<CfdSwapRate>,
500 rollover_hour: i8,
501 rollover_minute: i8,
502 triple_roll_weekday: i8,
503 ) -> PyResult<PyClassInitializer<Self>> {
504 let rollover_time =
505 Time::new(rollover_hour, rollover_minute, 0, 0).map_err(to_pyvalue_err)?;
506 let triple_roll_weekday =
507 Weekday::from_monday_one_offset(triple_roll_weekday).map_err(to_pyvalue_err)?;
508 Ok(
509 PyClassInitializer::from(PySimulationModule).add_subclass(Self::new(
510 rates,
511 rollover_time,
512 triple_roll_weekday,
513 )),
514 )
515 }
516
517 fn __repr__(&self) -> String {
518 format!("{self:?}")
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use std::{cell::Cell, rc::Rc};
525
526 use indexmap::IndexMap;
527 use nautilus_common::cache::Cache;
528 use nautilus_model::{
529 data::Data,
530 enums::{AccountType, BookType, OmsType},
531 identifiers::Venue,
532 types::{Currency, Money},
533 };
534 use pyo3::{IntoPyObjectExt, exceptions::PyAttributeError, ffi::c_str, types::PyDict};
535 use rstest::rstest;
536
537 use super::*;
538 use crate::{
539 config::{BacktestEngineConfig, SimulatedVenueConfig},
540 engine::BacktestEngine,
541 };
542
543 fn with_empty_context<T>(f: impl FnOnce(&ExchangeContext<'_>) -> T) -> T {
544 let instruments = AHashMap::new();
545 let matching_engines = IndexMap::new();
546 let cache = Cache::default();
547 f(&ExchangeContext {
548 venue: Venue::new("SIM"),
549 base_currency: Some(Currency::USD()),
550 instruments: &instruments,
551 matching_engines: &matching_engines,
552 cache: &cache,
553 })
554 }
555
556 #[rstest]
557 fn test_pure_python_simulation_module_dispatch() {
558 Python::initialize();
559
560 Python::attach(|py| {
561 let locals = PyDict::new(py);
562 locals
563 .set_item("SimulationModule", py.get_type::<PySimulationModule>())
564 .unwrap();
565 let module = py
566 .eval(
567 c_str!(
568 "type('PurePythonSimulationModule', (SimulationModule,), {\
569 'process': lambda self, ts_now, context: \
570 (setattr(self, 'context', context), [self.adjustment])[1]\
571 })()"
572 ),
573 None,
574 Some(&locals),
575 )
576 .unwrap();
577 module
578 .setattr(
579 "adjustment",
580 Money::from("1.25 USD").into_py_any(py).unwrap(),
581 )
582 .unwrap();
583
584 assert!(matches!(
585 pyobject_to_simulation_module_any(&module).unwrap(),
586 SimulationModuleAny::Python(_)
587 ));
588 let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
589 let result =
590 with_empty_context(|ctx| handle.process(nautilus_core::UnixNanos::from(10), ctx))
591 .unwrap();
592 handle
593 .acknowledge(&[AccountAdjustmentOutcome::Applied])
594 .unwrap();
595 handle.reset().unwrap();
596
597 let context = module.getattr("context").unwrap();
598
599 assert_eq!(
600 result,
601 SimulationModuleResult::Completed(vec![Money::from("1.25 USD")])
602 );
603 assert_eq!(
604 context
605 .getattr("venue")
606 .unwrap()
607 .extract::<Venue>()
608 .unwrap(),
609 Venue::new("SIM")
610 );
611 assert_eq!(
612 context
613 .getattr("base_currency")
614 .unwrap()
615 .extract::<Option<Currency>>()
616 .unwrap(),
617 Some(Currency::USD())
618 );
619 assert_eq!(context.getattr("instruments").unwrap().len().unwrap(), 0);
620 assert_eq!(context.getattr("order_books").unwrap().len().unwrap(), 0);
621 assert_eq!(context.getattr("positions").unwrap().len().unwrap(), 0);
622 let error = context.setattr("venue", Venue::new("OTHER")).unwrap_err();
623 assert!(error.is_instance_of::<PyAttributeError>(py));
624 });
625 }
626
627 #[derive(Debug)]
628 struct NativeRustSimulationModule {
629 calls: Rc<Cell<u32>>,
630 }
631
632 impl SimulationModule for NativeRustSimulationModule {
633 fn pre_process(&self, _data: &Data) -> anyhow::Result<()> {
634 Ok(())
635 }
636
637 fn process(
638 &self,
639 _ts_now: nautilus_core::UnixNanos,
640 _ctx: &ExchangeContext,
641 ) -> anyhow::Result<SimulationModuleResult> {
642 self.calls.set(self.calls.get() + 1);
643 Ok(SimulationModuleResult::NotReady)
644 }
645
646 fn acknowledge(&self, _outcomes: &[AccountAdjustmentOutcome]) -> anyhow::Result<()> {
647 Ok(())
648 }
649
650 fn log_diagnostics(&self) -> anyhow::Result<()> {
651 Ok(())
652 }
653
654 fn reset(&self) -> anyhow::Result<()> {
655 Ok(())
656 }
657 }
658
659 #[pyclass(
660 name = "NativeSimulationModuleTest",
661 module = "native_simulation_module_test.registered",
662 unsendable
663 )]
664 #[derive(Debug)]
665 struct NativeSimulationModuleBinding {
666 calls: Rc<Cell<u32>>,
667 }
668
669 #[pyclass(
670 name = "NativeSimulationModuleTest",
671 module = "native_simulation_module_test.unregistered",
672 unsendable
673 )]
674 #[derive(Debug)]
675 struct UnregisteredNativeSimulationModuleBinding;
676
677 fn extract_native_simulation_module(
678 _py: Python<'_>,
679 obj: &Bound<'_, PyAny>,
680 ) -> PyResult<SimulationModuleHandle> {
681 let binding = obj.extract::<PyRef<'_, NativeSimulationModuleBinding>>()?;
682 Ok(SimulationModuleHandle::new(NativeRustSimulationModule {
683 calls: binding.calls.clone(),
684 }))
685 }
686
687 #[rstest]
688 fn test_registered_native_simulation_module_dispatch() {
689 Python::initialize();
690
691 Python::attach(|py| {
692 register_simulation_module_extractor::<NativeSimulationModuleBinding>(
693 py,
694 extract_native_simulation_module,
695 )
696 .unwrap();
697 let calls = Rc::new(Cell::new(0));
698 let binding = Py::new(
699 py,
700 NativeSimulationModuleBinding {
701 calls: calls.clone(),
702 },
703 )
704 .unwrap();
705 let handle =
706 pyobject_to_simulation_module_handle(py, binding.bind(py).as_any()).unwrap();
707
708 let result =
709 with_empty_context(|ctx| handle.process(nautilus_core::UnixNanos::from(10), ctx))
710 .unwrap();
711
712 assert_eq!(result, SimulationModuleResult::NotReady);
713 assert_eq!(calls.get(), 1);
714 });
715 }
716
717 #[rstest]
718 fn test_registered_native_simulation_module_uses_exact_python_type() {
719 Python::initialize();
720
721 Python::attach(|py| {
722 register_simulation_module_extractor::<NativeSimulationModuleBinding>(
723 py,
724 extract_native_simulation_module,
725 )
726 .unwrap();
727 let binding = Py::new(py, UnregisteredNativeSimulationModuleBinding).unwrap();
728
729 let error =
730 pyobject_to_simulation_module_handle(py, binding.bind(py).as_any()).unwrap_err();
731
732 assert_eq!(
733 error.to_string(),
734 "TypeError: Cannot convert NativeSimulationModuleTest to SimulationModule"
735 );
736 });
737 }
738
739 fn engine_with_module(module: SimulationModuleHandle) -> BacktestEngine {
740 let mut engine = BacktestEngine::new(BacktestEngineConfig::default()).unwrap();
741 engine
742 .add_venue(
743 SimulatedVenueConfig::builder()
744 .venue(Venue::new("SIM"))
745 .oms_type(OmsType::Netting)
746 .account_type(AccountType::Margin)
747 .book_type(BookType::L1_MBP)
748 .starting_balances(vec![Money::from("1000 USD")])
749 .modules(vec![module])
750 .build()
751 .unwrap(),
752 )
753 .unwrap();
754 engine
755 }
756
757 #[rstest]
758 fn test_python_simulation_module_exception_propagates_through_run() {
759 Python::initialize();
760
761 Python::attach(|py| {
762 let locals = PyDict::new(py);
763 locals
764 .set_item("SimulationModule", py.get_type::<PySimulationModule>())
765 .unwrap();
766 let module = py
767 .eval(
768 c_str!(
769 "type('FailingSimulationModule', (SimulationModule,), {\
770 'process': lambda self, ts_now, context: \
771 (_ for _ in ()).throw(ValueError('module boom'))\
772 })()"
773 ),
774 None,
775 Some(&locals),
776 )
777 .unwrap();
778 let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
779 let mut engine = engine_with_module(handle);
780
781 let error = engine.run(None, None, None, false).unwrap_err();
782 let message = error.to_string();
783 assert!(message.contains("Simulation module 0 process failed"));
784 assert!(message.contains("Python SimulationModule.process failed"));
785 assert!(message.contains("ValueError: module boom"));
786
787 assert_eq!(
788 engine.run(None, None, None, false).unwrap_err().to_string(),
789 format!("Simulation module failure requires exchange reset: {message}")
790 );
791 });
792 }
793
794 #[rstest]
795 fn test_python_simulation_module_diagnostics_exception_propagates_through_run() {
796 Python::initialize();
797
798 Python::attach(|py| {
799 let locals = PyDict::new(py);
800 locals
801 .set_item("SimulationModule", py.get_type::<PySimulationModule>())
802 .unwrap();
803 let module = py
804 .eval(
805 c_str!(
806 "type('FailingDiagnosticsSimulationModule', (SimulationModule,), {\
807 'process': lambda self, ts_now, context: None,\
808 'log_diagnostics': lambda self: \
809 (_ for _ in ()).throw(ValueError('diagnostics boom'))\
810 })()"
811 ),
812 None,
813 Some(&locals),
814 )
815 .unwrap();
816 let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
817 let mut engine = engine_with_module(handle);
818
819 let error = engine.run(None, None, None, false).unwrap_err();
820 let message = error.to_string();
821
822 assert!(message.contains("Simulation module 0 log_diagnostics failed"));
823 assert!(message.contains("Python SimulationModule.log_diagnostics failed"));
824 assert!(message.contains("ValueError: diagnostics boom"));
825 });
826 }
827
828 #[rstest]
829 fn test_engine_reset_finishes_after_python_diagnostics_exception() {
830 Python::initialize();
831
832 Python::attach(|py| {
833 let locals = PyDict::new(py);
834 locals
835 .set_item("SimulationModule", py.get_type::<PySimulationModule>())
836 .unwrap();
837 let module = py
838 .eval(
839 c_str!(
840 "type('ResetAfterDiagnosticsFailureModule', (SimulationModule,), {\
841 'process': lambda self, ts_now, context: None,\
842 'log_diagnostics': lambda self: \
843 (_ for _ in ()).throw(ValueError('diagnostics boom')) \
844 if self.fail_diagnostics else None,\
845 'reset': lambda self: \
846 setattr(self, 'resets', self.resets + 1)\
847 })()"
848 ),
849 None,
850 Some(&locals),
851 )
852 .unwrap();
853 module.setattr("fail_diagnostics", true).unwrap();
854 module.setattr("resets", 0).unwrap();
855 let handle = pyobject_to_simulation_module_handle(py, &module).unwrap();
856 let mut engine = engine_with_module(handle);
857 engine.run(None, None, None, true).unwrap();
858
859 let error = engine.reset().unwrap_err();
860
861 assert!(
862 error
863 .to_string()
864 .contains("Simulation module 0 log_diagnostics failed")
865 );
866 assert_eq!(
867 module.getattr("resets").unwrap().extract::<u32>().unwrap(),
868 1
869 );
870 module.setattr("fail_diagnostics", false).unwrap();
871 engine.run(None, None, None, false).unwrap();
872 });
873 }
874}