odecast 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- odecast/__init__.py +10 -0
- odecast/analyze.py +132 -0
- odecast/api.py +253 -0
- odecast/backends/__init__.py +5 -0
- odecast/backends/scipy_bvp.py +46 -0
- odecast/backends/scipy_ivp.py +134 -0
- odecast/backends/sympy_backend.py +144 -0
- odecast/compile.py +145 -0
- odecast/equation.py +188 -0
- odecast/errors.py +45 -0
- odecast/reduce.py +359 -0
- odecast/solution.py +201 -0
- odecast/symbols.py +482 -0
- odecast/validate.py +191 -0
- odecast-0.1.0.dist-info/METADATA +288 -0
- odecast-0.1.0.dist-info/RECORD +17 -0
- odecast-0.1.0.dist-info/WHEEL +4 -0
odecast/__init__.py
ADDED
odecast/analyze.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Analysis functions for ODEs - order inference, validation, etc.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Set, List, Dict
|
|
6
|
+
import sympy as sp
|
|
7
|
+
from .symbols import Variable, Derivative, as_sympy
|
|
8
|
+
from .equation import Eq
|
|
9
|
+
from .errors import OrderMismatchError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def collect_variables(eqs: List[Eq]) -> Set[Variable]:
|
|
13
|
+
"""
|
|
14
|
+
Collect all variables used in the given equations.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
eqs: List of equations to analyze
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Set of Variable objects found in the equations
|
|
21
|
+
"""
|
|
22
|
+
variables = set()
|
|
23
|
+
|
|
24
|
+
for eq in eqs:
|
|
25
|
+
# Convert equation to SymPy and walk the expression tree
|
|
26
|
+
sympy_eq = eq.sympy()
|
|
27
|
+
|
|
28
|
+
# Get all functions in the equation (these correspond to our variables)
|
|
29
|
+
functions = sympy_eq.atoms(sp.Function)
|
|
30
|
+
|
|
31
|
+
# Map back to our Variable objects by walking the original expressions
|
|
32
|
+
_collect_variables_from_expr(eq.lhs, variables)
|
|
33
|
+
_collect_variables_from_expr(eq.rhs, variables)
|
|
34
|
+
|
|
35
|
+
return variables
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _collect_variables_from_expr(expr, variables: Set[Variable]):
|
|
39
|
+
"""Helper function to recursively collect variables from an expression."""
|
|
40
|
+
from .symbols import Expression # Avoid circular import
|
|
41
|
+
|
|
42
|
+
if isinstance(expr, Variable):
|
|
43
|
+
variables.add(expr)
|
|
44
|
+
elif isinstance(expr, Derivative):
|
|
45
|
+
variables.add(expr.variable)
|
|
46
|
+
elif isinstance(expr, Expression):
|
|
47
|
+
_collect_variables_from_expr(expr.left, variables)
|
|
48
|
+
_collect_variables_from_expr(expr.right, variables)
|
|
49
|
+
# For other types (numbers, SymPy expressions), do nothing
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def infer_orders(eqs: List[Eq]) -> Dict[Variable, int]:
|
|
53
|
+
"""
|
|
54
|
+
Infer the maximum derivative order for each variable from the equations.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
eqs: List of equations to analyze
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Dictionary mapping each variable to its maximum derivative order
|
|
61
|
+
"""
|
|
62
|
+
orders = {}
|
|
63
|
+
|
|
64
|
+
for eq in eqs:
|
|
65
|
+
# Analyze both sides of the equation
|
|
66
|
+
_infer_orders_from_expr(eq.lhs, orders)
|
|
67
|
+
_infer_orders_from_expr(eq.rhs, orders)
|
|
68
|
+
|
|
69
|
+
return orders
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _infer_orders_from_expr(expr, orders: Dict[Variable, int]):
|
|
73
|
+
"""Helper function to recursively infer orders from an expression."""
|
|
74
|
+
from .symbols import Expression # Avoid circular import
|
|
75
|
+
|
|
76
|
+
if isinstance(expr, Variable):
|
|
77
|
+
# Variable itself has order 0
|
|
78
|
+
current_order = orders.get(expr, 0)
|
|
79
|
+
orders[expr] = max(current_order, 0)
|
|
80
|
+
elif isinstance(expr, Derivative):
|
|
81
|
+
# Derivative has the specified order
|
|
82
|
+
current_order = orders.get(expr.variable, 0)
|
|
83
|
+
orders[expr.variable] = max(current_order, expr.order)
|
|
84
|
+
elif isinstance(expr, Expression):
|
|
85
|
+
_infer_orders_from_expr(expr.left, orders)
|
|
86
|
+
_infer_orders_from_expr(expr.right, orders)
|
|
87
|
+
# For other types (numbers, SymPy expressions), do nothing
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_orders(
|
|
91
|
+
variables: List[Variable], inferred: Dict[Variable, int]
|
|
92
|
+
) -> Dict[Variable, int]:
|
|
93
|
+
"""
|
|
94
|
+
Resolve final orders by checking intent_order against inferred orders.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
variables: List of variables to resolve orders for
|
|
98
|
+
inferred: Dictionary of inferred orders from equations
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Dictionary mapping each variable to its resolved order
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
OrderMismatchError: If intent_order is set but is less than inferred order
|
|
105
|
+
"""
|
|
106
|
+
resolved = {}
|
|
107
|
+
|
|
108
|
+
for var in variables:
|
|
109
|
+
inferred_order = inferred.get(var, 0)
|
|
110
|
+
|
|
111
|
+
if var.intent_order is not None:
|
|
112
|
+
if inferred_order > var.intent_order:
|
|
113
|
+
raise OrderMismatchError(
|
|
114
|
+
f"Variable '{var.name}' was declared with order {var.intent_order}, "
|
|
115
|
+
f"but equations use derivatives up to order {inferred_order}"
|
|
116
|
+
)
|
|
117
|
+
# Use the maximum of intent_order and inferred_order
|
|
118
|
+
resolved[var] = max(var.intent_order, inferred_order)
|
|
119
|
+
else:
|
|
120
|
+
# Use inferred order
|
|
121
|
+
resolved[var] = inferred_order
|
|
122
|
+
|
|
123
|
+
return resolved
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def analyze_equation(equation):
|
|
127
|
+
"""
|
|
128
|
+
Analyze an equation to determine properties like order, variables involved, etc.
|
|
129
|
+
|
|
130
|
+
This is a placeholder for future implementation.
|
|
131
|
+
"""
|
|
132
|
+
raise NotImplementedError("analyze_equation will be implemented in later playbooks")
|
odecast/api.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main APIs for odecast
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Any, Optional, Union, List
|
|
6
|
+
from .symbols import t, Variable
|
|
7
|
+
from .equation import Eq
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def var(
|
|
11
|
+
name: str, order: Optional[int] = None, shape: Optional[tuple] = None
|
|
12
|
+
) -> Variable:
|
|
13
|
+
"""
|
|
14
|
+
Create a dependent variable for use in differential equations.
|
|
15
|
+
|
|
16
|
+
The variable represents an unknown function y(t) where t is the independent variable.
|
|
17
|
+
You can create derivatives using the .d(n) method.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
name: Name of the variable (e.g., "y", "x", "theta")
|
|
21
|
+
order: Maximum order of derivatives expected (optional, for validation).
|
|
22
|
+
If specified and equations use higher-order derivatives, an
|
|
23
|
+
OrderMismatchError will be raised.
|
|
24
|
+
shape: Shape tuple for vector/matrix variables (e.g., (3,) for 3D vector,
|
|
25
|
+
(2, 2) for 2x2 matrix). None for scalar variables.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Variable object that supports:
|
|
29
|
+
- Arithmetic operations: y + z, 2*y, y/3, etc.
|
|
30
|
+
- Derivative notation: y.d() for y', y.d(2) for y'', etc.
|
|
31
|
+
- Use in equations: Eq(y.d(2) + y, 0)
|
|
32
|
+
- Component access for vectors: u[0], u[1] for vector components
|
|
33
|
+
- Vectorized operations: u.d() + 2*u for vector equations
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
>>> y = var("y") # Create scalar variable y(t)
|
|
37
|
+
>>> y.d() # First derivative y'(t)
|
|
38
|
+
>>> y.d(2) # Second derivative y''(t)
|
|
39
|
+
>>> Eq(y.d(2) + y, 0) # Simple harmonic oscillator
|
|
40
|
+
|
|
41
|
+
>>> u = var("u", shape=(2,)) # Create 2D vector variable u(t)
|
|
42
|
+
>>> u[0] # First component u[0](t)
|
|
43
|
+
>>> u.d() # Vector derivative u'(t)
|
|
44
|
+
>>> Eq(u.d() + u, 0) # Vector equation u' + u = 0
|
|
45
|
+
|
|
46
|
+
>>> z = var("z", order=1) # Declare z as first-order only
|
|
47
|
+
>>> Eq(z.d() - z, 0) # Valid: uses only first derivative
|
|
48
|
+
>>> Eq(z.d(2) + z, 0) # Would raise OrderMismatchError
|
|
49
|
+
"""
|
|
50
|
+
return Variable(name, order, shape)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def solve(equation, *, ivp=None, bvp=None, tspan=None, backend=None, **kwargs):
|
|
54
|
+
"""
|
|
55
|
+
Solve an ordinary differential equation or system of ODEs.
|
|
56
|
+
|
|
57
|
+
This is the main entry point for solving differential equations. It supports
|
|
58
|
+
both symbolic and numeric solutions through different backends.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
equation: Single Eq object or list of Eq objects representing the ODE system
|
|
62
|
+
ivp: Dictionary of initial conditions for initial value problems (IVP).
|
|
63
|
+
Keys can be Variable objects (for y(t0)) or Derivative objects (for y'(t0)).
|
|
64
|
+
Example: {y: 1.0, y.d(): 0.0} for y(0)=1, y'(0)=0
|
|
65
|
+
bvp: List of boundary conditions for boundary value problems (BVP).
|
|
66
|
+
Currently not implemented - will be available in future versions.
|
|
67
|
+
tspan: Tuple of (t_start, t_end) for numeric solutions.
|
|
68
|
+
Required for scipy backend, ignored for sympy backend.
|
|
69
|
+
backend: Solution backend to use:
|
|
70
|
+
- "scipy": Numeric integration using SciPy (default)
|
|
71
|
+
- "sympy": Symbolic solution using SymPy
|
|
72
|
+
- "auto": Try SymPy first, fall back to SciPy if needed
|
|
73
|
+
- "scipy_bvp": BVP solver (not yet implemented)
|
|
74
|
+
**kwargs: Additional backend-specific options:
|
|
75
|
+
For scipy: method, rtol, atol, max_step, etc.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Solution object that depends on the backend:
|
|
79
|
+
- SolutionIVP: For numeric solutions, supports sol[y], sol[y.d()], etc.
|
|
80
|
+
- SolutionExpr: For symbolic solutions, supports sol.as_expr(y)
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
MissingInitialConditionError: When required initial conditions are missing
|
|
84
|
+
OverdeterminedConditionsError: When too many initial conditions provided
|
|
85
|
+
OrderMismatchError: When declared variable order conflicts with equation usage
|
|
86
|
+
BackendError: When the chosen backend cannot solve the equation
|
|
87
|
+
ValueError: When required arguments are missing (e.g., tspan for scipy)
|
|
88
|
+
|
|
89
|
+
Examples:
|
|
90
|
+
Numeric solution of second-order ODE:
|
|
91
|
+
>>> y = var("y")
|
|
92
|
+
>>> eq = Eq(y.d(2) + 0.3*y.d() + y, 0)
|
|
93
|
+
>>> sol = solve(eq, ivp={y: 1.0, y.d(): 0.0}, tspan=(0, 10))
|
|
94
|
+
>>> y_values = sol[y] # Get y(t) values
|
|
95
|
+
>>> yprime_values = sol[y.d()] # Get y'(t) values
|
|
96
|
+
|
|
97
|
+
Symbolic solution:
|
|
98
|
+
>>> eq = Eq(y.d(2) + y, 0)
|
|
99
|
+
>>> sol = solve(eq, backend="sympy")
|
|
100
|
+
>>> expr = sol.as_expr(y) # Get SymPy expression
|
|
101
|
+
|
|
102
|
+
Coupled system:
|
|
103
|
+
>>> y, z = var("y"), var("z")
|
|
104
|
+
>>> eq1 = Eq(y.d(2) + z, 0)
|
|
105
|
+
>>> eq2 = Eq(z.d() - y, 0)
|
|
106
|
+
>>> sol = solve([eq1, eq2], ivp={y: 1.0, y.d(): 0.0, z: 0.0}, tspan=(0, 5))
|
|
107
|
+
"""
|
|
108
|
+
from .analyze import collect_variables, infer_orders, resolve_orders
|
|
109
|
+
from .validate import validate_ivp
|
|
110
|
+
from .reduce import build_state_map, isolate_highest_derivatives, make_rhs
|
|
111
|
+
from .compile import lambdify_rhs, lambdify_jac
|
|
112
|
+
from .backends.scipy_ivp import ScipyIVPBackend, convert_ivp_to_state_vector
|
|
113
|
+
from .equation import expand_vector_equations
|
|
114
|
+
import sympy as sp
|
|
115
|
+
|
|
116
|
+
# Normalize equations to list
|
|
117
|
+
eqs = [equation] if isinstance(equation, Eq) else list(equation)
|
|
118
|
+
|
|
119
|
+
# Expand vector equations to component equations
|
|
120
|
+
eqs = expand_vector_equations(eqs)
|
|
121
|
+
|
|
122
|
+
# Step 1: Analyze - infer orders and resolve
|
|
123
|
+
variables = collect_variables(eqs)
|
|
124
|
+
inferred_orders = infer_orders(eqs)
|
|
125
|
+
orders = resolve_orders(list(variables), inferred_orders)
|
|
126
|
+
|
|
127
|
+
# Route to appropriate backend
|
|
128
|
+
if backend is None:
|
|
129
|
+
backend = "scipy" # Default to scipy for IVP
|
|
130
|
+
|
|
131
|
+
if backend == "auto":
|
|
132
|
+
# Try SymPy first, fall back to SciPy on failure
|
|
133
|
+
try:
|
|
134
|
+
from .backends.sympy_backend import SymPyBackend
|
|
135
|
+
|
|
136
|
+
backend_instance = SymPyBackend()
|
|
137
|
+
solution = backend_instance.solve(
|
|
138
|
+
equations=eqs, t_symbol=t.symbol, **kwargs
|
|
139
|
+
)
|
|
140
|
+
return solution
|
|
141
|
+
except Exception:
|
|
142
|
+
# Fall back to SciPy backend
|
|
143
|
+
backend = "scipy"
|
|
144
|
+
|
|
145
|
+
if backend == "scipy":
|
|
146
|
+
if ivp is None:
|
|
147
|
+
raise ValueError("IVP conditions required for scipy backend")
|
|
148
|
+
if tspan is None:
|
|
149
|
+
raise ValueError("tspan required for scipy backend")
|
|
150
|
+
|
|
151
|
+
# Step 2: Validate IVP
|
|
152
|
+
t0 = tspan[0]
|
|
153
|
+
validate_ivp(orders, ivp, t0)
|
|
154
|
+
|
|
155
|
+
# Step 3: Reduce to first-order system
|
|
156
|
+
mapping = build_state_map(orders)
|
|
157
|
+
highest_rules = isolate_highest_derivatives(eqs, orders)
|
|
158
|
+
f_sym_vec, jac_sym = make_rhs(t.symbol, mapping, highest_rules)
|
|
159
|
+
|
|
160
|
+
# Step 4: Convert IVP to state vector
|
|
161
|
+
x0 = convert_ivp_to_state_vector(ivp, mapping)
|
|
162
|
+
|
|
163
|
+
# Step 5: Compile to numerical functions
|
|
164
|
+
# Determine state symbols
|
|
165
|
+
n_states = len(x0)
|
|
166
|
+
state_syms = [sp.Symbol(f"x{i}") for i in range(n_states)]
|
|
167
|
+
|
|
168
|
+
f_compiled = lambdify_rhs(f_sym_vec, t.symbol, state_syms)
|
|
169
|
+
jac_compiled = (
|
|
170
|
+
lambdify_jac(jac_sym, t.symbol, state_syms) if jac_sym is not None else None
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Step 6: Solve using SciPy backend
|
|
174
|
+
backend_instance = ScipyIVPBackend()
|
|
175
|
+
solution = backend_instance.solve(
|
|
176
|
+
f_compiled=f_compiled,
|
|
177
|
+
jac_compiled=jac_compiled,
|
|
178
|
+
x0=x0,
|
|
179
|
+
t0=t0,
|
|
180
|
+
tspan=tspan,
|
|
181
|
+
mapping=mapping,
|
|
182
|
+
options=kwargs,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return solution
|
|
186
|
+
|
|
187
|
+
elif backend == "sympy":
|
|
188
|
+
# SymPy backend for symbolic solutions
|
|
189
|
+
from .backends.sympy_backend import SymPyBackend
|
|
190
|
+
|
|
191
|
+
# Create SymPy backend instance
|
|
192
|
+
backend_instance = SymPyBackend()
|
|
193
|
+
solution = backend_instance.solve(equations=eqs, t_symbol=t.symbol, **kwargs)
|
|
194
|
+
|
|
195
|
+
return solution
|
|
196
|
+
|
|
197
|
+
elif backend == "scipy_bvp":
|
|
198
|
+
# Import BVP backend
|
|
199
|
+
from .backends.scipy_bvp import ScipyBVPBackend
|
|
200
|
+
|
|
201
|
+
# Create BVP backend instance
|
|
202
|
+
backend_instance = ScipyBVPBackend()
|
|
203
|
+
|
|
204
|
+
# For now, just raise NotImplementedError as the actual implementation
|
|
205
|
+
# will come in Milestone 5
|
|
206
|
+
raise NotImplementedError("BVP backend will be implemented in Milestone 5")
|
|
207
|
+
|
|
208
|
+
else:
|
|
209
|
+
raise ValueError(f"Unknown backend: {backend}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class BC:
|
|
213
|
+
"""
|
|
214
|
+
Boundary condition for boundary value problems (BVP).
|
|
215
|
+
|
|
216
|
+
Boundary conditions specify the value of a variable or its derivative
|
|
217
|
+
at specific points in the domain. This is used for BVP problems where
|
|
218
|
+
conditions are given at multiple points rather than all at the initial point.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
variable: The variable or derivative to constrain (Variable or Derivative object)
|
|
222
|
+
t: The time/position where the condition applies
|
|
223
|
+
value: The required value at that position
|
|
224
|
+
|
|
225
|
+
Examples:
|
|
226
|
+
>>> y = var("y")
|
|
227
|
+
>>> bc1 = BC(y, t=0, value=0) # y(0) = 0
|
|
228
|
+
>>> bc2 = BC(y, t=1, value=1) # y(1) = 1
|
|
229
|
+
>>> bc3 = BC(y.d(), t=0, value=0) # y'(0) = 0
|
|
230
|
+
|
|
231
|
+
BVP problem:
|
|
232
|
+
>>> eq = Eq(y.d(2) + y, 0)
|
|
233
|
+
>>> sol = solve(eq, bvp=[bc1, bc2], tspan=(0, 1), backend="scipy_bvp")
|
|
234
|
+
|
|
235
|
+
Note:
|
|
236
|
+
BVP solving is not yet implemented and will be available in future versions.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
def __init__(self, variable, *, t=None, value=None):
|
|
240
|
+
"""
|
|
241
|
+
Create a boundary condition.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
variable: The variable or derivative to constrain
|
|
245
|
+
t: The time/position where condition applies
|
|
246
|
+
value: The value at that position
|
|
247
|
+
"""
|
|
248
|
+
self.variable = variable
|
|
249
|
+
self.t = t
|
|
250
|
+
self.value = value
|
|
251
|
+
|
|
252
|
+
def __repr__(self):
|
|
253
|
+
return f"BC({self.variable}, t={self.t}, value={self.value})"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SciPy-based backend for solving boundary value problems
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, List, Dict
|
|
6
|
+
from ..api import BC
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScipyBVPBackend:
|
|
10
|
+
"""
|
|
11
|
+
Backend for solving boundary value problems using SciPy's solve_bvp.
|
|
12
|
+
|
|
13
|
+
This backend will handle two-point boundary value problems where
|
|
14
|
+
conditions are specified at multiple points in the domain.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def solve(
|
|
18
|
+
self, system: Any, bvp: List[BC], tspan: tuple, options: Dict = None
|
|
19
|
+
) -> Any:
|
|
20
|
+
"""
|
|
21
|
+
Solve a boundary value problem.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
system: First-order system representation
|
|
25
|
+
bvp: List of boundary conditions
|
|
26
|
+
tspan: Integration domain (start, end)
|
|
27
|
+
options: Additional solver options
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Solution object for BVP
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
NotImplementedError: BVP implementation arrives in Milestone 5
|
|
34
|
+
"""
|
|
35
|
+
raise NotImplementedError("BVP in Milestone 5")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def solve_bvp_scipy(equation, boundary_conditions, tspan, **kwargs):
|
|
39
|
+
"""
|
|
40
|
+
Solve a BVP using SciPy's solve_bvp.
|
|
41
|
+
|
|
42
|
+
This is a placeholder for future implementation.
|
|
43
|
+
"""
|
|
44
|
+
raise NotImplementedError(
|
|
45
|
+
"SciPy BVP backend will be implemented in later playbooks"
|
|
46
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SciPy-based backend for solving initial value problems
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Tuple, Callable, Optional, Any, Union
|
|
6
|
+
import numpy as np
|
|
7
|
+
from scipy.integrate import solve_ivp
|
|
8
|
+
|
|
9
|
+
from ..symbols import Variable, Derivative
|
|
10
|
+
from ..solution import SolutionIVP
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScipyIVPBackend:
|
|
14
|
+
"""
|
|
15
|
+
Backend for solving IVPs using SciPy's solve_ivp.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def solve(
|
|
19
|
+
self,
|
|
20
|
+
f_compiled: Callable,
|
|
21
|
+
jac_compiled: Optional[Callable],
|
|
22
|
+
x0: np.ndarray,
|
|
23
|
+
t0: float,
|
|
24
|
+
tspan: Tuple[float, float],
|
|
25
|
+
mapping: Dict[Union[Variable, Tuple[Variable, int]], Union[list, int]],
|
|
26
|
+
options: Optional[Dict[str, Any]] = None,
|
|
27
|
+
) -> SolutionIVP:
|
|
28
|
+
"""
|
|
29
|
+
Solve the IVP using SciPy's solve_ivp.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
f_compiled: Compiled RHS function f(t, x) -> dx/dt
|
|
33
|
+
jac_compiled: Compiled Jacobian function (optional)
|
|
34
|
+
x0: Initial state vector
|
|
35
|
+
t0: Initial time
|
|
36
|
+
tspan: Tuple of (t_start, t_end)
|
|
37
|
+
mapping: State mapping from build_state_map
|
|
38
|
+
options: Additional solver options
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
SolutionIVP object with results
|
|
42
|
+
"""
|
|
43
|
+
options = options or {}
|
|
44
|
+
|
|
45
|
+
# Extract solver options
|
|
46
|
+
method = options.get("method", "RK45")
|
|
47
|
+
rtol = options.get("rtol", 1e-3)
|
|
48
|
+
atol = options.get("atol", 1e-6)
|
|
49
|
+
events = options.get("events", None)
|
|
50
|
+
dense_output = options.get("dense_output", False)
|
|
51
|
+
max_step = options.get("max_step", np.inf)
|
|
52
|
+
|
|
53
|
+
# Solve using scipy.integrate.solve_ivp
|
|
54
|
+
sol = solve_ivp(
|
|
55
|
+
fun=f_compiled,
|
|
56
|
+
t_span=tspan,
|
|
57
|
+
y0=x0,
|
|
58
|
+
method=method,
|
|
59
|
+
rtol=rtol,
|
|
60
|
+
atol=atol,
|
|
61
|
+
events=events,
|
|
62
|
+
dense_output=dense_output,
|
|
63
|
+
max_step=max_step,
|
|
64
|
+
jac=jac_compiled,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if not sol.success:
|
|
68
|
+
raise RuntimeError(f"SciPy solve_ivp failed: {sol.message}")
|
|
69
|
+
|
|
70
|
+
# Create and return SolutionIVP object
|
|
71
|
+
return SolutionIVP(
|
|
72
|
+
t=sol.t,
|
|
73
|
+
y=sol.y,
|
|
74
|
+
mapping=mapping,
|
|
75
|
+
t0=t0,
|
|
76
|
+
f_compiled=f_compiled,
|
|
77
|
+
jac_compiled=jac_compiled,
|
|
78
|
+
x0=x0,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def convert_ivp_to_state_vector(
|
|
83
|
+
ivp_dict: Dict[Union[Variable, Derivative], float],
|
|
84
|
+
mapping: Dict[Union[Variable, Tuple[Variable, int]], Union[list, int]],
|
|
85
|
+
) -> np.ndarray:
|
|
86
|
+
"""
|
|
87
|
+
Convert IVP dictionary to initial state vector.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
ivp_dict: Dictionary mapping variables/derivatives to initial values
|
|
91
|
+
mapping: State mapping from build_state_map
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Initial state vector x0
|
|
95
|
+
"""
|
|
96
|
+
# Import here to avoid circular import
|
|
97
|
+
from ..validate import normalize_ivp
|
|
98
|
+
|
|
99
|
+
# Normalize the IVP dictionary to handle vector variables
|
|
100
|
+
normalized_ivp = normalize_ivp(ivp_dict)
|
|
101
|
+
|
|
102
|
+
# Determine the size of the state vector
|
|
103
|
+
max_index = 0
|
|
104
|
+
for key, value in mapping.items():
|
|
105
|
+
if isinstance(key, tuple): # (var, level) keys
|
|
106
|
+
max_index = max(max_index, value)
|
|
107
|
+
elif isinstance(key, Variable): # var keys with lists
|
|
108
|
+
if isinstance(value, list) and value:
|
|
109
|
+
max_index = max(max_index, max(value))
|
|
110
|
+
|
|
111
|
+
n_states = max_index + 1
|
|
112
|
+
x0 = np.zeros(n_states)
|
|
113
|
+
|
|
114
|
+
# Fill in the initial conditions using normalized format
|
|
115
|
+
for (var, level), value in normalized_ivp.items():
|
|
116
|
+
if (var, level) in mapping:
|
|
117
|
+
state_index = mapping[(var, level)]
|
|
118
|
+
x0[state_index] = value
|
|
119
|
+
# else: silently ignore conditions that aren't needed
|
|
120
|
+
# This happens when vector derivatives are provided but some
|
|
121
|
+
# components don't need that level of derivative
|
|
122
|
+
|
|
123
|
+
return x0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def solve_ivp_scipy(equation, initial_conditions, tspan, **kwargs):
|
|
127
|
+
"""
|
|
128
|
+
Solve an IVP using SciPy's solve_ivp.
|
|
129
|
+
|
|
130
|
+
This is a placeholder that will be removed once the ScipyIVPBackend is integrated.
|
|
131
|
+
"""
|
|
132
|
+
raise NotImplementedError(
|
|
133
|
+
"SciPy IVP backend will be implemented in later playbooks"
|
|
134
|
+
)
|