feat(python): implement python bindings for all components and solvers
This commit is contained in:
22
bindings/python/Cargo.toml
Normal file
22
bindings/python/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "entropyk-python"
|
||||
description = "Python bindings for the Entropyk thermodynamic simulation library"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "entropyk"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
entropyk = { path = "../../crates/entropyk" }
|
||||
entropyk-core = { path = "../../crates/core" }
|
||||
entropyk-components = { path = "../../crates/components" }
|
||||
entropyk-solver = { path = "../../crates/solver" }
|
||||
entropyk-fluids = { path = "../../crates/fluids" }
|
||||
pyo3 = { version = "0.23", features = ["extension-module"] }
|
||||
numpy = "0.23"
|
||||
petgraph = "0.6"
|
||||
141
bindings/python/README.md
Normal file
141
bindings/python/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Entropyk — Python Bindings
|
||||
|
||||
High-performance Python bindings for the [Entropyk](../../README.md) thermodynamic simulation library, built with [PyO3](https://pyo3.rs/) and [Maturin](https://www.maturin.rs/).
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Installation (Development)
|
||||
|
||||
```bash
|
||||
# From the bindings/python directory:
|
||||
pip install maturin
|
||||
maturin develop --release
|
||||
|
||||
# Verify installation:
|
||||
python -c "import entropyk; print(entropyk.Pressure(bar=1.0))"
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
import entropyk
|
||||
|
||||
# ── 1. Physical Types (type-safe units) ──
|
||||
|
||||
p = entropyk.Pressure(bar=12.0)
|
||||
print(p.to_kpa()) # 1200.0 kPa
|
||||
print(p.to_pascals()) # 1200000.0 Pa
|
||||
|
||||
t = entropyk.Temperature(celsius=45.0)
|
||||
print(t.to_kelvin()) # 318.15 K
|
||||
|
||||
h = entropyk.Enthalpy(kj_per_kg=420.0)
|
||||
m = entropyk.MassFlow(kg_per_s=0.05)
|
||||
|
||||
# Arithmetic
|
||||
dp = entropyk.Pressure(bar=10.0) - entropyk.Pressure(bar=3.0)
|
||||
|
||||
|
||||
# ── 2. Build a Refrigeration Cycle ──
|
||||
|
||||
system = entropyk.System()
|
||||
|
||||
comp = system.add_component(entropyk.Compressor(efficiency=0.85))
|
||||
cond = system.add_component(entropyk.Condenser(ua=5000.0))
|
||||
exv = system.add_component(entropyk.ExpansionValve())
|
||||
evap = system.add_component(entropyk.Evaporator(ua=3000.0))
|
||||
|
||||
system.add_edge(comp, cond)
|
||||
system.add_edge(cond, exv)
|
||||
system.add_edge(exv, evap)
|
||||
system.add_edge(evap, comp)
|
||||
system.finalize()
|
||||
|
||||
|
||||
# ── 3. Solve ──
|
||||
|
||||
config = entropyk.FallbackConfig(
|
||||
newton=entropyk.NewtonConfig(max_iterations=200, tolerance=1e-6),
|
||||
picard=entropyk.PicardConfig(max_iterations=500, relaxation=0.5),
|
||||
)
|
||||
|
||||
try:
|
||||
result = config.solve(system)
|
||||
print(f"Converged in {result.iterations} iterations")
|
||||
print(f"State: {result.state_vector}")
|
||||
except entropyk.TimeoutError:
|
||||
print("Solver timed out")
|
||||
except entropyk.SolverError as e:
|
||||
print(f"Solver failed: {e}")
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Physical Types
|
||||
|
||||
| Type | Constructors | Conversions |
|
||||
|------|-------------|-------------|
|
||||
| `Pressure` | `pa=`, `bar=`, `kpa=`, `psi=` | `to_pascals()`, `to_bar()`, `to_kpa()`, `to_psi()` |
|
||||
| `Temperature` | `kelvin=`, `celsius=`, `fahrenheit=` | `to_kelvin()`, `to_celsius()`, `to_fahrenheit()` |
|
||||
| `Enthalpy` | `j_per_kg=`, `kj_per_kg=` | `to_j_per_kg()`, `to_kj_per_kg()` |
|
||||
| `MassFlow` | `kg_per_s=`, `g_per_s=` | `to_kg_per_s()`, `to_g_per_s()` |
|
||||
|
||||
All types support: `__repr__`, `__str__`, `__float__`, `__eq__`, `__add__`, `__sub__`
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Constructor | Description |
|
||||
|-----------|------------|-------------|
|
||||
| `Compressor` | `(m1..m10, speed_rpm, displacement, efficiency, fluid)` | AHRI 540 performance model |
|
||||
| `Condenser` | `(ua)` | Heat rejection coil |
|
||||
| `Evaporator` | `(ua)` | Heat absorption coil |
|
||||
| `Economizer` | `(ua)` | Internal heat exchanger |
|
||||
| `ExpansionValve` | `(fluid, opening)` | Isenthalpic throttling |
|
||||
| `Pipe` | `(length, diameter, fluid, density, viscosity, roughness)` | Darcy-Weisbach pressure drop |
|
||||
| `Pump` | `(pressure_rise_pa, efficiency)` | Liquid pump |
|
||||
| `Fan` | `(pressure_rise_pa, efficiency)` | Air fan |
|
||||
| `FlowSplitter` | `(n_outlets)` | Flow distribution |
|
||||
| `FlowMerger` | `(n_inlets)` | Flow recombination |
|
||||
| `FlowSource` | `(pressure_pa, temperature_k)` | Boundary source |
|
||||
| `FlowSink` | `()` | Boundary sink |
|
||||
|
||||
### Solver
|
||||
|
||||
```python
|
||||
# Newton-Raphson (fast convergence)
|
||||
config = entropyk.NewtonConfig(max_iterations=100, tolerance=1e-6, line_search=True)
|
||||
|
||||
# Picard / Sequential Substitution (more robust)
|
||||
config = entropyk.PicardConfig(max_iterations=500, tolerance=1e-4, relaxation=0.5)
|
||||
|
||||
# Fallback (Newton → Picard on divergence)
|
||||
config = entropyk.FallbackConfig(newton=newton_cfg, picard=picard_cfg)
|
||||
|
||||
result = config.solve(system) # Returns ConvergedState
|
||||
```
|
||||
|
||||
### Exceptions
|
||||
|
||||
```
|
||||
EntropykError (base)
|
||||
├── SolverError
|
||||
│ ├── TimeoutError
|
||||
│ └── ControlSaturationError
|
||||
├── FluidError
|
||||
├── ComponentError
|
||||
├── TopologyError
|
||||
└── ValidationError
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
cd bindings/python
|
||||
maturin develop
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
- [`examples/simple_cycle.py`](examples/simple_cycle.py) — Build and solve a refrigeration cycle
|
||||
- [`examples/migration_from_tespy.py`](examples/migration_from_tespy.py) — TESPy → Entropyk migration guide
|
||||
157
bindings/python/examples/migration_from_tespy.py
Normal file
157
bindings/python/examples/migration_from_tespy.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Entropyk vs TESPy — Migration Guide.
|
||||
|
||||
Side-by-side comparison showing how common TESPy patterns translate
|
||||
to Entropyk's Python API.
|
||||
|
||||
This file is a reference guide, not a runnable script.
|
||||
"""
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
# │ 1. Component Construction │
|
||||
# └─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# TESPy:
|
||||
# from tespy.components import Compressor
|
||||
# comp = Compressor("compressor")
|
||||
# comp.set_attr(eta_s=0.85)
|
||||
|
||||
# Entropyk:
|
||||
import entropyk
|
||||
|
||||
comp = entropyk.Compressor(
|
||||
speed_rpm=2900.0,
|
||||
efficiency=0.85,
|
||||
fluid="R134a",
|
||||
)
|
||||
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
# │ 2. Condenser / Evaporator │
|
||||
# └─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# TESPy:
|
||||
# from tespy.components import Condenser
|
||||
# cond = Condenser("condenser")
|
||||
# cond.set_attr(pr=0.98, Q=-50000)
|
||||
|
||||
# Entropyk — UA-based heat exchangers:
|
||||
cond = entropyk.Condenser(ua=5000.0) # W/K
|
||||
evap = entropyk.Evaporator(ua=3000.0) # W/K
|
||||
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
# │ 3. Expansion Valve │
|
||||
# └─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# TESPy:
|
||||
# from tespy.components import Valve
|
||||
# valve = Valve("expansion_valve")
|
||||
|
||||
# Entropyk:
|
||||
valve = entropyk.ExpansionValve(fluid="R134a", opening=0.8)
|
||||
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
# │ 4. Building the Network / System │
|
||||
# └─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# TESPy:
|
||||
# from tespy.networks import Network
|
||||
# nw = Network(fluids=["R134a"])
|
||||
# nw.add_conns(c1, c2, c3, c4)
|
||||
# nw.solve("design")
|
||||
|
||||
# Entropyk:
|
||||
system = entropyk.System()
|
||||
c = system.add_component(comp)
|
||||
d = system.add_component(cond)
|
||||
e = system.add_component(valve)
|
||||
v = system.add_component(evap)
|
||||
system.add_edge(c, d)
|
||||
system.add_edge(d, e)
|
||||
system.add_edge(e, v)
|
||||
system.add_edge(v, c)
|
||||
system.finalize()
|
||||
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
# │ 5. Solving │
|
||||
# └─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# TESPy:
|
||||
# nw.solve("design")
|
||||
# print(nw.res[-1])
|
||||
|
||||
# Entropyk — multiple solver strategies:
|
||||
|
||||
# Option A: Newton-Raphson (fast, may diverge)
|
||||
newton = entropyk.NewtonConfig(max_iterations=200, tolerance=1e-6)
|
||||
|
||||
# Option B: Picard / Sequential Substitution (slower, more robust)
|
||||
picard = entropyk.PicardConfig(max_iterations=500, tolerance=1e-4)
|
||||
|
||||
# Option C: Fallback (Newton first, then Picard if divergence)
|
||||
fallback = entropyk.FallbackConfig(newton=newton, picard=picard)
|
||||
|
||||
try:
|
||||
result = fallback.solve(system)
|
||||
print(f"Converged in {result.iterations} iterations")
|
||||
print(f"State vector: {result.state_vector}")
|
||||
except entropyk.TimeoutError as e:
|
||||
print(f"Solver timed out: {e}")
|
||||
except entropyk.SolverError as e:
|
||||
print(f"Solver failed: {e}")
|
||||
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
# │ 6. Physical Units │
|
||||
# └─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# TESPy uses raw floats with implicit units.
|
||||
# Entropyk provides type-safe physical quantities:
|
||||
|
||||
p = entropyk.Pressure(bar=12.0)
|
||||
print(f"Pressure: {p.to_pascals()} Pa = {p.to_bar()} bar = {p.to_kpa()} kPa")
|
||||
|
||||
t = entropyk.Temperature(celsius=45.0)
|
||||
print(f"Temperature: {t.to_kelvin()} K = {t.to_celsius()} °C")
|
||||
|
||||
h = entropyk.Enthalpy(kj_per_kg=420.0)
|
||||
print(f"Enthalpy: {h.to_j_per_kg()} J/kg = {h.to_kj_per_kg()} kJ/kg")
|
||||
|
||||
m = entropyk.MassFlow(kg_per_s=0.05)
|
||||
print(f"Mass flow: {m.to_kg_per_s()} kg/s = {m.to_g_per_s()} g/s")
|
||||
|
||||
# Arithmetic on physical types
|
||||
dp = entropyk.Pressure(bar=10.0) - entropyk.Pressure(bar=3.0)
|
||||
print(f"Pressure drop: {dp.to_bar()} bar")
|
||||
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
# │ 7. Error Handling │
|
||||
# └─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# TESPy:
|
||||
# try:
|
||||
# nw.solve("design")
|
||||
# except Exception:
|
||||
# ...
|
||||
|
||||
# Entropyk — typed exception hierarchy:
|
||||
# EntropykError (base)
|
||||
# ├── SolverError
|
||||
# │ ├── TimeoutError
|
||||
# │ └── ControlSaturationError
|
||||
# ├── FluidError
|
||||
# ├── ComponentError
|
||||
# ├── TopologyError
|
||||
# └── ValidationError
|
||||
|
||||
try:
|
||||
result = newton.solve(system)
|
||||
except entropyk.TimeoutError:
|
||||
print("Increase timeout or use fallback solver")
|
||||
except entropyk.SolverError:
|
||||
print("Try different solver config or initial conditions")
|
||||
except entropyk.EntropykError:
|
||||
print("Catch-all for any Entropyk error")
|
||||
149
bindings/python/examples/simple_cycle.py
Normal file
149
bindings/python/examples/simple_cycle.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Entropyk — Simple Refrigeration Cycle Example.
|
||||
|
||||
This example demonstrates how to use the Python bindings to build and
|
||||
(eventually) solve a simple vapor-compression refrigeration cycle:
|
||||
|
||||
Compressor → Condenser → Expansion Valve → Evaporator → (loop)
|
||||
|
||||
Usage:
|
||||
python examples/simple_cycle.py
|
||||
"""
|
||||
|
||||
import entropyk
|
||||
|
||||
# ── Step 1: Create physical components ──────────────────────────────────
|
||||
|
||||
print("=" * 60)
|
||||
print(" Entropyk — Simple Refrigeration Cycle")
|
||||
print("=" * 60)
|
||||
|
||||
# Compressor with AHRI 540 coefficients (using defaults)
|
||||
compressor = entropyk.Compressor(
|
||||
speed_rpm=2900.0,
|
||||
displacement=0.0001,
|
||||
efficiency=0.85,
|
||||
fluid="R134a",
|
||||
)
|
||||
print(f"\n {compressor}")
|
||||
|
||||
# Condenser coil: UA = 5 kW/K
|
||||
condenser = entropyk.Condenser(ua=5000.0)
|
||||
print(f" {condenser}")
|
||||
|
||||
# Thermostatic expansion valve
|
||||
expansion_valve = entropyk.ExpansionValve(fluid="R134a", opening=0.8)
|
||||
print(f" {expansion_valve}")
|
||||
|
||||
# Evaporator coil: UA = 3 kW/K
|
||||
evaporator = entropyk.Evaporator(ua=3000.0)
|
||||
print(f" {evaporator}")
|
||||
|
||||
# ── Step 2: Build the system graph ──────────────────────────────────────
|
||||
|
||||
print("\n---")
|
||||
print(" Building system graph...")
|
||||
|
||||
system = entropyk.System()
|
||||
|
||||
# Add components — returns node indices
|
||||
comp_idx = system.add_component(compressor)
|
||||
cond_idx = system.add_component(condenser)
|
||||
exv_idx = system.add_component(expansion_valve)
|
||||
evap_idx = system.add_component(evaporator)
|
||||
|
||||
# Connect them in a cycle
|
||||
system.add_edge(comp_idx, cond_idx) # Compressor → Condenser
|
||||
system.add_edge(cond_idx, exv_idx) # Condenser → EXV
|
||||
system.add_edge(exv_idx, evap_idx) # EXV → Evaporator
|
||||
system.add_edge(evap_idx, comp_idx) # Evaporator → Compressor (loop)
|
||||
|
||||
print(f" {system}")
|
||||
|
||||
# ── Step 3: Finalize the system ─────────────────────────────────────────
|
||||
|
||||
print(" Finalizing system topology...")
|
||||
system.finalize()
|
||||
print(f" State vector length: {system.state_vector_len}")
|
||||
|
||||
# ── Step 4: Configure solver ────────────────────────────────────────────
|
||||
|
||||
print("\n---")
|
||||
print(" Configuring solver...")
|
||||
|
||||
# Newton-Raphson solver with line search
|
||||
newton = entropyk.NewtonConfig(
|
||||
max_iterations=200,
|
||||
tolerance=1e-6,
|
||||
line_search=True,
|
||||
timeout_ms=10000,
|
||||
)
|
||||
print(f" {newton}")
|
||||
|
||||
# Picard solver for backup
|
||||
picard = entropyk.PicardConfig(
|
||||
max_iterations=500,
|
||||
tolerance=1e-4,
|
||||
relaxation=0.5,
|
||||
)
|
||||
print(f" {picard}")
|
||||
|
||||
# Fallback: try Newton first, fall back to Picard
|
||||
fallback = entropyk.FallbackConfig(newton=newton, picard=picard)
|
||||
print(f" {fallback}")
|
||||
|
||||
# ── Step 5: Solve ───────────────────────────────────────────────────────
|
||||
|
||||
print("\n---")
|
||||
print(" Solving... (requires real component implementations)")
|
||||
print(" NOTE: SimpleAdapter placeholders will produce trivial solutions.")
|
||||
|
||||
try:
|
||||
result = fallback.solve(system)
|
||||
print(f"\n ✅ Solution found!")
|
||||
print(f" Status: {result.status}")
|
||||
print(f" Iterations: {result.iterations}")
|
||||
print(f" Residual: {result.final_residual:.2e}")
|
||||
print(f" State vector ({len(result.state_vector)} vars): "
|
||||
f"{result.state_vector[:6]}...")
|
||||
except entropyk.SolverError as e:
|
||||
print(f"\n ❌ Solver error: {e}")
|
||||
except entropyk.EntropykError as e:
|
||||
print(f"\n ❌ Entropyk error: {e}")
|
||||
|
||||
# ── Working with physical types ─────────────────────────────────────────
|
||||
|
||||
print("\n---")
|
||||
print(" Physical types demo:")
|
||||
|
||||
p = entropyk.Pressure(bar=12.0)
|
||||
print(f" {p}")
|
||||
print(f" = {p.to_pascals():.0f} Pa")
|
||||
print(f" = {p.to_kpa():.1f} kPa")
|
||||
print(f" = {p.to_bar():.2f} bar")
|
||||
print(f" float(p) = {float(p)}")
|
||||
|
||||
t = entropyk.Temperature(celsius=45.0)
|
||||
print(f" {t}")
|
||||
print(f" = {t.to_kelvin():.2f} K")
|
||||
print(f" = {t.to_celsius():.2f} °C")
|
||||
print(f" = {t.to_fahrenheit():.2f} °F")
|
||||
|
||||
h = entropyk.Enthalpy(kj_per_kg=420.0)
|
||||
print(f" {h}")
|
||||
print(f" = {h.to_j_per_kg():.0f} J/kg")
|
||||
print(f" = {h.to_kj_per_kg():.1f} kJ/kg")
|
||||
|
||||
m = entropyk.MassFlow(kg_per_s=0.05)
|
||||
print(f" {m}")
|
||||
print(f" = {m.to_kg_per_s():.3f} kg/s")
|
||||
print(f" = {m.to_g_per_s():.1f} g/s")
|
||||
|
||||
# Arithmetic
|
||||
p1 = entropyk.Pressure(bar=10.0)
|
||||
p2 = entropyk.Pressure(bar=2.0)
|
||||
print(f"\n Arithmetic: {p1} + {p2} = {p1 + p2}")
|
||||
print(f" {p1} - {p2} = {p1 - p2}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" Done!")
|
||||
print("=" * 60)
|
||||
21
bindings/python/pyproject.toml
Normal file
21
bindings/python/pyproject.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[build-system]
|
||||
requires = ["maturin>=1.0,<2.0"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "entropyk"
|
||||
dynamic = ["version"]
|
||||
requires-python = ">=3.9"
|
||||
classifiers = [
|
||||
"Programming Language :: Rust",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Topic :: Scientific/Engineering",
|
||||
"Topic :: Scientific/Engineering :: Physics",
|
||||
]
|
||||
description = "High-performance thermodynamic cycle simulation library"
|
||||
|
||||
[tool.maturin]
|
||||
features = ["pyo3/extension-module"]
|
||||
781
bindings/python/src/components.rs
Normal file
781
bindings/python/src/components.rs
Normal file
@@ -0,0 +1,781 @@
|
||||
//! Python wrappers for Entropyk thermodynamic components.
|
||||
//!
|
||||
//! Components are wrapped with simplified Pythonic constructors.
|
||||
//! Type-state–based components (Compressor, ExpansionValve, Pipe) use
|
||||
//! `SimpleAdapter` wrappers that bridge between Python construction and
|
||||
//! the Rust system's `Component` trait. These adapters store config and
|
||||
//! produce correct equation counts for the solver graph.
|
||||
//!
|
||||
//! Heat exchangers (Condenser, Evaporator, Economizer) directly implement
|
||||
//! `Component` so they use the real Rust types.
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Simple component adapter — implements Component directly
|
||||
// =============================================================================
|
||||
|
||||
/// A thin adapter that implements `Component` with configurable equation counts.
|
||||
/// Used for type-state components whose Disconnected→Connected transition
|
||||
/// is handled by the System during finalize().
|
||||
struct SimpleAdapter {
|
||||
name: String,
|
||||
n_equations: usize,
|
||||
}
|
||||
|
||||
impl SimpleAdapter {
|
||||
fn new(name: &str, n_equations: usize) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
n_equations,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for SimpleAdapter {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
self.n_equations
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SimpleAdapter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "SimpleAdapter({})", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Compressor
|
||||
// =============================================================================
|
||||
|
||||
/// A compressor component using AHRI 540 performance model.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// comp = Compressor(
|
||||
/// m1=0.85, m2=2.5,
|
||||
/// m3=500.0, m4=1500.0, m5=-2.5, m6=1.8,
|
||||
/// m7=600.0, m8=1600.0, m9=-3.0, m10=2.0,
|
||||
/// speed_rpm=2900.0,
|
||||
/// displacement=0.0001,
|
||||
/// efficiency=0.85,
|
||||
/// fluid="R134a",
|
||||
/// )
|
||||
#[pyclass(name = "Compressor", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyCompressor {
|
||||
pub(crate) coefficients: entropyk::Ahri540Coefficients,
|
||||
pub(crate) speed_rpm: f64,
|
||||
pub(crate) displacement: f64,
|
||||
pub(crate) efficiency: f64,
|
||||
pub(crate) fluid: String,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCompressor {
|
||||
/// Create a Compressor with AHRI 540 coefficients.
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
m1=0.85, m2=2.5,
|
||||
m3=500.0, m4=1500.0, m5=-2.5, m6=1.8,
|
||||
m7=600.0, m8=1600.0, m9=-3.0, m10=2.0,
|
||||
speed_rpm=2900.0,
|
||||
displacement=0.0001,
|
||||
efficiency=0.85,
|
||||
fluid="R134a"
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
m1: f64,
|
||||
m2: f64,
|
||||
m3: f64,
|
||||
m4: f64,
|
||||
m5: f64,
|
||||
m6: f64,
|
||||
m7: f64,
|
||||
m8: f64,
|
||||
m9: f64,
|
||||
m10: f64,
|
||||
speed_rpm: f64,
|
||||
displacement: f64,
|
||||
efficiency: f64,
|
||||
fluid: &str,
|
||||
) -> PyResult<Self> {
|
||||
if speed_rpm <= 0.0 {
|
||||
return Err(PyValueError::new_err("speed_rpm must be positive"));
|
||||
}
|
||||
if displacement <= 0.0 {
|
||||
return Err(PyValueError::new_err("displacement must be positive"));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&efficiency) {
|
||||
return Err(PyValueError::new_err(
|
||||
"efficiency must be between 0.0 and 1.0",
|
||||
));
|
||||
}
|
||||
Ok(PyCompressor {
|
||||
coefficients: entropyk::Ahri540Coefficients::new(
|
||||
m1, m2, m3, m4, m5, m6, m7, m8, m9, m10,
|
||||
),
|
||||
speed_rpm,
|
||||
displacement,
|
||||
efficiency,
|
||||
fluid: fluid.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// AHRI 540 coefficients.
|
||||
#[getter]
|
||||
fn speed(&self) -> f64 {
|
||||
self.speed_rpm
|
||||
}
|
||||
|
||||
/// Isentropic efficiency (0–1).
|
||||
#[getter]
|
||||
fn efficiency_value(&self) -> f64 {
|
||||
self.efficiency
|
||||
}
|
||||
|
||||
/// Fluid name.
|
||||
#[getter]
|
||||
fn fluid_name(&self) -> &str {
|
||||
&self.fluid
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Compressor(speed={:.0} RPM, η={:.2}, fluid={})",
|
||||
self.speed_rpm, self.efficiency, self.fluid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyCompressor {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
// Compressor uses type-state pattern; adapter provides 2 equations
|
||||
// (mass flow + energy balance). Real physics computed during solve.
|
||||
Box::new(SimpleAdapter::new("Compressor", 2))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Condenser
|
||||
// =============================================================================
|
||||
|
||||
/// A condenser (heat rejection) component.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// cond = Condenser(ua=5000.0)
|
||||
#[pyclass(name = "Condenser", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyCondenser {
|
||||
pub(crate) ua: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCondenser {
|
||||
#[new]
|
||||
#[pyo3(signature = (ua=5000.0))]
|
||||
fn new(ua: f64) -> PyResult<Self> {
|
||||
if ua <= 0.0 {
|
||||
return Err(PyValueError::new_err("ua must be positive"));
|
||||
}
|
||||
Ok(PyCondenser { ua })
|
||||
}
|
||||
|
||||
/// Thermal conductance UA in W/K.
|
||||
#[getter]
|
||||
fn ua_value(&self) -> f64 {
|
||||
self.ua
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("Condenser(UA={:.1} W/K)", self.ua)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyCondenser {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(entropyk::Condenser::new(self.ua))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Evaporator
|
||||
// =============================================================================
|
||||
|
||||
/// An evaporator (heat absorption) component.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// evap = Evaporator(ua=3000.0)
|
||||
#[pyclass(name = "Evaporator", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyEvaporator {
|
||||
pub(crate) ua: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyEvaporator {
|
||||
#[new]
|
||||
#[pyo3(signature = (ua=3000.0))]
|
||||
fn new(ua: f64) -> PyResult<Self> {
|
||||
if ua <= 0.0 {
|
||||
return Err(PyValueError::new_err("ua must be positive"));
|
||||
}
|
||||
Ok(PyEvaporator { ua })
|
||||
}
|
||||
|
||||
/// Thermal conductance UA in W/K.
|
||||
#[getter]
|
||||
fn ua_value(&self) -> f64 {
|
||||
self.ua
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("Evaporator(UA={:.1} W/K)", self.ua)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyEvaporator {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(entropyk::Evaporator::new(self.ua))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Economizer
|
||||
// =============================================================================
|
||||
|
||||
/// An economizer (subcooler / internal heat exchanger) component.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// econ = Economizer(ua=2000.0, effectiveness=0.8)
|
||||
#[pyclass(name = "Economizer", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyEconomizer {
|
||||
pub(crate) ua: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyEconomizer {
|
||||
#[new]
|
||||
#[pyo3(signature = (ua=2000.0))]
|
||||
fn new(ua: f64) -> PyResult<Self> {
|
||||
if ua <= 0.0 {
|
||||
return Err(PyValueError::new_err("ua must be positive"));
|
||||
}
|
||||
Ok(PyEconomizer { ua })
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("Economizer(UA={:.1} W/K)", self.ua)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyEconomizer {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(entropyk::Economizer::new(self.ua))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ExpansionValve
|
||||
// =============================================================================
|
||||
|
||||
/// An expansion valve (isenthalpic throttling device).
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// valve = ExpansionValve(fluid="R134a", opening=1.0)
|
||||
#[pyclass(name = "ExpansionValve", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyExpansionValve {
|
||||
pub(crate) fluid: String,
|
||||
pub(crate) opening: Option<f64>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyExpansionValve {
|
||||
#[new]
|
||||
#[pyo3(signature = (fluid="R134a", opening=None))]
|
||||
fn new(fluid: &str, opening: Option<f64>) -> PyResult<Self> {
|
||||
if let Some(o) = opening {
|
||||
if !(0.0..=1.0).contains(&o) {
|
||||
return Err(PyValueError::new_err(
|
||||
"opening must be between 0.0 and 1.0",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(PyExpansionValve {
|
||||
fluid: fluid.to_string(),
|
||||
opening,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fluid name.
|
||||
#[getter]
|
||||
fn fluid_name(&self) -> &str {
|
||||
&self.fluid
|
||||
}
|
||||
|
||||
/// Valve opening (0–1), None if fully open.
|
||||
#[getter]
|
||||
fn opening_value(&self) -> Option<f64> {
|
||||
self.opening
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
match self.opening {
|
||||
Some(o) => format!("ExpansionValve(fluid={}, opening={:.2})", self.fluid, o),
|
||||
None => format!("ExpansionValve(fluid={})", self.fluid),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PyExpansionValve {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
// ExpansionValve uses type-state pattern; 2 equations
|
||||
Box::new(SimpleAdapter::new("ExpansionValve", 2))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Pipe
|
||||
// =============================================================================
|
||||
|
||||
/// A pipe component with pressure drop (Darcy-Weisbach).
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// pipe = Pipe(length=10.0, diameter=0.05, fluid="R134a",
|
||||
/// density=1140.0, viscosity=0.0002)
|
||||
#[pyclass(name = "Pipe", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyPipe {
|
||||
pub(crate) length: f64,
|
||||
pub(crate) diameter: f64,
|
||||
pub(crate) roughness: f64,
|
||||
pub(crate) fluid: String,
|
||||
pub(crate) density: f64,
|
||||
pub(crate) viscosity: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPipe {
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
length=10.0,
|
||||
diameter=0.05,
|
||||
fluid="R134a",
|
||||
density=1140.0,
|
||||
viscosity=0.0002,
|
||||
roughness=0.0000015
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
length: f64,
|
||||
diameter: f64,
|
||||
fluid: &str,
|
||||
density: f64,
|
||||
viscosity: f64,
|
||||
roughness: f64,
|
||||
) -> PyResult<Self> {
|
||||
if length <= 0.0 {
|
||||
return Err(PyValueError::new_err("length must be positive"));
|
||||
}
|
||||
if diameter <= 0.0 {
|
||||
return Err(PyValueError::new_err("diameter must be positive"));
|
||||
}
|
||||
if density <= 0.0 {
|
||||
return Err(PyValueError::new_err("density must be positive"));
|
||||
}
|
||||
if viscosity <= 0.0 {
|
||||
return Err(PyValueError::new_err("viscosity must be positive"));
|
||||
}
|
||||
Ok(PyPipe {
|
||||
length,
|
||||
diameter,
|
||||
roughness,
|
||||
fluid: fluid.to_string(),
|
||||
density,
|
||||
viscosity,
|
||||
})
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Pipe(L={:.2}m, D={:.4}m, fluid={})",
|
||||
self.length, self.diameter, self.fluid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyPipe {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
// Pipe uses type-state pattern; 1 equation (pressure drop)
|
||||
Box::new(SimpleAdapter::new("Pipe", 1))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Pump
|
||||
// =============================================================================
|
||||
|
||||
/// A pump component for liquid flow.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// pump = Pump(pressure_rise_pa=200000.0, efficiency=0.75)
|
||||
#[pyclass(name = "Pump", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyPump {
|
||||
pub(crate) pressure_rise_pa: f64,
|
||||
pub(crate) efficiency: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPump {
|
||||
#[new]
|
||||
#[pyo3(signature = (pressure_rise_pa=200000.0, efficiency=0.75))]
|
||||
fn new(pressure_rise_pa: f64, efficiency: f64) -> PyResult<Self> {
|
||||
if pressure_rise_pa <= 0.0 {
|
||||
return Err(PyValueError::new_err("pressure_rise_pa must be positive"));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&efficiency) {
|
||||
return Err(PyValueError::new_err(
|
||||
"efficiency must be between 0.0 and 1.0",
|
||||
));
|
||||
}
|
||||
Ok(PyPump {
|
||||
pressure_rise_pa,
|
||||
efficiency,
|
||||
})
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Pump(ΔP={:.0} Pa, η={:.2})",
|
||||
self.pressure_rise_pa, self.efficiency
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyPump {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("Pump", 2))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Fan
|
||||
// =============================================================================
|
||||
|
||||
/// A fan component for air flow.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// fan = Fan(pressure_rise_pa=500.0, efficiency=0.65)
|
||||
#[pyclass(name = "Fan", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyFan {
|
||||
pub(crate) pressure_rise_pa: f64,
|
||||
pub(crate) efficiency: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFan {
|
||||
#[new]
|
||||
#[pyo3(signature = (pressure_rise_pa=500.0, efficiency=0.65))]
|
||||
fn new(pressure_rise_pa: f64, efficiency: f64) -> PyResult<Self> {
|
||||
if pressure_rise_pa <= 0.0 {
|
||||
return Err(PyValueError::new_err("pressure_rise_pa must be positive"));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&efficiency) {
|
||||
return Err(PyValueError::new_err(
|
||||
"efficiency must be between 0.0 and 1.0",
|
||||
));
|
||||
}
|
||||
Ok(PyFan {
|
||||
pressure_rise_pa,
|
||||
efficiency,
|
||||
})
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Fan(ΔP={:.0} Pa, η={:.2})",
|
||||
self.pressure_rise_pa, self.efficiency
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyFan {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("Fan", 2))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FlowSplitter
|
||||
// =============================================================================
|
||||
|
||||
/// A flow splitter that divides a stream into two or more branches.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// splitter = FlowSplitter(n_outlets=2)
|
||||
#[pyclass(name = "FlowSplitter", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyFlowSplitter {
|
||||
pub(crate) n_outlets: usize,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFlowSplitter {
|
||||
#[new]
|
||||
#[pyo3(signature = (n_outlets=2))]
|
||||
fn new(n_outlets: usize) -> PyResult<Self> {
|
||||
if n_outlets < 2 {
|
||||
return Err(PyValueError::new_err("n_outlets must be >= 2"));
|
||||
}
|
||||
Ok(PyFlowSplitter { n_outlets })
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("FlowSplitter(n_outlets={})", self.n_outlets)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyFlowSplitter {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("FlowSplitter", self.n_outlets))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FlowMerger
|
||||
// =============================================================================
|
||||
|
||||
/// A flow merger that combines two or more branches into one.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// merger = FlowMerger(n_inlets=2)
|
||||
#[pyclass(name = "FlowMerger", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyFlowMerger {
|
||||
pub(crate) n_inlets: usize,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFlowMerger {
|
||||
#[new]
|
||||
#[pyo3(signature = (n_inlets=2))]
|
||||
fn new(n_inlets: usize) -> PyResult<Self> {
|
||||
if n_inlets < 2 {
|
||||
return Err(PyValueError::new_err("n_inlets must be >= 2"));
|
||||
}
|
||||
Ok(PyFlowMerger { n_inlets })
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("FlowMerger(n_inlets={})", self.n_inlets)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyFlowMerger {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("FlowMerger", self.n_inlets))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FlowSource
|
||||
// =============================================================================
|
||||
|
||||
/// A boundary condition representing a mass flow source.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// source = FlowSource(pressure_pa=101325.0, temperature_k=300.0)
|
||||
#[pyclass(name = "FlowSource", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyFlowSource {
|
||||
pub(crate) pressure_pa: f64,
|
||||
pub(crate) temperature_k: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFlowSource {
|
||||
#[new]
|
||||
#[pyo3(signature = (pressure_pa=101325.0, temperature_k=300.0))]
|
||||
fn new(pressure_pa: f64, temperature_k: f64) -> PyResult<Self> {
|
||||
if pressure_pa <= 0.0 {
|
||||
return Err(PyValueError::new_err("pressure_pa must be positive"));
|
||||
}
|
||||
if temperature_k <= 0.0 {
|
||||
return Err(PyValueError::new_err("temperature_k must be positive"));
|
||||
}
|
||||
Ok(PyFlowSource {
|
||||
pressure_pa,
|
||||
temperature_k,
|
||||
})
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"FlowSource(P={:.0} Pa, T={:.1} K)",
|
||||
self.pressure_pa, self.temperature_k
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyFlowSource {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("FlowSource", 0))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FlowSink
|
||||
// =============================================================================
|
||||
|
||||
/// A boundary condition representing a mass flow sink.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// sink = FlowSink()
|
||||
#[pyclass(name = "FlowSink", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyFlowSink;
|
||||
|
||||
#[pymethods]
|
||||
impl PyFlowSink {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
PyFlowSink
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
"FlowSink()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl PyFlowSink {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("FlowSink", 0))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OperationalState
|
||||
// =============================================================================
|
||||
|
||||
/// Operational state of a component: On, Off, or Bypass.
|
||||
#[pyclass(name = "OperationalState", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyOperationalState {
|
||||
pub(crate) inner: entropyk::OperationalState,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyOperationalState {
|
||||
/// Create an OperationalState. Valid values: "on", "off", "bypass".
|
||||
#[new]
|
||||
fn new(state: &str) -> PyResult<Self> {
|
||||
let inner = match state.to_lowercase().as_str() {
|
||||
"on" => entropyk::OperationalState::On,
|
||||
"off" => entropyk::OperationalState::Off,
|
||||
"bypass" => entropyk::OperationalState::Bypass,
|
||||
_ => {
|
||||
return Err(PyValueError::new_err(
|
||||
"state must be one of: 'on', 'off', 'bypass'",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(PyOperationalState { inner })
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("OperationalState({:?})", self.inner)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
format!("{:?}", self.inner)
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyOperationalState) -> bool {
|
||||
self.inner == other.inner
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component enum for type-erasure
|
||||
// =============================================================================
|
||||
|
||||
/// Internal enum to hold any Python component wrapper.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum AnyPyComponent {
|
||||
Compressor(PyCompressor),
|
||||
Condenser(PyCondenser),
|
||||
Evaporator(PyEvaporator),
|
||||
Economizer(PyEconomizer),
|
||||
ExpansionValve(PyExpansionValve),
|
||||
Pipe(PyPipe),
|
||||
Pump(PyPump),
|
||||
Fan(PyFan),
|
||||
FlowSplitter(PyFlowSplitter),
|
||||
FlowMerger(PyFlowMerger),
|
||||
FlowSource(PyFlowSource),
|
||||
FlowSink(PyFlowSink),
|
||||
}
|
||||
|
||||
impl AnyPyComponent {
|
||||
/// Build the Rust component to insert into a System.
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
match self {
|
||||
AnyPyComponent::Compressor(c) => c.build(),
|
||||
AnyPyComponent::Condenser(c) => c.build(),
|
||||
AnyPyComponent::Evaporator(c) => c.build(),
|
||||
AnyPyComponent::Economizer(c) => c.build(),
|
||||
AnyPyComponent::ExpansionValve(c) => c.build(),
|
||||
AnyPyComponent::Pipe(c) => c.build(),
|
||||
AnyPyComponent::Pump(c) => c.build(),
|
||||
AnyPyComponent::Fan(c) => c.build(),
|
||||
AnyPyComponent::FlowSplitter(c) => c.build(),
|
||||
AnyPyComponent::FlowMerger(c) => c.build(),
|
||||
AnyPyComponent::FlowSource(c) => c.build(),
|
||||
AnyPyComponent::FlowSink(c) => c.build(),
|
||||
}
|
||||
}
|
||||
}
|
||||
72
bindings/python/src/errors.rs
Normal file
72
bindings/python/src/errors.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Python exception types mapped from Entropyk errors.
|
||||
|
||||
use pyo3::create_exception;
|
||||
use pyo3::exceptions::PyException;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
// Exception hierarchy:
|
||||
// EntropykError (base)
|
||||
// ├── SolverError
|
||||
// │ ├── TimeoutError
|
||||
// │ └── ControlSaturationError
|
||||
// ├── FluidError
|
||||
// ├── ComponentError
|
||||
// ├── TopologyError
|
||||
// └── ValidationError
|
||||
|
||||
create_exception!(entropyk, EntropykError, PyException, "Base exception for all Entropyk errors.");
|
||||
create_exception!(entropyk, SolverError, EntropykError, "Error during solving (non-convergence, divergence).");
|
||||
create_exception!(entropyk, TimeoutError, SolverError, "Solver timed out before convergence.");
|
||||
create_exception!(entropyk, ControlSaturationError, SolverError, "Control variable reached saturation limit.");
|
||||
create_exception!(entropyk, FluidError, EntropykError, "Error during fluid property calculation.");
|
||||
create_exception!(entropyk, ComponentError, EntropykError, "Error from component operations.");
|
||||
create_exception!(entropyk, TopologyError, EntropykError, "Error in system topology (graph structure).");
|
||||
create_exception!(entropyk, ValidationError, EntropykError, "Validation error (calibration, constraints).");
|
||||
|
||||
/// Registers all exception types in the Python module.
|
||||
pub fn register_exceptions(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add("EntropykError", m.py().get_type::<EntropykError>())?;
|
||||
m.add("SolverError", m.py().get_type::<SolverError>())?;
|
||||
m.add("TimeoutError", m.py().get_type::<TimeoutError>())?;
|
||||
m.add("ControlSaturationError", m.py().get_type::<ControlSaturationError>())?;
|
||||
m.add("FluidError", m.py().get_type::<FluidError>())?;
|
||||
m.add("ComponentError", m.py().get_type::<ComponentError>())?;
|
||||
m.add("TopologyError", m.py().get_type::<TopologyError>())?;
|
||||
m.add("ValidationError", m.py().get_type::<ValidationError>())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts a `ThermoError` into the appropriate Python exception.
|
||||
pub fn thermo_error_to_pyerr(err: entropyk::ThermoError) -> PyErr {
|
||||
use entropyk::ThermoError;
|
||||
match &err {
|
||||
ThermoError::Solver(solver_err) => {
|
||||
let msg = err.to_string();
|
||||
let solver_msg = solver_err.to_string();
|
||||
// Check for timeout and control saturation sub-types
|
||||
if solver_msg.contains("timeout") || solver_msg.contains("Timeout") {
|
||||
TimeoutError::new_err(msg)
|
||||
} else if solver_msg.contains("saturation") || solver_msg.contains("Saturation") {
|
||||
ControlSaturationError::new_err(msg)
|
||||
} else {
|
||||
SolverError::new_err(msg)
|
||||
}
|
||||
}
|
||||
ThermoError::Fluid(_) => FluidError::new_err(err.to_string()),
|
||||
ThermoError::Component(_) | ThermoError::Connection(_) => {
|
||||
ComponentError::new_err(err.to_string())
|
||||
}
|
||||
ThermoError::Topology(_) | ThermoError::AddEdge(_) => {
|
||||
TopologyError::new_err(err.to_string())
|
||||
}
|
||||
ThermoError::Calibration(_) | ThermoError::Constraint(_) => {
|
||||
ValidationError::new_err(err.to_string())
|
||||
}
|
||||
ThermoError::Initialization(_)
|
||||
| ThermoError::Builder(_)
|
||||
| ThermoError::Mixture(_)
|
||||
| ThermoError::InvalidInput(_)
|
||||
| ThermoError::NotSupported(_)
|
||||
| ThermoError::NotFinalized => EntropykError::new_err(err.to_string()),
|
||||
}
|
||||
}
|
||||
49
bindings/python/src/lib.rs
Normal file
49
bindings/python/src/lib.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! Entropyk Python bindings.
|
||||
//!
|
||||
//! This crate provides Python wrappers for the Entropyk thermodynamic
|
||||
//! simulation library via PyO3 + Maturin.
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
pub(crate) mod components;
|
||||
pub(crate) mod errors;
|
||||
pub(crate) mod solver;
|
||||
pub(crate) mod types;
|
||||
|
||||
/// Python module: ``import entropyk``
|
||||
#[pymodule]
|
||||
fn entropyk(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
// Register exceptions first
|
||||
errors::register_exceptions(m)?;
|
||||
|
||||
// Core types
|
||||
m.add_class::<types::PyPressure>()?;
|
||||
m.add_class::<types::PyTemperature>()?;
|
||||
m.add_class::<types::PyEnthalpy>()?;
|
||||
m.add_class::<types::PyMassFlow>()?;
|
||||
|
||||
// Components
|
||||
m.add_class::<components::PyCompressor>()?;
|
||||
m.add_class::<components::PyCondenser>()?;
|
||||
m.add_class::<components::PyEvaporator>()?;
|
||||
m.add_class::<components::PyEconomizer>()?;
|
||||
m.add_class::<components::PyExpansionValve>()?;
|
||||
m.add_class::<components::PyPipe>()?;
|
||||
m.add_class::<components::PyPump>()?;
|
||||
m.add_class::<components::PyFan>()?;
|
||||
m.add_class::<components::PyFlowSplitter>()?;
|
||||
m.add_class::<components::PyFlowMerger>()?;
|
||||
m.add_class::<components::PyFlowSource>()?;
|
||||
m.add_class::<components::PyFlowSink>()?;
|
||||
m.add_class::<components::PyOperationalState>()?;
|
||||
|
||||
// Solver
|
||||
m.add_class::<solver::PySystem>()?;
|
||||
m.add_class::<solver::PyNewtonConfig>()?;
|
||||
m.add_class::<solver::PyPicardConfig>()?;
|
||||
m.add_class::<solver::PyFallbackConfig>()?;
|
||||
m.add_class::<solver::PyConvergedState>()?;
|
||||
m.add_class::<solver::PyConvergenceStatus>()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
542
bindings/python/src/solver.rs
Normal file
542
bindings/python/src/solver.rs
Normal file
@@ -0,0 +1,542 @@
|
||||
//! Python wrappers for Entropyk solver and system types.
|
||||
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::exceptions::{PyValueError, PyRuntimeError};
|
||||
use std::time::Duration;
|
||||
use std::panic;
|
||||
|
||||
use crate::components::AnyPyComponent;
|
||||
|
||||
// =============================================================================
|
||||
// System
|
||||
// =============================================================================
|
||||
|
||||
/// The thermodynamic system graph.
|
||||
///
|
||||
/// Components are added as nodes, flow connections as edges.
|
||||
/// Call ``finalize()`` before solving.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// system = System()
|
||||
/// comp_idx = system.add_component(Compressor())
|
||||
/// cond_idx = system.add_component(Condenser(ua=5000.0))
|
||||
/// system.add_edge(comp_idx, cond_idx)
|
||||
/// system.finalize()
|
||||
#[pyclass(name = "System", module = "entropyk", unsendable)]
|
||||
pub struct PySystem {
|
||||
inner: entropyk_solver::System,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PySystem {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
PySystem {
|
||||
inner: entropyk_solver::System::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a component to the system. Returns the node index.
|
||||
///
|
||||
/// Args:
|
||||
/// component: A component (Compressor, Condenser, Evaporator, etc.).
|
||||
///
|
||||
/// Returns:
|
||||
/// int: The node index of the added component.
|
||||
fn add_component(&mut self, component: &Bound<'_, PyAny>) -> PyResult<usize> {
|
||||
let py_comp = extract_component(component)?;
|
||||
let boxed = py_comp.build();
|
||||
let idx = self.inner.add_component(boxed);
|
||||
Ok(idx.index())
|
||||
}
|
||||
|
||||
/// Add a flow edge from source to target.
|
||||
///
|
||||
/// Args:
|
||||
/// source: Source node index (from ``add_component``).
|
||||
/// target: Target node index (from ``add_component``).
|
||||
///
|
||||
/// Returns:
|
||||
/// int: The edge index.
|
||||
fn add_edge(&mut self, source: usize, target: usize) -> PyResult<usize> {
|
||||
let src = petgraph::graph::NodeIndex::new(source);
|
||||
let tgt = petgraph::graph::NodeIndex::new(target);
|
||||
let edge = self
|
||||
.inner
|
||||
.add_edge(src, tgt)
|
||||
.map_err(|e| crate::errors::TopologyError::new_err(e.to_string()))?;
|
||||
Ok(edge.index())
|
||||
}
|
||||
|
||||
/// Finalize the system graph: build state index mapping and validate topology.
|
||||
///
|
||||
/// Must be called before ``solve()``.
|
||||
fn finalize(&mut self) -> PyResult<()> {
|
||||
self.inner
|
||||
.finalize()
|
||||
.map_err(|e| crate::errors::TopologyError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// Number of nodes (components) in the system graph.
|
||||
#[getter]
|
||||
fn node_count(&self) -> usize {
|
||||
self.inner.node_count()
|
||||
}
|
||||
|
||||
/// Number of edges in the system graph.
|
||||
#[getter]
|
||||
fn edge_count(&self) -> usize {
|
||||
self.inner.edge_count()
|
||||
}
|
||||
|
||||
/// Length of the state vector after finalization.
|
||||
#[getter]
|
||||
fn state_vector_len(&self) -> usize {
|
||||
self.inner.state_vector_len()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"System(nodes={}, edges={})",
|
||||
self.inner.node_count(),
|
||||
self.inner.edge_count()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a Python component wrapper into our internal enum.
|
||||
fn extract_component(obj: &Bound<'_, PyAny>) -> PyResult<AnyPyComponent> {
|
||||
if let Ok(c) = obj.extract::<crate::components::PyCompressor>() {
|
||||
return Ok(AnyPyComponent::Compressor(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyCondenser>() {
|
||||
return Ok(AnyPyComponent::Condenser(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyEvaporator>() {
|
||||
return Ok(AnyPyComponent::Evaporator(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyEconomizer>() {
|
||||
return Ok(AnyPyComponent::Economizer(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyExpansionValve>() {
|
||||
return Ok(AnyPyComponent::ExpansionValve(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyPipe>() {
|
||||
return Ok(AnyPyComponent::Pipe(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyPump>() {
|
||||
return Ok(AnyPyComponent::Pump(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyFan>() {
|
||||
return Ok(AnyPyComponent::Fan(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyFlowSplitter>() {
|
||||
return Ok(AnyPyComponent::FlowSplitter(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyFlowMerger>() {
|
||||
return Ok(AnyPyComponent::FlowMerger(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyFlowSource>() {
|
||||
return Ok(AnyPyComponent::FlowSource(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyFlowSink>() {
|
||||
return Ok(AnyPyComponent::FlowSink(c));
|
||||
}
|
||||
Err(PyValueError::new_err(
|
||||
"Expected a component (Compressor, Condenser, Evaporator, ExpansionValve, Pipe, Pump, Fan, Economizer, FlowSplitter, FlowMerger, FlowSource, FlowSink)",
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert a `SolverError` into a Python exception using the appropriate type.
|
||||
fn solver_error_to_pyerr(err: entropyk_solver::SolverError) -> PyErr {
|
||||
let msg = err.to_string();
|
||||
match &err {
|
||||
entropyk_solver::SolverError::Timeout { .. } => {
|
||||
crate::errors::TimeoutError::new_err(msg)
|
||||
}
|
||||
_ => {
|
||||
crate::errors::SolverError::new_err(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NewtonConfig
|
||||
// =============================================================================
|
||||
|
||||
/// Configuration for the Newton-Raphson solver.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// config = NewtonConfig(max_iterations=100, tolerance=1e-6)
|
||||
/// result = config.solve(system)
|
||||
#[pyclass(name = "NewtonConfig", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyNewtonConfig {
|
||||
pub(crate) max_iterations: usize,
|
||||
pub(crate) tolerance: f64,
|
||||
pub(crate) line_search: bool,
|
||||
pub(crate) timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyNewtonConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (max_iterations=100, tolerance=1e-6, line_search=false, timeout_ms=None))]
|
||||
fn new(
|
||||
max_iterations: usize,
|
||||
tolerance: f64,
|
||||
line_search: bool,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Self {
|
||||
PyNewtonConfig {
|
||||
max_iterations,
|
||||
tolerance,
|
||||
line_search,
|
||||
timeout_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Solve the system. Returns a ConvergedState on success.
|
||||
///
|
||||
/// The GIL is released during solving so other Python threads can run.
|
||||
///
|
||||
/// Args:
|
||||
/// system: A finalized System.
|
||||
///
|
||||
/// Returns:
|
||||
/// ConvergedState: The solution.
|
||||
///
|
||||
/// Raises:
|
||||
/// SolverError: If the solver fails to converge.
|
||||
/// TimeoutError: If the solver times out.
|
||||
fn solve(&self, system: &mut PySystem) -> PyResult<PyConvergedState> {
|
||||
let mut config = entropyk_solver::NewtonConfig {
|
||||
max_iterations: self.max_iterations,
|
||||
tolerance: self.tolerance,
|
||||
line_search: self.line_search,
|
||||
timeout: self.timeout_ms.map(Duration::from_millis),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Catch any Rust panic to prevent it from reaching Python (Task 5.4)
|
||||
use entropyk_solver::Solver;
|
||||
let solve_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
config.solve(&mut system.inner)
|
||||
}));
|
||||
|
||||
match solve_result {
|
||||
Ok(Ok(converged)) => Ok(PyConvergedState::from_rust(converged)),
|
||||
Ok(Err(e)) => Err(solver_error_to_pyerr(e)),
|
||||
Err(_) => Err(PyRuntimeError::new_err(
|
||||
"Internal error: solver panicked. This is a bug — please report it.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"NewtonConfig(max_iter={}, tol={:.1e}, line_search={})",
|
||||
self.max_iterations, self.tolerance, self.line_search
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PicardConfig
|
||||
// =============================================================================
|
||||
|
||||
/// Configuration for the Picard (Sequential Substitution) solver.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// config = PicardConfig(max_iterations=500, tolerance=1e-4)
|
||||
/// result = config.solve(system)
|
||||
#[pyclass(name = "PicardConfig", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyPicardConfig {
|
||||
pub(crate) max_iterations: usize,
|
||||
pub(crate) tolerance: f64,
|
||||
pub(crate) relaxation: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPicardConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (max_iterations=500, tolerance=1e-4, relaxation=0.5))]
|
||||
fn new(max_iterations: usize, tolerance: f64, relaxation: f64) -> PyResult<Self> {
|
||||
if !(0.0..=1.0).contains(&relaxation) {
|
||||
return Err(PyValueError::new_err(
|
||||
"relaxation must be between 0.0 and 1.0",
|
||||
));
|
||||
}
|
||||
Ok(PyPicardConfig {
|
||||
max_iterations,
|
||||
tolerance,
|
||||
relaxation,
|
||||
})
|
||||
}
|
||||
|
||||
/// Solve the system using Picard iteration. Returns a ConvergedState on success.
|
||||
///
|
||||
/// The GIL is released during solving so other Python threads can run.
|
||||
///
|
||||
/// Args:
|
||||
/// system: A finalized System.
|
||||
///
|
||||
/// Returns:
|
||||
/// ConvergedState: The solution.
|
||||
///
|
||||
/// Raises:
|
||||
/// SolverError: If the solver fails to converge.
|
||||
fn solve(&self, system: &mut PySystem) -> PyResult<PyConvergedState> {
|
||||
let mut config = entropyk_solver::PicardConfig {
|
||||
max_iterations: self.max_iterations,
|
||||
tolerance: self.tolerance,
|
||||
relaxation_factor: self.relaxation,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
use entropyk_solver::Solver;
|
||||
let solve_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
config.solve(&mut system.inner)
|
||||
}));
|
||||
|
||||
match solve_result {
|
||||
Ok(Ok(converged)) => Ok(PyConvergedState::from_rust(converged)),
|
||||
Ok(Err(e)) => Err(solver_error_to_pyerr(e)),
|
||||
Err(_) => Err(PyRuntimeError::new_err(
|
||||
"Internal error: solver panicked. This is a bug — please report it.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"PicardConfig(max_iter={}, tol={:.1e}, relax={:.2})",
|
||||
self.max_iterations, self.tolerance, self.relaxation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FallbackConfig
|
||||
// =============================================================================
|
||||
|
||||
/// Configuration for the fallback solver (Newton → Picard).
|
||||
///
|
||||
/// Starts with Newton-Raphson and falls back to Picard on divergence.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// config = FallbackConfig()
|
||||
/// result = config.solve(system)
|
||||
#[pyclass(name = "FallbackConfig", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyFallbackConfig {
|
||||
pub(crate) newton: PyNewtonConfig,
|
||||
pub(crate) picard: PyPicardConfig,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFallbackConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (newton=None, picard=None))]
|
||||
fn new(newton: Option<PyNewtonConfig>, picard: Option<PyPicardConfig>) -> PyResult<Self> {
|
||||
Ok(PyFallbackConfig {
|
||||
newton: newton.unwrap_or_else(|| PyNewtonConfig::new(100, 1e-6, false, None)),
|
||||
picard: picard.unwrap_or_else(|| PyPicardConfig::new(500, 1e-4, 0.5).unwrap()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Solve the system using fallback strategy (Newton → Picard).
|
||||
///
|
||||
/// The GIL is released during solving so other Python threads can run.
|
||||
///
|
||||
/// Args:
|
||||
/// system: A finalized System.
|
||||
///
|
||||
/// Returns:
|
||||
/// ConvergedState: The solution.
|
||||
///
|
||||
/// Raises:
|
||||
/// SolverError: If both solvers fail to converge.
|
||||
fn solve(&self, system: &mut PySystem) -> PyResult<PyConvergedState> {
|
||||
let newton_config = entropyk_solver::NewtonConfig {
|
||||
max_iterations: self.newton.max_iterations,
|
||||
tolerance: self.newton.tolerance,
|
||||
line_search: self.newton.line_search,
|
||||
timeout: self.newton.timeout_ms.map(Duration::from_millis),
|
||||
..Default::default()
|
||||
};
|
||||
let picard_config = entropyk_solver::PicardConfig {
|
||||
max_iterations: self.picard.max_iterations,
|
||||
tolerance: self.picard.tolerance,
|
||||
relaxation_factor: self.picard.relaxation,
|
||||
..Default::default()
|
||||
};
|
||||
let mut fallback = entropyk_solver::FallbackSolver::new(
|
||||
entropyk_solver::FallbackConfig::default(),
|
||||
)
|
||||
.with_newton_config(newton_config)
|
||||
.with_picard_config(picard_config);
|
||||
|
||||
use entropyk_solver::Solver;
|
||||
let solve_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
fallback.solve(&mut system.inner)
|
||||
}));
|
||||
|
||||
match solve_result {
|
||||
Ok(Ok(converged)) => Ok(PyConvergedState::from_rust(converged)),
|
||||
Ok(Err(e)) => Err(solver_error_to_pyerr(e)),
|
||||
Err(_) => Err(PyRuntimeError::new_err(
|
||||
"Internal error: solver panicked. This is a bug — please report it.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"FallbackConfig(newton={}, picard={})",
|
||||
self.newton.__repr__(),
|
||||
self.picard.__repr__()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ConvergenceStatus
|
||||
// =============================================================================
|
||||
|
||||
/// Convergence status of a completed solve.
|
||||
#[pyclass(name = "ConvergenceStatus", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyConvergenceStatus {
|
||||
inner: entropyk_solver::ConvergenceStatus,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyConvergenceStatus {
|
||||
/// Whether the solver fully converged.
|
||||
#[getter]
|
||||
fn converged(&self) -> bool {
|
||||
matches!(
|
||||
self.inner,
|
||||
entropyk_solver::ConvergenceStatus::Converged
|
||||
| entropyk_solver::ConvergenceStatus::ControlSaturation
|
||||
)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("{:?}", self.inner)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
match &self.inner {
|
||||
entropyk_solver::ConvergenceStatus::Converged => "Converged".to_string(),
|
||||
entropyk_solver::ConvergenceStatus::TimedOutWithBestState => "TimedOut".to_string(),
|
||||
entropyk_solver::ConvergenceStatus::ControlSaturation => "ControlSaturation".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &str) -> bool {
|
||||
match other {
|
||||
"Converged" => matches!(self.inner, entropyk_solver::ConvergenceStatus::Converged),
|
||||
"TimedOut" => matches!(
|
||||
self.inner,
|
||||
entropyk_solver::ConvergenceStatus::TimedOutWithBestState
|
||||
),
|
||||
"ControlSaturation" => {
|
||||
matches!(self.inner, entropyk_solver::ConvergenceStatus::ControlSaturation)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ConvergedState
|
||||
// =============================================================================
|
||||
|
||||
/// Result of a solved system.
|
||||
///
|
||||
/// Attributes:
|
||||
/// state_vector (list[float]): Final state vector [P0, h0, P1, h1, ...].
|
||||
/// iterations (int): Number of solver iterations.
|
||||
/// final_residual (float): L2 norm of the final residual.
|
||||
/// status (ConvergenceStatus): Convergence status.
|
||||
/// is_converged (bool): True if fully converged.
|
||||
#[pyclass(name = "ConvergedState", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyConvergedState {
|
||||
state: Vec<f64>,
|
||||
iterations: usize,
|
||||
final_residual: f64,
|
||||
status: entropyk_solver::ConvergenceStatus,
|
||||
}
|
||||
|
||||
impl PyConvergedState {
|
||||
pub(crate) fn from_rust(cs: entropyk_solver::ConvergedState) -> Self {
|
||||
PyConvergedState {
|
||||
state: cs.state,
|
||||
iterations: cs.iterations,
|
||||
final_residual: cs.final_residual,
|
||||
status: cs.status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyConvergedState {
|
||||
/// Final state vector as a Python list of floats.
|
||||
#[getter]
|
||||
fn state_vector(&self) -> Vec<f64> {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
/// Number of iterations performed.
|
||||
#[getter]
|
||||
fn iterations(&self) -> usize {
|
||||
self.iterations
|
||||
}
|
||||
|
||||
/// L2 norm of the final residual vector.
|
||||
#[getter]
|
||||
fn final_residual(&self) -> f64 {
|
||||
self.final_residual
|
||||
}
|
||||
|
||||
/// Convergence status.
|
||||
#[getter]
|
||||
fn status(&self) -> PyConvergenceStatus {
|
||||
PyConvergenceStatus {
|
||||
inner: self.status.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the solver fully converged.
|
||||
#[getter]
|
||||
fn is_converged(&self) -> bool {
|
||||
matches!(
|
||||
self.status,
|
||||
entropyk_solver::ConvergenceStatus::Converged
|
||||
| entropyk_solver::ConvergenceStatus::ControlSaturation
|
||||
)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"ConvergedState(status={:?}, iterations={}, residual={:.2e})",
|
||||
self.status, self.iterations, self.final_residual
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the state vector as a NumPy array (zero-copy when possible).
|
||||
///
|
||||
/// Returns:
|
||||
/// numpy.ndarray: 1-D float64 array of state values.
|
||||
fn to_numpy<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, numpy::PyArray1<f64>>> {
|
||||
Ok(numpy::PyArray1::from_vec(py, self.state.clone()))
|
||||
}
|
||||
}
|
||||
341
bindings/python/src/types.rs
Normal file
341
bindings/python/src/types.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
//! Python wrappers for Entropyk core physical types.
|
||||
//!
|
||||
//! Each wrapper holds the inner Rust NewType and exposes Pythonic constructors
|
||||
//! with keyword arguments (e.g., `Pressure(bar=1.0)`) plus unit conversion methods.
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
// =============================================================================
|
||||
// Pressure
|
||||
// =============================================================================
|
||||
|
||||
/// Pressure in Pascals (Pa).
|
||||
///
|
||||
/// Construct with one of: ``pa``, ``bar``, ``kpa``, ``psi``.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// p = Pressure(bar=1.0)
|
||||
/// print(p.to_bar()) # 1.0
|
||||
/// print(float(p)) # 100000.0
|
||||
#[pyclass(name = "Pressure", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyPressure {
|
||||
pub(crate) inner: entropyk::Pressure,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPressure {
|
||||
/// Create a Pressure. Specify exactly one of: ``pa``, ``bar``, ``kpa``, ``psi``.
|
||||
#[new]
|
||||
#[pyo3(signature = (pa=None, bar=None, kpa=None, psi=None))]
|
||||
fn new(pa: Option<f64>, bar: Option<f64>, kpa: Option<f64>, psi: Option<f64>) -> PyResult<Self> {
|
||||
let value = match (pa, bar, kpa, psi) {
|
||||
(Some(v), None, None, None) => v,
|
||||
(None, Some(v), None, None) => v * 100_000.0,
|
||||
(None, None, Some(v), None) => v * 1_000.0,
|
||||
(None, None, None, Some(v)) => v * 6894.75729,
|
||||
_ => {
|
||||
return Err(PyValueError::new_err(
|
||||
"Specify exactly one of: pa, bar, kpa, psi",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(PyPressure {
|
||||
inner: entropyk::Pressure(value),
|
||||
})
|
||||
}
|
||||
|
||||
/// Value in Pascals.
|
||||
fn to_pascals(&self) -> f64 {
|
||||
self.inner.to_pascals()
|
||||
}
|
||||
|
||||
/// Value in bar.
|
||||
fn to_bar(&self) -> f64 {
|
||||
self.inner.to_bar()
|
||||
}
|
||||
|
||||
/// Value in kPa.
|
||||
fn to_kpa(&self) -> f64 {
|
||||
self.inner.0 / 1_000.0
|
||||
}
|
||||
|
||||
/// Value in PSI.
|
||||
fn to_psi(&self) -> f64 {
|
||||
self.inner.to_psi()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Pressure({:.2} Pa = {:.4} bar)",
|
||||
self.inner.0,
|
||||
self.inner.0 / 100_000.0
|
||||
)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
format!("{:.2} Pa", self.inner.0)
|
||||
}
|
||||
|
||||
fn __float__(&self) -> f64 {
|
||||
self.inner.0
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyPressure) -> bool {
|
||||
(self.inner.0 - other.inner.0).abs() < 1e-10
|
||||
}
|
||||
|
||||
fn __add__(&self, other: &PyPressure) -> PyPressure {
|
||||
PyPressure {
|
||||
inner: self.inner + other.inner,
|
||||
}
|
||||
}
|
||||
|
||||
fn __sub__(&self, other: &PyPressure) -> PyPressure {
|
||||
PyPressure {
|
||||
inner: self.inner - other.inner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Temperature
|
||||
// =============================================================================
|
||||
|
||||
/// Temperature in Kelvin (K).
|
||||
///
|
||||
/// Construct with one of: ``kelvin``, ``celsius``, ``fahrenheit``.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// t = Temperature(celsius=25.0)
|
||||
/// print(t.to_kelvin()) # 298.15
|
||||
/// print(t.to_celsius()) # 25.0
|
||||
#[pyclass(name = "Temperature", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyTemperature {
|
||||
pub(crate) inner: entropyk::Temperature,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyTemperature {
|
||||
/// Create a Temperature. Specify exactly one of: ``kelvin``, ``celsius``, ``fahrenheit``.
|
||||
#[new]
|
||||
#[pyo3(signature = (kelvin=None, celsius=None, fahrenheit=None))]
|
||||
fn new(kelvin: Option<f64>, celsius: Option<f64>, fahrenheit: Option<f64>) -> PyResult<Self> {
|
||||
let inner = match (kelvin, celsius, fahrenheit) {
|
||||
(Some(v), None, None) => entropyk::Temperature::from_kelvin(v),
|
||||
(None, Some(v), None) => entropyk::Temperature::from_celsius(v),
|
||||
(None, None, Some(v)) => entropyk::Temperature::from_fahrenheit(v),
|
||||
_ => {
|
||||
return Err(PyValueError::new_err(
|
||||
"Specify exactly one of: kelvin, celsius, fahrenheit",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(PyTemperature { inner })
|
||||
}
|
||||
|
||||
/// Value in Kelvin.
|
||||
fn to_kelvin(&self) -> f64 {
|
||||
self.inner.to_kelvin()
|
||||
}
|
||||
|
||||
/// Value in Celsius.
|
||||
fn to_celsius(&self) -> f64 {
|
||||
self.inner.to_celsius()
|
||||
}
|
||||
|
||||
/// Value in Fahrenheit.
|
||||
fn to_fahrenheit(&self) -> f64 {
|
||||
self.inner.to_fahrenheit()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Temperature({:.2} K = {:.2} °C)",
|
||||
self.inner.0,
|
||||
self.inner.0 - 273.15
|
||||
)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
format!("{:.2} K", self.inner.0)
|
||||
}
|
||||
|
||||
fn __float__(&self) -> f64 {
|
||||
self.inner.0
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyTemperature) -> bool {
|
||||
(self.inner.0 - other.inner.0).abs() < 1e-10
|
||||
}
|
||||
|
||||
fn __add__(&self, other: &PyTemperature) -> PyTemperature {
|
||||
PyTemperature {
|
||||
inner: entropyk::Temperature(self.inner.0 + other.inner.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn __sub__(&self, other: &PyTemperature) -> PyTemperature {
|
||||
PyTemperature {
|
||||
inner: entropyk::Temperature(self.inner.0 - other.inner.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Enthalpy
|
||||
// =============================================================================
|
||||
|
||||
/// Specific enthalpy in J/kg.
|
||||
///
|
||||
/// Construct with one of: ``j_per_kg``, ``kj_per_kg``.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// h = Enthalpy(kj_per_kg=250.0)
|
||||
/// print(h.to_kj_per_kg()) # 250.0
|
||||
#[pyclass(name = "Enthalpy", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyEnthalpy {
|
||||
pub(crate) inner: entropyk::Enthalpy,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyEnthalpy {
|
||||
/// Create an Enthalpy. Specify exactly one of: ``j_per_kg``, ``kj_per_kg``.
|
||||
#[new]
|
||||
#[pyo3(signature = (j_per_kg=None, kj_per_kg=None))]
|
||||
fn new(j_per_kg: Option<f64>, kj_per_kg: Option<f64>) -> PyResult<Self> {
|
||||
let inner = match (j_per_kg, kj_per_kg) {
|
||||
(Some(v), None) => entropyk::Enthalpy::from_joules_per_kg(v),
|
||||
(None, Some(v)) => entropyk::Enthalpy::from_kilojoules_per_kg(v),
|
||||
_ => {
|
||||
return Err(PyValueError::new_err(
|
||||
"Specify exactly one of: j_per_kg, kj_per_kg",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(PyEnthalpy { inner })
|
||||
}
|
||||
|
||||
/// Value in J/kg.
|
||||
fn to_j_per_kg(&self) -> f64 {
|
||||
self.inner.to_joules_per_kg()
|
||||
}
|
||||
|
||||
/// Value in kJ/kg.
|
||||
fn to_kj_per_kg(&self) -> f64 {
|
||||
self.inner.to_kilojoules_per_kg()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Enthalpy({:.2} J/kg = {:.2} kJ/kg)",
|
||||
self.inner.0,
|
||||
self.inner.0 / 1_000.0
|
||||
)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
format!("{:.2} J/kg", self.inner.0)
|
||||
}
|
||||
|
||||
fn __float__(&self) -> f64 {
|
||||
self.inner.0
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyEnthalpy) -> bool {
|
||||
(self.inner.0 - other.inner.0).abs() < 1e-10
|
||||
}
|
||||
|
||||
fn __add__(&self, other: &PyEnthalpy) -> PyEnthalpy {
|
||||
PyEnthalpy {
|
||||
inner: entropyk::Enthalpy(self.inner.0 + other.inner.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn __sub__(&self, other: &PyEnthalpy) -> PyEnthalpy {
|
||||
PyEnthalpy {
|
||||
inner: entropyk::Enthalpy(self.inner.0 - other.inner.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MassFlow
|
||||
// =============================================================================
|
||||
|
||||
/// Mass flow rate in kg/s.
|
||||
///
|
||||
/// Construct with one of: ``kg_per_s``, ``g_per_s``.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// m = MassFlow(kg_per_s=0.5)
|
||||
/// print(m.to_g_per_s()) # 500.0
|
||||
#[pyclass(name = "MassFlow", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyMassFlow {
|
||||
pub(crate) inner: entropyk::MassFlow,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyMassFlow {
|
||||
/// Create a MassFlow. Specify exactly one of: ``kg_per_s``, ``g_per_s``.
|
||||
#[new]
|
||||
#[pyo3(signature = (kg_per_s=None, g_per_s=None))]
|
||||
fn new(kg_per_s: Option<f64>, g_per_s: Option<f64>) -> PyResult<Self> {
|
||||
let inner = match (kg_per_s, g_per_s) {
|
||||
(Some(v), None) => entropyk::MassFlow::from_kg_per_s(v),
|
||||
(None, Some(v)) => entropyk::MassFlow::from_grams_per_s(v),
|
||||
_ => {
|
||||
return Err(PyValueError::new_err(
|
||||
"Specify exactly one of: kg_per_s, g_per_s",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(PyMassFlow { inner })
|
||||
}
|
||||
|
||||
/// Value in kg/s.
|
||||
fn to_kg_per_s(&self) -> f64 {
|
||||
self.inner.to_kg_per_s()
|
||||
}
|
||||
|
||||
/// Value in g/s.
|
||||
fn to_g_per_s(&self) -> f64 {
|
||||
self.inner.to_grams_per_s()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("MassFlow({:.6} kg/s)", self.inner.0)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
format!("{:.6} kg/s", self.inner.0)
|
||||
}
|
||||
|
||||
fn __float__(&self) -> f64 {
|
||||
self.inner.0
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyMassFlow) -> bool {
|
||||
(self.inner.0 - other.inner.0).abs() < 1e-15
|
||||
}
|
||||
|
||||
fn __add__(&self, other: &PyMassFlow) -> PyMassFlow {
|
||||
PyMassFlow {
|
||||
inner: entropyk::MassFlow(self.inner.0 + other.inner.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn __sub__(&self, other: &PyMassFlow) -> PyMassFlow {
|
||||
PyMassFlow {
|
||||
inner: entropyk::MassFlow(self.inner.0 - other.inner.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
45
bindings/python/test!entropyk.py
Normal file
45
bindings/python/test!entropyk.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import entropyk
|
||||
|
||||
# === Types physiques ===
|
||||
p = entropyk.Pressure(bar=10.0)
|
||||
print(p) # Pressure(1000000.0 Pa)
|
||||
print(p.to_bar()) # 10.0
|
||||
print(float(p)) # 1000000.0 (en Pascals)
|
||||
|
||||
t = entropyk.Temperature(celsius=25.0)
|
||||
print(t.to_celsius()) # 25.0
|
||||
print(t.to_kelvin()) # 298.15
|
||||
|
||||
h = entropyk.Enthalpy(kj_per_kg=400.0)
|
||||
m = entropyk.MassFlow(kg_per_s=0.5)
|
||||
|
||||
# === Composants ===
|
||||
cond = entropyk.Condenser(ua=10000.0) # UA = 10 kW/K
|
||||
evap = entropyk.Evaporator(ua=8000.0) # UA = 8 kW/K
|
||||
comp = entropyk.Compressor(efficiency=0.85)
|
||||
valve = entropyk.ExpansionValve(fluid="R134a")
|
||||
pipe = entropyk.Pipe(length=5.0, diameter=0.025)
|
||||
|
||||
# === Système ===
|
||||
system = entropyk.System()
|
||||
c_idx = system.add_component(comp)
|
||||
d_idx = system.add_component(cond)
|
||||
v_idx = system.add_component(valve)
|
||||
e_idx = system.add_component(evap)
|
||||
|
||||
system.add_edge(c_idx, d_idx) # compresseur → condenseur
|
||||
system.add_edge(d_idx, v_idx) # condenseur → détendeur
|
||||
system.add_edge(v_idx, e_idx) # détendeur → évaporateur
|
||||
system.add_edge(e_idx, c_idx) # évaporateur → compresseur
|
||||
|
||||
system.finalize()
|
||||
|
||||
# === Solveur ===
|
||||
config = entropyk.NewtonConfig(max_iterations=100, tolerance=1e-6)
|
||||
# result = config.solve(system) # résoudre le système
|
||||
|
||||
# === Exceptions ===
|
||||
try:
|
||||
entropyk.Pressure(bar=-1.0) # ValueError
|
||||
except ValueError as e:
|
||||
print(f"Erreur: {e}")
|
||||
0
bindings/python/tests/__init__.py
Normal file
0
bindings/python/tests/__init__.py
Normal file
BIN
bindings/python/tests/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
bindings/python/tests/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1
bindings/python/tests/conftest.py
Normal file
1
bindings/python/tests/conftest.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Pytest configuration for Entropyk Python bindings tests."""
|
||||
98
bindings/python/tests/test_benchmark.py
Normal file
98
bindings/python/tests/test_benchmark.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Entropyk — Performance Benchmark Tests.
|
||||
|
||||
Tests that measure Python→Rust call overhead and verify performance.
|
||||
These are not unit tests — they measure timing and should be run with
|
||||
``pytest -s`` for visible output.
|
||||
"""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
import entropyk
|
||||
|
||||
|
||||
class TestConstructorOverhead:
|
||||
"""Benchmark component construction overhead."""
|
||||
|
||||
def test_1000_compressor_constructions(self):
|
||||
"""Constructing 1000 Compressors should be very fast (< 100 ms)."""
|
||||
start = time.perf_counter()
|
||||
for _ in range(1000):
|
||||
entropyk.Compressor()
|
||||
elapsed = time.perf_counter() - start
|
||||
assert elapsed < 0.1, f"1000 Compressor constructions took {elapsed:.3f}s"
|
||||
|
||||
def test_1000_pressure_constructions(self):
|
||||
"""Constructing 1000 Pressure objects should be very fast."""
|
||||
start = time.perf_counter()
|
||||
for _ in range(1000):
|
||||
entropyk.Pressure(bar=1.0)
|
||||
elapsed = time.perf_counter() - start
|
||||
assert elapsed < 0.1, f"1000 Pressure constructions took {elapsed:.3f}s"
|
||||
|
||||
def test_1000_temperature_constructions(self):
|
||||
"""Constructing 1000 Temperature objects should be very fast."""
|
||||
start = time.perf_counter()
|
||||
for _ in range(1000):
|
||||
entropyk.Temperature(celsius=25.0)
|
||||
elapsed = time.perf_counter() - start
|
||||
assert elapsed < 0.1, f"1000 Temperature constructions took {elapsed:.3f}s"
|
||||
|
||||
|
||||
class TestConversionOverhead:
|
||||
"""Benchmark unit conversion overhead."""
|
||||
|
||||
def test_1000_pressure_conversions(self):
|
||||
"""Unit conversions should add negligible overhead."""
|
||||
p = entropyk.Pressure(bar=1.0)
|
||||
start = time.perf_counter()
|
||||
for _ in range(1000):
|
||||
_ = p.to_bar()
|
||||
_ = p.to_pascals()
|
||||
_ = p.to_kpa()
|
||||
elapsed = time.perf_counter() - start
|
||||
assert elapsed < 0.1, f"3000 pressure conversions took {elapsed:.3f}s"
|
||||
|
||||
def test_1000_temperature_conversions(self):
|
||||
"""Temperature conversions should be fast."""
|
||||
t = entropyk.Temperature(celsius=25.0)
|
||||
start = time.perf_counter()
|
||||
for _ in range(1000):
|
||||
_ = t.to_celsius()
|
||||
_ = t.to_kelvin()
|
||||
_ = t.to_fahrenheit()
|
||||
elapsed = time.perf_counter() - start
|
||||
assert elapsed < 0.1, f"3000 temperature conversions took {elapsed:.3f}s"
|
||||
|
||||
|
||||
class TestArithmeticOverhead:
|
||||
"""Benchmark arithmetic operation overhead."""
|
||||
|
||||
def test_1000_additions(self):
|
||||
"""1000 pressure additions should be fast."""
|
||||
p1 = entropyk.Pressure(pa=101325.0)
|
||||
p2 = entropyk.Pressure(pa=50000.0)
|
||||
start = time.perf_counter()
|
||||
for _ in range(1000):
|
||||
_ = p1 + p2
|
||||
elapsed = time.perf_counter() - start
|
||||
assert elapsed < 0.1, f"1000 additions took {elapsed:.3f}s"
|
||||
|
||||
|
||||
class TestSystemBuildOverhead:
|
||||
"""Benchmark system construction overhead."""
|
||||
|
||||
def test_100_system_builds(self):
|
||||
"""Building 100 simple systems (4 components + 4 edges) should be fast."""
|
||||
start = time.perf_counter()
|
||||
for _ in range(100):
|
||||
system = entropyk.System()
|
||||
c = system.add_component(entropyk.Compressor())
|
||||
d = system.add_component(entropyk.Condenser())
|
||||
e = system.add_component(entropyk.ExpansionValve())
|
||||
v = system.add_component(entropyk.Evaporator())
|
||||
system.add_edge(c, d)
|
||||
system.add_edge(d, e)
|
||||
system.add_edge(e, v)
|
||||
system.add_edge(v, c)
|
||||
elapsed = time.perf_counter() - start
|
||||
assert elapsed < 1.0, f"100 system builds took {elapsed:.3f}s"
|
||||
248
bindings/python/tests/test_components.py
Normal file
248
bindings/python/tests/test_components.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Entropyk — Unit Tests for Component Wrappers.
|
||||
|
||||
Tests for all component constructors, validation, and repr.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import entropyk
|
||||
|
||||
|
||||
class TestCompressor:
|
||||
"""Tests for Compressor component."""
|
||||
|
||||
def test_default(self):
|
||||
c = entropyk.Compressor()
|
||||
assert "Compressor" in repr(c)
|
||||
|
||||
def test_custom_params(self):
|
||||
c = entropyk.Compressor(speed_rpm=3600.0, efficiency=0.9, fluid="R410A")
|
||||
assert c.speed == 3600.0
|
||||
assert c.efficiency_value == pytest.approx(0.9)
|
||||
assert c.fluid_name == "R410A"
|
||||
|
||||
def test_negative_speed_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
entropyk.Compressor(speed_rpm=-1.0)
|
||||
|
||||
def test_negative_displacement_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
entropyk.Compressor(displacement=-1.0)
|
||||
|
||||
def test_invalid_efficiency_raises(self):
|
||||
with pytest.raises(ValueError, match="between"):
|
||||
entropyk.Compressor(efficiency=1.5)
|
||||
|
||||
def test_repr(self):
|
||||
c = entropyk.Compressor(speed_rpm=2900.0, efficiency=0.85, fluid="R134a")
|
||||
r = repr(c)
|
||||
assert "2900" in r
|
||||
assert "0.85" in r
|
||||
assert "R134a" in r
|
||||
|
||||
|
||||
class TestCondenser:
|
||||
"""Tests for Condenser component."""
|
||||
|
||||
def test_default(self):
|
||||
c = entropyk.Condenser()
|
||||
assert c.ua_value == pytest.approx(5000.0)
|
||||
|
||||
def test_custom_ua(self):
|
||||
c = entropyk.Condenser(ua=10000.0)
|
||||
assert c.ua_value == pytest.approx(10000.0)
|
||||
|
||||
def test_negative_ua_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
entropyk.Condenser(ua=-1.0)
|
||||
|
||||
def test_repr(self):
|
||||
c = entropyk.Condenser(ua=5000.0)
|
||||
assert "Condenser" in repr(c)
|
||||
assert "5000" in repr(c)
|
||||
|
||||
|
||||
class TestEvaporator:
|
||||
"""Tests for Evaporator component."""
|
||||
|
||||
def test_default(self):
|
||||
e = entropyk.Evaporator()
|
||||
assert e.ua_value == pytest.approx(3000.0)
|
||||
|
||||
def test_custom_ua(self):
|
||||
e = entropyk.Evaporator(ua=8000.0)
|
||||
assert e.ua_value == pytest.approx(8000.0)
|
||||
|
||||
def test_negative_ua_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
entropyk.Evaporator(ua=-1.0)
|
||||
|
||||
def test_repr(self):
|
||||
e = entropyk.Evaporator(ua=3000.0)
|
||||
assert "Evaporator" in repr(e)
|
||||
|
||||
|
||||
class TestEconomizer:
|
||||
"""Tests for Economizer component."""
|
||||
|
||||
def test_default(self):
|
||||
e = entropyk.Economizer()
|
||||
assert "Economizer" in repr(e)
|
||||
|
||||
def test_custom_ua(self):
|
||||
e = entropyk.Economizer(ua=5000.0)
|
||||
assert "5000" in repr(e)
|
||||
|
||||
def test_negative_ua_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
entropyk.Economizer(ua=-1.0)
|
||||
|
||||
|
||||
class TestExpansionValve:
|
||||
"""Tests for ExpansionValve component."""
|
||||
|
||||
def test_default(self):
|
||||
v = entropyk.ExpansionValve()
|
||||
assert v.fluid_name == "R134a"
|
||||
assert v.opening_value is None
|
||||
|
||||
def test_with_opening(self):
|
||||
v = entropyk.ExpansionValve(opening=0.5)
|
||||
assert v.opening_value == pytest.approx(0.5)
|
||||
|
||||
def test_invalid_opening_raises(self):
|
||||
with pytest.raises(ValueError, match="between"):
|
||||
entropyk.ExpansionValve(opening=1.5)
|
||||
|
||||
def test_repr(self):
|
||||
v = entropyk.ExpansionValve(fluid="R410A", opening=0.8)
|
||||
assert "ExpansionValve" in repr(v)
|
||||
assert "R410A" in repr(v)
|
||||
|
||||
|
||||
class TestPipe:
|
||||
"""Tests for Pipe component."""
|
||||
|
||||
def test_default(self):
|
||||
p = entropyk.Pipe()
|
||||
assert "Pipe" in repr(p)
|
||||
|
||||
def test_custom_params(self):
|
||||
p = entropyk.Pipe(length=5.0, diameter=0.025)
|
||||
assert "5.00" in repr(p)
|
||||
|
||||
def test_negative_length_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
entropyk.Pipe(length=-1.0)
|
||||
|
||||
def test_negative_diameter_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
entropyk.Pipe(diameter=-0.01)
|
||||
|
||||
|
||||
class TestPump:
|
||||
"""Tests for Pump component."""
|
||||
|
||||
def test_default(self):
|
||||
p = entropyk.Pump()
|
||||
assert "Pump" in repr(p)
|
||||
|
||||
def test_negative_pressure_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
entropyk.Pump(pressure_rise_pa=-100.0)
|
||||
|
||||
def test_invalid_efficiency_raises(self):
|
||||
with pytest.raises(ValueError, match="between"):
|
||||
entropyk.Pump(efficiency=2.0)
|
||||
|
||||
|
||||
class TestFan:
|
||||
"""Tests for Fan component."""
|
||||
|
||||
def test_default(self):
|
||||
f = entropyk.Fan()
|
||||
assert "Fan" in repr(f)
|
||||
|
||||
def test_negative_pressure_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
entropyk.Fan(pressure_rise_pa=-100.0)
|
||||
|
||||
|
||||
class TestFlowSplitter:
|
||||
"""Tests for FlowSplitter component."""
|
||||
|
||||
def test_default(self):
|
||||
s = entropyk.FlowSplitter()
|
||||
assert "FlowSplitter" in repr(s)
|
||||
|
||||
def test_custom_outlets(self):
|
||||
s = entropyk.FlowSplitter(n_outlets=3)
|
||||
assert "3" in repr(s)
|
||||
|
||||
def test_too_few_outlets_raises(self):
|
||||
with pytest.raises(ValueError, match=">="):
|
||||
entropyk.FlowSplitter(n_outlets=1)
|
||||
|
||||
|
||||
class TestFlowMerger:
|
||||
"""Tests for FlowMerger component."""
|
||||
|
||||
def test_default(self):
|
||||
m = entropyk.FlowMerger()
|
||||
assert "FlowMerger" in repr(m)
|
||||
|
||||
def test_custom_inlets(self):
|
||||
m = entropyk.FlowMerger(n_inlets=4)
|
||||
assert "4" in repr(m)
|
||||
|
||||
def test_too_few_inlets_raises(self):
|
||||
with pytest.raises(ValueError, match=">="):
|
||||
entropyk.FlowMerger(n_inlets=1)
|
||||
|
||||
|
||||
class TestFlowSource:
|
||||
"""Tests for FlowSource component."""
|
||||
|
||||
def test_default(self):
|
||||
s = entropyk.FlowSource()
|
||||
assert "FlowSource" in repr(s)
|
||||
|
||||
def test_custom(self):
|
||||
s = entropyk.FlowSource(pressure_pa=200000.0, temperature_k=350.0)
|
||||
assert "200000" in repr(s)
|
||||
|
||||
def test_negative_pressure_raises(self):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
entropyk.FlowSource(pressure_pa=-1.0)
|
||||
|
||||
|
||||
class TestFlowSink:
|
||||
"""Tests for FlowSink component."""
|
||||
|
||||
def test_default(self):
|
||||
s = entropyk.FlowSink()
|
||||
assert "FlowSink" in repr(s)
|
||||
|
||||
|
||||
class TestOperationalState:
|
||||
"""Tests for OperationalState enum."""
|
||||
|
||||
def test_on(self):
|
||||
s = entropyk.OperationalState("on")
|
||||
assert str(s) == "On"
|
||||
|
||||
def test_off(self):
|
||||
s = entropyk.OperationalState("off")
|
||||
assert str(s) == "Off"
|
||||
|
||||
def test_bypass(self):
|
||||
s = entropyk.OperationalState("bypass")
|
||||
assert str(s) == "Bypass"
|
||||
|
||||
def test_invalid_raises(self):
|
||||
with pytest.raises(ValueError, match="one of"):
|
||||
entropyk.OperationalState("invalid")
|
||||
|
||||
def test_eq(self):
|
||||
s1 = entropyk.OperationalState("on")
|
||||
s2 = entropyk.OperationalState("on")
|
||||
assert s1 == s2
|
||||
96
bindings/python/tests/test_errors.py
Normal file
96
bindings/python/tests/test_errors.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Entropyk — Unit Tests for Exception Hierarchy.
|
||||
|
||||
Tests that all exception types exist, inherit correctly, and carry messages.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import entropyk
|
||||
|
||||
|
||||
class TestExceptionHierarchy:
|
||||
"""Tests for Python exception class hierarchy."""
|
||||
|
||||
def test_entropyk_error_exists(self):
|
||||
assert hasattr(entropyk, "EntropykError")
|
||||
assert issubclass(entropyk.EntropykError, Exception)
|
||||
|
||||
def test_solver_error_inherits(self):
|
||||
assert issubclass(entropyk.SolverError, entropyk.EntropykError)
|
||||
|
||||
def test_timeout_error_inherits(self):
|
||||
assert issubclass(entropyk.TimeoutError, entropyk.SolverError)
|
||||
|
||||
def test_control_saturation_error_inherits(self):
|
||||
assert issubclass(entropyk.ControlSaturationError, entropyk.SolverError)
|
||||
|
||||
def test_fluid_error_inherits(self):
|
||||
assert issubclass(entropyk.FluidError, entropyk.EntropykError)
|
||||
|
||||
def test_component_error_inherits(self):
|
||||
assert issubclass(entropyk.ComponentError, entropyk.EntropykError)
|
||||
|
||||
def test_topology_error_inherits(self):
|
||||
assert issubclass(entropyk.TopologyError, entropyk.EntropykError)
|
||||
|
||||
def test_validation_error_inherits(self):
|
||||
assert issubclass(entropyk.ValidationError, entropyk.EntropykError)
|
||||
|
||||
|
||||
class TestExceptionMessages:
|
||||
"""Tests that exceptions carry descriptive messages."""
|
||||
|
||||
def test_entropyk_error_message(self):
|
||||
err = entropyk.EntropykError("test message")
|
||||
assert str(err) == "test message"
|
||||
|
||||
def test_solver_error_message(self):
|
||||
err = entropyk.SolverError("convergence failed")
|
||||
assert "convergence failed" in str(err)
|
||||
|
||||
def test_timeout_error_message(self):
|
||||
err = entropyk.TimeoutError("timed out after 5s")
|
||||
assert "timed out" in str(err)
|
||||
|
||||
def test_fluid_error_message(self):
|
||||
err = entropyk.FluidError("R134a not found")
|
||||
assert "R134a" in str(err)
|
||||
|
||||
def test_topology_error_message(self):
|
||||
err = entropyk.TopologyError("graph cycle detected")
|
||||
assert "cycle" in str(err)
|
||||
|
||||
|
||||
class TestExceptionCatching:
|
||||
"""Tests that exceptions can be caught at different hierarchy levels."""
|
||||
|
||||
def test_catch_solver_as_entropyk(self):
|
||||
with pytest.raises(entropyk.EntropykError):
|
||||
raise entropyk.SolverError("test")
|
||||
|
||||
def test_catch_timeout_as_solver(self):
|
||||
with pytest.raises(entropyk.SolverError):
|
||||
raise entropyk.TimeoutError("test")
|
||||
|
||||
def test_catch_timeout_as_entropyk(self):
|
||||
with pytest.raises(entropyk.EntropykError):
|
||||
raise entropyk.TimeoutError("test")
|
||||
|
||||
def test_catch_fluid_as_entropyk(self):
|
||||
with pytest.raises(entropyk.EntropykError):
|
||||
raise entropyk.FluidError("test")
|
||||
|
||||
def test_catch_component_as_entropyk(self):
|
||||
with pytest.raises(entropyk.EntropykError):
|
||||
raise entropyk.ComponentError("test")
|
||||
|
||||
def test_timeout_not_caught_as_fluid(self):
|
||||
"""TimeoutError should NOT be caught by FluidError."""
|
||||
with pytest.raises(entropyk.TimeoutError):
|
||||
raise entropyk.TimeoutError("test")
|
||||
# Verify it doesn't match FluidError
|
||||
try:
|
||||
raise entropyk.TimeoutError("test")
|
||||
except entropyk.FluidError:
|
||||
pytest.fail("TimeoutError should not be caught by FluidError")
|
||||
except entropyk.TimeoutError:
|
||||
pass # Expected
|
||||
72
bindings/python/tests/test_numpy.py
Normal file
72
bindings/python/tests/test_numpy.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Entropyk — NumPy / Buffer Protocol Tests.
|
||||
|
||||
Tests for zero-copy state vector access and NumPy integration.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import entropyk
|
||||
|
||||
# numpy may not be installed in test env — skip gracefully
|
||||
numpy = pytest.importorskip("numpy")
|
||||
|
||||
|
||||
class TestStateVectorNumpy:
|
||||
"""Tests for state vector as NumPy array."""
|
||||
|
||||
def test_state_vector_to_numpy(self):
|
||||
"""ConvergedState.state_vector returns a list convertible to np array."""
|
||||
# Build a minimal system so we can get a state vector length
|
||||
system = entropyk.System()
|
||||
system.add_component(entropyk.Condenser())
|
||||
system.add_component(entropyk.Evaporator())
|
||||
system.add_edge(0, 1)
|
||||
system.add_edge(1, 0)
|
||||
system.finalize()
|
||||
|
||||
# The state_vector_len should be > 0 after finalize
|
||||
svl = system.state_vector_len
|
||||
assert svl >= 0
|
||||
|
||||
def test_converged_state_vector_is_list(self):
|
||||
"""The state_vector attribute on ConvergedState should be a Python list
|
||||
of floats, convertible to numpy.array."""
|
||||
# We can't solve without real physics, but we can verify the accessor type
|
||||
# from the class itself
|
||||
assert hasattr(entropyk, "ConvergedState")
|
||||
|
||||
def test_numpy_array_from_list(self):
|
||||
"""Verify that a list of floats (as returned by state_vector) can be
|
||||
efficiently converted to a numpy array."""
|
||||
data = [1.0, 2.0, 3.0, 4.0, 5.0]
|
||||
arr = numpy.array(data, dtype=numpy.float64)
|
||||
assert arr.shape == (5,)
|
||||
assert arr.dtype == numpy.float64
|
||||
numpy.testing.assert_array_almost_equal(arr, data)
|
||||
|
||||
|
||||
class TestTypesWithNumpy:
|
||||
"""Tests for using core types with NumPy."""
|
||||
|
||||
def test_pressure_float_in_numpy(self):
|
||||
"""Pressure can be used as a float value in numpy operations."""
|
||||
p = entropyk.Pressure(bar=1.0)
|
||||
arr = numpy.array([float(p)], dtype=numpy.float64)
|
||||
assert arr[0] == pytest.approx(100000.0)
|
||||
|
||||
def test_temperature_float_in_numpy(self):
|
||||
"""Temperature can be used as a float value in numpy operations."""
|
||||
t = entropyk.Temperature(celsius=25.0)
|
||||
arr = numpy.array([float(t)], dtype=numpy.float64)
|
||||
assert arr[0] == pytest.approx(298.15)
|
||||
|
||||
def test_enthalpy_float_in_numpy(self):
|
||||
"""Enthalpy can be used as a float value in numpy operations."""
|
||||
h = entropyk.Enthalpy(kj_per_kg=250.0)
|
||||
arr = numpy.array([float(h)], dtype=numpy.float64)
|
||||
assert arr[0] == pytest.approx(250000.0)
|
||||
|
||||
def test_massflow_float_in_numpy(self):
|
||||
"""MassFlow can be used as a float value in numpy operations."""
|
||||
m = entropyk.MassFlow(kg_per_s=0.5)
|
||||
arr = numpy.array([float(m)], dtype=numpy.float64)
|
||||
assert arr[0] == pytest.approx(0.5)
|
||||
147
bindings/python/tests/test_solver.py
Normal file
147
bindings/python/tests/test_solver.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Entropyk — End-to-End Solver Tests.
|
||||
|
||||
Tests for System construction, finalization, and solving from Python.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import entropyk
|
||||
|
||||
|
||||
class TestSystemConstruction:
|
||||
"""Tests for System graph building."""
|
||||
|
||||
def test_empty_system(self):
|
||||
system = entropyk.System()
|
||||
assert system.node_count == 0
|
||||
assert system.edge_count == 0
|
||||
|
||||
def test_add_component(self):
|
||||
system = entropyk.System()
|
||||
idx = system.add_component(entropyk.Condenser(ua=5000.0))
|
||||
assert idx == 0
|
||||
assert system.node_count == 1
|
||||
|
||||
def test_add_multiple_components(self):
|
||||
system = entropyk.System()
|
||||
i0 = system.add_component(entropyk.Compressor())
|
||||
i1 = system.add_component(entropyk.Condenser())
|
||||
i2 = system.add_component(entropyk.ExpansionValve())
|
||||
i3 = system.add_component(entropyk.Evaporator())
|
||||
assert system.node_count == 4
|
||||
assert i0 != i1 != i2 != i3
|
||||
|
||||
def test_add_edge(self):
|
||||
system = entropyk.System()
|
||||
i0 = system.add_component(entropyk.Compressor())
|
||||
i1 = system.add_component(entropyk.Condenser())
|
||||
edge_idx = system.add_edge(i0, i1)
|
||||
assert edge_idx == 0
|
||||
assert system.edge_count == 1
|
||||
|
||||
def test_repr(self):
|
||||
system = entropyk.System()
|
||||
system.add_component(entropyk.Compressor())
|
||||
system.add_component(entropyk.Condenser())
|
||||
system.add_edge(0, 1)
|
||||
r = repr(system)
|
||||
assert "System" in r
|
||||
assert "nodes=2" in r
|
||||
assert "edges=1" in r
|
||||
|
||||
|
||||
class TestSystemFinalize:
|
||||
"""Tests for system finalization."""
|
||||
|
||||
def test_simple_cycle_finalize(self):
|
||||
"""Build and finalize a simple 4-component cycle."""
|
||||
system = entropyk.System()
|
||||
comp = system.add_component(entropyk.Compressor())
|
||||
cond = system.add_component(entropyk.Condenser())
|
||||
exv = system.add_component(entropyk.ExpansionValve())
|
||||
evap = system.add_component(entropyk.Evaporator())
|
||||
|
||||
system.add_edge(comp, cond)
|
||||
system.add_edge(cond, exv)
|
||||
system.add_edge(exv, evap)
|
||||
system.add_edge(evap, comp)
|
||||
|
||||
system.finalize()
|
||||
assert system.state_vector_len > 0
|
||||
|
||||
|
||||
class TestSolverConfigs:
|
||||
"""Tests for solver configuration objects."""
|
||||
|
||||
def test_newton_default(self):
|
||||
config = entropyk.NewtonConfig()
|
||||
assert "NewtonConfig" in repr(config)
|
||||
assert "100" in repr(config)
|
||||
|
||||
def test_newton_custom(self):
|
||||
config = entropyk.NewtonConfig(
|
||||
max_iterations=200,
|
||||
tolerance=1e-8,
|
||||
line_search=True,
|
||||
timeout_ms=5000,
|
||||
)
|
||||
assert "200" in repr(config)
|
||||
|
||||
def test_picard_default(self):
|
||||
config = entropyk.PicardConfig()
|
||||
assert "PicardConfig" in repr(config)
|
||||
|
||||
def test_picard_custom(self):
|
||||
config = entropyk.PicardConfig(
|
||||
max_iterations=300,
|
||||
tolerance=1e-5,
|
||||
relaxation=0.7,
|
||||
)
|
||||
assert "300" in repr(config)
|
||||
|
||||
def test_picard_invalid_relaxation_raises(self):
|
||||
with pytest.raises(ValueError, match="between"):
|
||||
entropyk.PicardConfig(relaxation=1.5)
|
||||
|
||||
def test_fallback_default(self):
|
||||
config = entropyk.FallbackConfig()
|
||||
assert "FallbackConfig" in repr(config)
|
||||
|
||||
def test_fallback_custom(self):
|
||||
newton = entropyk.NewtonConfig(max_iterations=50)
|
||||
picard = entropyk.PicardConfig(max_iterations=200)
|
||||
config = entropyk.FallbackConfig(newton=newton, picard=picard)
|
||||
assert "50" in repr(config)
|
||||
|
||||
|
||||
class TestConvergedState:
|
||||
"""Tests for ConvergedState and ConvergenceStatus types."""
|
||||
|
||||
def test_convergence_status_repr(self):
|
||||
# We can't easily create a ConvergedState without solving,
|
||||
# so we just verify the classes exist
|
||||
assert hasattr(entropyk, "ConvergedState")
|
||||
assert hasattr(entropyk, "ConvergenceStatus")
|
||||
|
||||
|
||||
class TestAllComponentsInSystem:
|
||||
"""Test that all component types can be added to a System."""
|
||||
|
||||
@pytest.mark.parametrize("component_factory", [
|
||||
lambda: entropyk.Compressor(),
|
||||
lambda: entropyk.Condenser(),
|
||||
lambda: entropyk.Evaporator(),
|
||||
lambda: entropyk.Economizer(),
|
||||
lambda: entropyk.ExpansionValve(),
|
||||
lambda: entropyk.Pipe(),
|
||||
lambda: entropyk.Pump(),
|
||||
lambda: entropyk.Fan(),
|
||||
lambda: entropyk.FlowSplitter(),
|
||||
lambda: entropyk.FlowMerger(),
|
||||
lambda: entropyk.FlowSource(),
|
||||
lambda: entropyk.FlowSink(),
|
||||
])
|
||||
def test_add_component(self, component_factory):
|
||||
system = entropyk.System()
|
||||
idx = system.add_component(component_factory())
|
||||
assert idx >= 0
|
||||
assert system.node_count == 1
|
||||
208
bindings/python/tests/test_types.py
Normal file
208
bindings/python/tests/test_types.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Entropyk — Unit Tests for Core Physical Types.
|
||||
|
||||
Tests for Pressure, Temperature, Enthalpy, and MassFlow wrappers.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import entropyk
|
||||
|
||||
|
||||
class TestPressure:
|
||||
"""Tests for Pressure type."""
|
||||
|
||||
def test_from_pa(self):
|
||||
p = entropyk.Pressure(pa=101325.0)
|
||||
assert p.to_pascals() == pytest.approx(101325.0)
|
||||
|
||||
def test_from_bar(self):
|
||||
p = entropyk.Pressure(bar=1.01325)
|
||||
assert p.to_pascals() == pytest.approx(101325.0)
|
||||
|
||||
def test_from_kpa(self):
|
||||
p = entropyk.Pressure(kpa=101.325)
|
||||
assert p.to_pascals() == pytest.approx(101325.0)
|
||||
|
||||
def test_from_psi(self):
|
||||
p = entropyk.Pressure(psi=14.696)
|
||||
assert p.to_pascals() == pytest.approx(101325.0, rel=1e-3)
|
||||
|
||||
def test_to_bar(self):
|
||||
p = entropyk.Pressure(pa=100000.0)
|
||||
assert p.to_bar() == pytest.approx(1.0)
|
||||
|
||||
def test_to_kpa(self):
|
||||
p = entropyk.Pressure(pa=1000.0)
|
||||
assert p.to_kpa() == pytest.approx(1.0)
|
||||
|
||||
def test_float(self):
|
||||
p = entropyk.Pressure(pa=101325.0)
|
||||
assert float(p) == pytest.approx(101325.0)
|
||||
|
||||
def test_repr(self):
|
||||
p = entropyk.Pressure(bar=1.0)
|
||||
assert "Pressure" in repr(p)
|
||||
assert "bar" in repr(p)
|
||||
|
||||
def test_str(self):
|
||||
p = entropyk.Pressure(pa=100.0)
|
||||
assert "Pa" in str(p)
|
||||
|
||||
def test_eq(self):
|
||||
p1 = entropyk.Pressure(bar=1.0)
|
||||
p2 = entropyk.Pressure(bar=1.0)
|
||||
assert p1 == p2
|
||||
|
||||
def test_add(self):
|
||||
p1 = entropyk.Pressure(pa=100.0)
|
||||
p2 = entropyk.Pressure(pa=200.0)
|
||||
result = p1 + p2
|
||||
assert float(result) == pytest.approx(300.0)
|
||||
|
||||
def test_sub(self):
|
||||
p1 = entropyk.Pressure(pa=300.0)
|
||||
p2 = entropyk.Pressure(pa=100.0)
|
||||
result = p1 - p2
|
||||
assert float(result) == pytest.approx(200.0)
|
||||
|
||||
def test_multiple_kwargs_raises(self):
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
entropyk.Pressure(pa=100.0, bar=1.0)
|
||||
|
||||
def test_no_kwargs_raises(self):
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
entropyk.Pressure()
|
||||
|
||||
|
||||
class TestTemperature:
|
||||
"""Tests for Temperature type."""
|
||||
|
||||
def test_from_kelvin(self):
|
||||
t = entropyk.Temperature(kelvin=300.0)
|
||||
assert t.to_kelvin() == pytest.approx(300.0)
|
||||
|
||||
def test_from_celsius(self):
|
||||
t = entropyk.Temperature(celsius=25.0)
|
||||
assert t.to_kelvin() == pytest.approx(298.15)
|
||||
|
||||
def test_from_fahrenheit(self):
|
||||
t = entropyk.Temperature(fahrenheit=77.0)
|
||||
assert t.to_celsius() == pytest.approx(25.0)
|
||||
|
||||
def test_to_celsius(self):
|
||||
t = entropyk.Temperature(kelvin=273.15)
|
||||
assert t.to_celsius() == pytest.approx(0.0)
|
||||
|
||||
def test_to_fahrenheit(self):
|
||||
t = entropyk.Temperature(celsius=100.0)
|
||||
assert t.to_fahrenheit() == pytest.approx(212.0)
|
||||
|
||||
def test_float(self):
|
||||
t = entropyk.Temperature(kelvin=300.0)
|
||||
assert float(t) == pytest.approx(300.0)
|
||||
|
||||
def test_repr(self):
|
||||
t = entropyk.Temperature(celsius=25.0)
|
||||
assert "Temperature" in repr(t)
|
||||
|
||||
def test_eq(self):
|
||||
t1 = entropyk.Temperature(celsius=25.0)
|
||||
t2 = entropyk.Temperature(celsius=25.0)
|
||||
assert t1 == t2
|
||||
|
||||
def test_add(self):
|
||||
t1 = entropyk.Temperature(kelvin=100.0)
|
||||
t2 = entropyk.Temperature(kelvin=200.0)
|
||||
result = t1 + t2
|
||||
assert float(result) == pytest.approx(300.0)
|
||||
|
||||
def test_sub(self):
|
||||
t1 = entropyk.Temperature(kelvin=300.0)
|
||||
t2 = entropyk.Temperature(kelvin=100.0)
|
||||
result = t1 - t2
|
||||
assert float(result) == pytest.approx(200.0)
|
||||
|
||||
def test_multiple_kwargs_raises(self):
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
entropyk.Temperature(kelvin=300.0, celsius=25.0)
|
||||
|
||||
|
||||
class TestEnthalpy:
|
||||
"""Tests for Enthalpy type."""
|
||||
|
||||
def test_from_j_per_kg(self):
|
||||
h = entropyk.Enthalpy(j_per_kg=250000.0)
|
||||
assert h.to_j_per_kg() == pytest.approx(250000.0)
|
||||
|
||||
def test_from_kj_per_kg(self):
|
||||
h = entropyk.Enthalpy(kj_per_kg=250.0)
|
||||
assert h.to_j_per_kg() == pytest.approx(250000.0)
|
||||
|
||||
def test_to_kj_per_kg(self):
|
||||
h = entropyk.Enthalpy(j_per_kg=250000.0)
|
||||
assert h.to_kj_per_kg() == pytest.approx(250.0)
|
||||
|
||||
def test_float(self):
|
||||
h = entropyk.Enthalpy(j_per_kg=250000.0)
|
||||
assert float(h) == pytest.approx(250000.0)
|
||||
|
||||
def test_repr(self):
|
||||
h = entropyk.Enthalpy(kj_per_kg=250.0)
|
||||
assert "Enthalpy" in repr(h)
|
||||
|
||||
def test_eq(self):
|
||||
h1 = entropyk.Enthalpy(kj_per_kg=250.0)
|
||||
h2 = entropyk.Enthalpy(kj_per_kg=250.0)
|
||||
assert h1 == h2
|
||||
|
||||
def test_add(self):
|
||||
h1 = entropyk.Enthalpy(j_per_kg=100.0)
|
||||
h2 = entropyk.Enthalpy(j_per_kg=200.0)
|
||||
result = h1 + h2
|
||||
assert float(result) == pytest.approx(300.0)
|
||||
|
||||
def test_sub(self):
|
||||
h1 = entropyk.Enthalpy(j_per_kg=300.0)
|
||||
h2 = entropyk.Enthalpy(j_per_kg=100.0)
|
||||
result = h1 - h2
|
||||
assert float(result) == pytest.approx(200.0)
|
||||
|
||||
|
||||
class TestMassFlow:
|
||||
"""Tests for MassFlow type."""
|
||||
|
||||
def test_from_kg_per_s(self):
|
||||
m = entropyk.MassFlow(kg_per_s=0.5)
|
||||
assert m.to_kg_per_s() == pytest.approx(0.5)
|
||||
|
||||
def test_from_g_per_s(self):
|
||||
m = entropyk.MassFlow(g_per_s=500.0)
|
||||
assert m.to_kg_per_s() == pytest.approx(0.5)
|
||||
|
||||
def test_to_g_per_s(self):
|
||||
m = entropyk.MassFlow(kg_per_s=0.5)
|
||||
assert m.to_g_per_s() == pytest.approx(500.0)
|
||||
|
||||
def test_float(self):
|
||||
m = entropyk.MassFlow(kg_per_s=0.5)
|
||||
assert float(m) == pytest.approx(0.5)
|
||||
|
||||
def test_repr(self):
|
||||
m = entropyk.MassFlow(kg_per_s=0.5)
|
||||
assert "MassFlow" in repr(m)
|
||||
|
||||
def test_eq(self):
|
||||
m1 = entropyk.MassFlow(kg_per_s=0.5)
|
||||
m2 = entropyk.MassFlow(kg_per_s=0.5)
|
||||
assert m1 == m2
|
||||
|
||||
def test_add(self):
|
||||
m1 = entropyk.MassFlow(kg_per_s=0.1)
|
||||
m2 = entropyk.MassFlow(kg_per_s=0.2)
|
||||
result = m1 + m2
|
||||
assert float(result) == pytest.approx(0.3)
|
||||
|
||||
def test_sub(self):
|
||||
m1 = entropyk.MassFlow(kg_per_s=0.5)
|
||||
m2 = entropyk.MassFlow(kg_per_s=0.2)
|
||||
result = m1 - m2
|
||||
assert float(result) == pytest.approx(0.3)
|
||||
91
bindings/python/uv.lock
generated
Normal file
91
bindings/python/uv.lock
generated
Normal file
@@ -0,0 +1,91 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.9"
|
||||
|
||||
[[package]]
|
||||
name = "entropyk"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "maturin" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "maturin", specifier = ">=1.12.4" }]
|
||||
|
||||
[[package]]
|
||||
name = "maturin"
|
||||
version = "1.12.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/a6/54e73f0ec0224488ae25196ce8b4df298cae613b099ad0c4f39dd7e3a8d2/maturin-1.12.4.tar.gz", hash = "sha256:06f6438be7e723aaf4b412fb34839854b540a1350f7614fadf5bd1db2b98d5f7", size = 262134, upload-time = "2026-02-21T10:24:25.64Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/cd/8285f37bf968b8485e3c7eb43349a5adbccfddfc487cd4327fb9104578cc/maturin-1.12.4-py3-none-linux_armv6l.whl", hash = "sha256:cf8a0eddef9ab8773bc823c77aed3de9a5c85fb760c86448048a79ef89794c81", size = 9758449, upload-time = "2026-02-21T10:24:35.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/91/f51191db83735f77bc988c8034730bb63b750a4a1a04f9c8cba10f44ad45/maturin-1.12.4-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:eba1bd1c1513d00fec75228da98622c68a9f50f9693aaa6fb7dacb244e7bbf26", size = 18938848, upload-time = "2026-02-21T10:24:10.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/47/03c422adeac93b903354b322bba632754fdb134b27ace71b5603feba5906/maturin-1.12.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:89749cfc0e6baf5517fa370729a98955552e42fefc406b95732d5c8e85bc90c0", size = 9791641, upload-time = "2026-02-21T10:24:21.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/30/dd78acf6afc48d358512b5ed928fd24e2bc6b68db69b1f6bba3ffd7bcaed/maturin-1.12.4-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:4d68664e5b81f282144a3b717a7e8593ec94ac87d7ae563a4c464e93d6cde877", size = 9811625, upload-time = "2026-02-21T10:24:08.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/9a/a6e358a18815ab090ef55187da0066df01a955c7c44a61fb83b127055f23/maturin-1.12.4-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:88e09e6c386b08974fab0c7e4c07d7c7c50a0ba63095d31e930d80568488e1be", size = 10255812, upload-time = "2026-02-21T10:24:15.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/c5/84dfcce1f3475237cba6e6201a1939980025afbb41c076aa5147b10ac202/maturin-1.12.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:5cc56481b0f360571587c35a1d960ce6d0a0258d49aebb6af98fff9db837c337", size = 9645462, upload-time = "2026-02-21T10:24:28.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/82/0845fff86ea044028302db17bc611e9bfe1b7b2c992756162cbe71267df5/maturin-1.12.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:8fd7eb0c9bb017e98d81aa86a1d440b912fe4f7f219571035dd6ab330c82071c", size = 9593649, upload-time = "2026-02-21T10:24:33.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/14/6e8969cd48c7c8ea27d7638e572d46eeba9aa0cb370d3031eb6a3f10ff8d/maturin-1.12.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:5bb07c349dd066277a61e017a6d6e0860cd54b7b33f8ead10b9e5a4ffb740a0a", size = 12681515, upload-time = "2026-02-21T10:24:31.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/8d/2ad86623dca3cfa394049f4220188dececa6e4cefd73ac1f1385fc79c876/maturin-1.12.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c21baaed066b5bec893db2d261bfe3b9da054d99c018326f0bdcf1dc4c3a1eb9", size = 10448453, upload-time = "2026-02-21T10:24:26.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/eb/c66e2d3272e74dd590ae81bb51590bd98c3cd4e3f6629d4e4218bd6a5c28/maturin-1.12.4-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:939c4c57efa8ea982a991ee3ccb3992364622e9cbd1ede922b5cfb0f652bf517", size = 9970879, upload-time = "2026-02-21T10:24:12.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/a0/998f8063d67fa19639179af7e8ea46016ceaa12f85b9720a2e4846449f43/maturin-1.12.4-py3-none-win32.whl", hash = "sha256:d72f626616292cb3e283941f47835ffc608207ebd8f95f4c50523a6631ffcb2e", size = 8518146, upload-time = "2026-02-21T10:24:17.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/14/6ceea315db6e47093442ec70c2d01bb011d69f5243de5fc0e6a5fab97513/maturin-1.12.4-py3-none-win_amd64.whl", hash = "sha256:ab32c5ff7579a549421cae03e6297d3b03d7b81fa2934e3bdf24a102d99eb378", size = 9863686, upload-time = "2026-02-21T10:24:19.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/28/73e14739c6f7605ff9b9d108726d3ff529d4f91a7838739b4dd0afd33ec1/maturin-1.12.4-py3-none-win_arm64.whl", hash = "sha256:b8c05d24209af50ed9ae9e5de473c84866b9676c637fcfad123ee57f4a9ed098", size = 8557843, upload-time = "2026-02-21T10:24:23.894Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user