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 ADDED
@@ -0,0 +1,10 @@
1
+ """
2
+ Odecast: A DSL for ordinary differential equations
3
+ """
4
+
5
+ from .symbols import t
6
+ from .api import var, solve, BC
7
+ from .equation import Eq
8
+
9
+ __version__ = "0.1.0"
10
+ __all__ = ["t", "var", "Eq", "solve", "BC"]
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,5 @@
1
+ """
2
+ Backend implementations for solving ODEs
3
+ """
4
+
5
+ # This package contains different solver backends
@@ -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
+ )