Skip to main content

hpr_py/
lib.rs

1//! Python bindings for hpr-sim: the `hpr` Python package.
2//!
3//! **Guide:** [Python][guide-python] shows the package in use, and [Start here][guide-start]
4//! says what works today and how far to trust it.
5//!
6//! [guide-python]: https://nrdptel.github.io/hpr-sim/python.html
7//! [guide-start]: https://nrdptel.github.io/hpr-sim/start-here.html
8//!
9//! The package wraps the [`hpr`] builder, the same four types in the same order, and a Monte
10//! Carlo run of them:
11//!
12//! - `Environment`: the launch site, the standard atmosphere, a wind (constant, or a Python
13//!   function of height) and a gravity model.
14//! - `Motor`: a motor from the built-in catalog, or a RASP `.eng` or RockSim `.rse` file.
15//! - `Rocket`: parts added from the nose back, the motor and the parachutes; or a design read
16//!   from a `.hpr`, `.hprz`, `.ork` or rocket JSON file.
17//! - `Flight`: the rocket flown from a rail as soon as it is made, with its metrics, its events
18//!   and its recording as NumPy arrays; with a `DragTable` or a Python function of the Mach
19//!   number, another drag in place of hpr's (`models`).
20//! - `MonteCarlo`: the rocket flown many times, each flight's inputs scattered about the nominal
21//!   flight's, as `hpr mc` flies it (`montecarlo`): the library's
22//!   `hpr_analysis::montecarlo::MonteCarlo` run on every core, its `RunTable` as NumPy arrays.
23//!
24//! **How far to trust it:** the bindings add no physics. Each call hands its arguments to the
25//! [`hpr`] builder and returns what it returns, so a flight made in Python runs the same code as
26//! the one the Rust builder makes from the same numbers; the package's tests fly the builder's
27//! example rocket and match every digit the Rust example prints (the last digits can differ
28//! between a release and a debug build). The guide's [Accuracy][guide-accuracy] page says how
29//! good the models themselves are.
30//!
31//! [guide-accuracy]: https://nrdptel.github.io/hpr-sim/accuracy.html
32//!
33//! Values cross into Python in SI units, named in every argument and attribute (`length_m`,
34//! `apogee_m`, `max_speed_m_s`), as in the Rust API. Records such as a flight's summary cross as
35//! dictionaries, made from the same `serde` form the Rust types write as JSON. Errors raise
36//! `hpr.HprError`, a `ValueError`, whose message is the Rust error's.
37//!
38//! The crate is built by maturin (`crates/hpr-py/pyproject.toml`) into one wheel per operating
39//! system, on CPython's stable ABI (abi3), so it serves CPython 3.10 and later. Its tests are Python's, in
40//! `crates/hpr-py/tests/`, run by pytest on the built wheel.
41
42#![allow(
43    clippy::disallowed_methods,
44    reason = "the bindings are an I/O boundary, as the command line is: `Motor.from_file` and \
45              `Rocket.from_file` read the files Python names"
46)]
47
48mod flight;
49mod models;
50mod montecarlo;
51mod rocket;
52
53use pyo3::create_exception;
54use pyo3::exceptions::PyValueError;
55use pyo3::prelude::*;
56use serde::Serialize;
57use serde::de::DeserializeOwned;
58
59create_exception!(
60    hpr,
61    HprError,
62    PyValueError,
63    "An error from hpr-sim: an input out of its domain, a part out of order, a motor or \
64     material that isn't there, or a flight that failed. The message is the library's."
65);
66
67/// The Python exception for a library error.
68fn error(error: impl std::fmt::Display) -> PyErr {
69    HprError::new_err(error.to_string())
70}
71
72/// A value the library serializes, as the Python object `json.loads` makes of its JSON: numbers,
73/// strings, lists, dictionaries and `None`.
74fn to_python<'py>(py: Python<'py>, value: &impl Serialize) -> PyResult<Bound<'py, PyAny>> {
75    let text = serde_json::to_string(value).map_err(error)?;
76    py.import("json")?.call_method1("loads", (text,))
77}
78
79/// A name as the bindings compare it: trimmed, in lower case, with hyphens and spaces as
80/// underscores, so `"Flat circular"` and `"flat-circular"` are `"flat_circular"`.
81fn key(name: &str) -> String {
82    name.trim().to_ascii_lowercase().replace(['-', ' '], "_")
83}
84
85/// A unit enum of the library, by its `serde` name: `"flat_circular"` is
86/// `CanopyType::FlatCircular`. The names are the ones the library writes in JSON, so the two
87/// can't drift apart.
88fn by_name<T: DeserializeOwned>(what: &str, name: &str) -> PyResult<T> {
89    serde_json::from_value(serde_json::Value::String(key(name)))
90        .map_err(|_| error(format!("no {what} `{name}`")))
91}
92
93/// The built-in materials' ids, the names `Rocket`'s parts take: `"abs"`, `"kraft_phenolic"`,
94/// `"birch_plywood"` and the rest, each with its density's source in the Rust documentation of
95/// `hpr_design::materials`.
96#[pyfunction]
97fn materials() -> Vec<&'static str> {
98    hpr::hpr_design::materials::BUILTIN
99        .iter()
100        .map(|material| material.id)
101        .collect()
102}
103
104/// The `hpr` package's native module. `hpr/__init__.py` re-exports it.
105#[pymodule]
106fn _hpr(m: &Bound<'_, PyModule>) -> PyResult<()> {
107    m.add("HprError", m.py().get_type::<HprError>())?;
108    m.add("__version__", env!("CARGO_PKG_VERSION"))?;
109    m.add_function(wrap_pyfunction!(materials, m)?)?;
110    m.add_class::<flight::Environment>()?;
111    m.add_class::<rocket::Motor>()?;
112    m.add_class::<rocket::Rocket>()?;
113    m.add_class::<flight::DragTable>()?;
114    m.add_class::<flight::Flight>()?;
115    m.add_class::<montecarlo::MonteCarlo>()?;
116    Ok(())
117}