eqsys 0.1.0__tar.gz
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.
- eqsys-0.1.0/.gitignore +7 -0
- eqsys-0.1.0/PKG-INFO +10 -0
- eqsys-0.1.0/README.md +139 -0
- eqsys-0.1.0/eqsys/__init__.py +7 -0
- eqsys-0.1.0/eqsys/diagnostics.py +57 -0
- eqsys-0.1.0/eqsys/jacobian.py +37 -0
- eqsys-0.1.0/eqsys/solver.py +71 -0
- eqsys-0.1.0/eqsys/status.py +13 -0
- eqsys-0.1.0/eqsys/system.py +104 -0
- eqsys-0.1.0/eqsys/tracker.py +16 -0
- eqsys-0.1.0/eqsys/validator.py +96 -0
- eqsys-0.1.0/eqsys/var.py +33 -0
- eqsys-0.1.0/pyproject.toml +19 -0
- eqsys-0.1.0/tests/__init__.py +0 -0
- eqsys-0.1.0/tests/test_benchmarks.py +60 -0
- eqsys-0.1.0/tests/test_diagnostics.py +82 -0
- eqsys-0.1.0/tests/test_jacobian.py +88 -0
- eqsys-0.1.0/tests/test_solver.py +188 -0
- eqsys-0.1.0/tests/test_status.py +13 -0
- eqsys-0.1.0/tests/test_system.py +229 -0
- eqsys-0.1.0/tests/test_tracker.py +46 -0
- eqsys-0.1.0/tests/test_validator.py +189 -0
- eqsys-0.1.0/tests/test_var.py +48 -0
eqsys-0.1.0/.gitignore
ADDED
eqsys-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eqsys
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Nonlinear equation system solver with automatic dependency tracking
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: numpy
|
|
7
|
+
Requires-Dist: scipy
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest-benchmark; extra == 'dev'
|
eqsys-0.1.0/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# eqsys
|
|
2
|
+
|
|
3
|
+
Nonlinear equation system solver with automatic dependency tracking.
|
|
4
|
+
|
|
5
|
+
Write only residual functions in plain Python — the system handles variable tracking, sparse Jacobian computation, and Newton-Raphson iteration automatically.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install eqsys
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Example
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import math
|
|
17
|
+
from eqsys import EquationSystem
|
|
18
|
+
|
|
19
|
+
sys = EquationSystem("pipe_flow")
|
|
20
|
+
|
|
21
|
+
# Variables — name and initial guess are required
|
|
22
|
+
P1 = sys.var("P1", guess=101325.0)
|
|
23
|
+
P2 = sys.var("P2", guess=100000.0)
|
|
24
|
+
flow = sys.var("flow", guess=1.0, min_value=0.0)
|
|
25
|
+
|
|
26
|
+
# Constants
|
|
27
|
+
D, L, rho, mu = 0.1, 5.0, 998.0, 1e-3
|
|
28
|
+
|
|
29
|
+
# Equations — plain Python functions returning residuals
|
|
30
|
+
def inlet_pressure():
|
|
31
|
+
return P1() - 101325.0
|
|
32
|
+
|
|
33
|
+
def pipe_pressure_drop():
|
|
34
|
+
area = math.pi * D ** 2 / 4
|
|
35
|
+
v = flow() / (rho * area)
|
|
36
|
+
Re = rho * v * D / mu
|
|
37
|
+
f = 64 / Re if Re < 2300 else 0.02
|
|
38
|
+
dp = f * L / D * rho * v ** 2 / 2
|
|
39
|
+
return P2() - (P1() - dp)
|
|
40
|
+
|
|
41
|
+
def outlet_pressure():
|
|
42
|
+
return P2() - 100000.0
|
|
43
|
+
|
|
44
|
+
sys.add_equation("inlet_pressure", inlet_pressure)
|
|
45
|
+
sys.add_equation("pipe_pressure_drop", pipe_pressure_drop)
|
|
46
|
+
sys.add_equation("outlet_pressure", outlet_pressure)
|
|
47
|
+
|
|
48
|
+
# Validate and solve
|
|
49
|
+
sys.validate()
|
|
50
|
+
status = sys.solve()
|
|
51
|
+
|
|
52
|
+
print(f"P1 = {P1()}") # 101325.0
|
|
53
|
+
print(f"P2 = {P2()}") # 100000.0
|
|
54
|
+
print(f"flow = {flow()}") # ~12.77
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Key Features
|
|
58
|
+
|
|
59
|
+
- **Minimal API** — write residual functions, the system does the rest
|
|
60
|
+
- **Automatic dependency tracking** — no need to declare which variables each equation uses
|
|
61
|
+
- **Sparse Jacobian** — only computes non-zero partial derivatives
|
|
62
|
+
- **Per-variable solver parameters** — bounds, max step, derivative step
|
|
63
|
+
- **Strict validation** — catches dimension mismatches, unused variables, equation errors
|
|
64
|
+
- **Convergence diagnostics** — residual history, worst equations, variable trajectories
|
|
65
|
+
- **Real-time monitoring** — `on_iteration` callback with stop control
|
|
66
|
+
- **Multiple systems** — variables from one system can be used as constants in another
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
### Variables
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
x = sys.var("x",
|
|
74
|
+
guess=1.0, # required — initial value
|
|
75
|
+
min_value=-1e15, # lower bound (default)
|
|
76
|
+
max_value=1e15, # upper bound (default)
|
|
77
|
+
max_step=1e10, # max change per iteration (default)
|
|
78
|
+
deriv_step=1e-8, # finite difference step (default)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
x() # returns current float value (guess before solve, solution after)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Equations
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
sys.add_equation("name", func) # func() -> float (residual)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Any Python code is allowed inside equations: `if/else`, `math.*`, loops, function calls.
|
|
91
|
+
|
|
92
|
+
### Validation
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
status = sys.validate() # ValidationStatus.VALID / INVALID / WARNING
|
|
96
|
+
details = sys.validation_details() # list of ValidationIssue
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Checks: dimension match, duplicate names, equation errors, unused variables, guess bounds.
|
|
100
|
+
|
|
101
|
+
### Solving
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
status = sys.solve(tol=1e-8, max_iter=100) # SolveStatus.CONVERGED / NOT_CONVERGED / ERROR
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
No exceptions — check the returned status.
|
|
108
|
+
|
|
109
|
+
### Diagnostics
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
diag = sys.diagnostics()
|
|
113
|
+
diag.iterations # number of iterations
|
|
114
|
+
diag.converged # bool
|
|
115
|
+
diag.residual_history # [norm_0, norm_1, ...]
|
|
116
|
+
diag.worst_equations() # [("eq_name", residual), ...] sorted by |residual|
|
|
117
|
+
diag.variable_history("x") # [x_0, x_1, ...]
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Real-Time Monitoring
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
def monitor(info, control):
|
|
124
|
+
print(f"Iter {info.iteration}: norm={info.norm:.2e}")
|
|
125
|
+
if info.iteration > 50:
|
|
126
|
+
control.stop = True # abort solve
|
|
127
|
+
|
|
128
|
+
sys.solve(on_iteration=monitor)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Requirements
|
|
132
|
+
|
|
133
|
+
- Python 3.11+
|
|
134
|
+
- numpy
|
|
135
|
+
- scipy
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class IterationInfo:
|
|
7
|
+
iteration: int
|
|
8
|
+
norm: float
|
|
9
|
+
values: dict[str, float]
|
|
10
|
+
residuals: dict[str, float]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SolverControl:
|
|
15
|
+
stop: bool = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DiagnosticsData:
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.reset()
|
|
21
|
+
|
|
22
|
+
def reset(self):
|
|
23
|
+
self.converged = False
|
|
24
|
+
self.residual_history: list[float] = []
|
|
25
|
+
self._residuals_per_eq: list[dict[str, float]] = []
|
|
26
|
+
self._values_per_iter: list[dict[str, float]] = []
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def iterations(self) -> int:
|
|
30
|
+
return len(self.residual_history)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def final_norm(self) -> float:
|
|
34
|
+
if not self.residual_history:
|
|
35
|
+
return 0.0
|
|
36
|
+
return self.residual_history[-1]
|
|
37
|
+
|
|
38
|
+
def record_iteration(
|
|
39
|
+
self,
|
|
40
|
+
norm: float,
|
|
41
|
+
residuals: dict[str, float],
|
|
42
|
+
values: dict[str, float],
|
|
43
|
+
):
|
|
44
|
+
self.residual_history.append(norm)
|
|
45
|
+
self._residuals_per_eq.append(dict(residuals))
|
|
46
|
+
self._values_per_iter.append(dict(values))
|
|
47
|
+
|
|
48
|
+
def worst_equations(self) -> list[tuple[str, float]]:
|
|
49
|
+
if not self._residuals_per_eq:
|
|
50
|
+
return []
|
|
51
|
+
last = self._residuals_per_eq[-1]
|
|
52
|
+
return sorted(last.items(), key=lambda x: abs(x[1]), reverse=True)
|
|
53
|
+
|
|
54
|
+
def variable_history(self, var_name: str) -> list[float]:
|
|
55
|
+
if self._values_per_iter and var_name not in self._values_per_iter[0]:
|
|
56
|
+
raise KeyError(f'Variable "{var_name}" not found in diagnostics history')
|
|
57
|
+
return [v[var_name] for v in self._values_per_iter]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from scipy.sparse import coo_matrix
|
|
5
|
+
from eqsys.var import Var
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compute_jacobian(
|
|
9
|
+
equations: list[tuple[str, callable]],
|
|
10
|
+
variables: list[Var],
|
|
11
|
+
deps: dict[str, set[str]],
|
|
12
|
+
residuals: np.ndarray,
|
|
13
|
+
) -> coo_matrix:
|
|
14
|
+
var_index = {v.name: j for j, v in enumerate(variables)}
|
|
15
|
+
rows = []
|
|
16
|
+
cols = []
|
|
17
|
+
data = []
|
|
18
|
+
|
|
19
|
+
for i, (eq_name, eq_func) in enumerate(equations):
|
|
20
|
+
eq_deps = deps.get(eq_name, set())
|
|
21
|
+
for var_name in eq_deps:
|
|
22
|
+
j = var_index[var_name]
|
|
23
|
+
var = variables[j]
|
|
24
|
+
|
|
25
|
+
original = var.value
|
|
26
|
+
var.value = original + var.deriv_step
|
|
27
|
+
f_perturbed = eq_func()
|
|
28
|
+
var.value = original
|
|
29
|
+
|
|
30
|
+
dfdx = (f_perturbed - residuals[i]) / var.deriv_step
|
|
31
|
+
rows.append(i)
|
|
32
|
+
cols.append(j)
|
|
33
|
+
data.append(dfdx)
|
|
34
|
+
|
|
35
|
+
n_eq = len(equations)
|
|
36
|
+
n_var = len(variables)
|
|
37
|
+
return coo_matrix((data, (rows, cols)), shape=(n_eq, n_var)).tocsc()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.sparse.linalg import spsolve
|
|
7
|
+
|
|
8
|
+
from eqsys.diagnostics import DiagnosticsData, IterationInfo, SolverControl
|
|
9
|
+
from eqsys.jacobian import compute_jacobian
|
|
10
|
+
from eqsys.status import SolveStatus
|
|
11
|
+
from eqsys.var import Var
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def newton_raphson(
|
|
15
|
+
eval_fn: Callable,
|
|
16
|
+
equations: list[tuple[str, Callable]],
|
|
17
|
+
variables: list[Var],
|
|
18
|
+
diagnostics: DiagnosticsData,
|
|
19
|
+
tol: float = 1e-8,
|
|
20
|
+
max_iter: int = 100,
|
|
21
|
+
on_iteration: Callable[[IterationInfo, SolverControl], None] | None = None,
|
|
22
|
+
) -> SolveStatus:
|
|
23
|
+
diagnostics.reset()
|
|
24
|
+
|
|
25
|
+
for iteration in range(max_iter):
|
|
26
|
+
try:
|
|
27
|
+
F, deps = eval_fn()
|
|
28
|
+
except Exception:
|
|
29
|
+
return SolveStatus.ERROR
|
|
30
|
+
|
|
31
|
+
norm = float(np.linalg.norm(F))
|
|
32
|
+
converged = norm < tol
|
|
33
|
+
|
|
34
|
+
residuals_dict = {
|
|
35
|
+
eq_name: float(F[i]) for i, (eq_name, _) in enumerate(equations)
|
|
36
|
+
}
|
|
37
|
+
values_dict = {v.name: v.value for v in variables}
|
|
38
|
+
diagnostics.record_iteration(
|
|
39
|
+
norm=norm, residuals=residuals_dict, values=values_dict
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if on_iteration is not None:
|
|
43
|
+
info = IterationInfo(
|
|
44
|
+
iteration=iteration,
|
|
45
|
+
norm=norm,
|
|
46
|
+
values=dict(values_dict),
|
|
47
|
+
residuals=dict(residuals_dict),
|
|
48
|
+
)
|
|
49
|
+
control = SolverControl()
|
|
50
|
+
on_iteration(info, control)
|
|
51
|
+
if control.stop:
|
|
52
|
+
return SolveStatus.NOT_CONVERGED
|
|
53
|
+
|
|
54
|
+
if converged:
|
|
55
|
+
diagnostics.converged = True
|
|
56
|
+
return SolveStatus.CONVERGED
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
J = compute_jacobian(equations, variables, deps, F)
|
|
60
|
+
dx = spsolve(J, -F)
|
|
61
|
+
except Exception:
|
|
62
|
+
return SolveStatus.ERROR
|
|
63
|
+
|
|
64
|
+
for k, var in enumerate(variables):
|
|
65
|
+
step = float(dx[k])
|
|
66
|
+
step = max(-var.max_step, min(var.max_step, step))
|
|
67
|
+
new_val = var.value + step
|
|
68
|
+
new_val = max(var.min_value, min(var.max_value, new_val))
|
|
69
|
+
var.value = new_val
|
|
70
|
+
|
|
71
|
+
return SolveStatus.NOT_CONVERGED
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from eqsys.diagnostics import DiagnosticsData, IterationInfo, SolverControl
|
|
7
|
+
from eqsys.solver import newton_raphson
|
|
8
|
+
from eqsys.status import SolveStatus, ValidationStatus
|
|
9
|
+
from eqsys.tracker import DependencyTracker
|
|
10
|
+
from eqsys.validator import ValidationIssue, validate_system
|
|
11
|
+
from eqsys.var import Var
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EquationSystem:
|
|
17
|
+
def __init__(self, name: str):
|
|
18
|
+
self.name = name
|
|
19
|
+
self._variables: list[Var] = []
|
|
20
|
+
self._equations: list[tuple[str, Callable]] = []
|
|
21
|
+
self._tracker = DependencyTracker()
|
|
22
|
+
self._diagnostics = DiagnosticsData()
|
|
23
|
+
self._validation_issues: list[ValidationIssue] = []
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return (
|
|
27
|
+
f'EquationSystem("{self.name}", '
|
|
28
|
+
f"vars={len(self._variables)}, eqs={len(self._equations)})"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def var(
|
|
32
|
+
self,
|
|
33
|
+
name: str,
|
|
34
|
+
guess: float,
|
|
35
|
+
min_value: float = -1e15,
|
|
36
|
+
max_value: float = 1e15,
|
|
37
|
+
max_step: float = 1e10,
|
|
38
|
+
deriv_step: float = 1e-8,
|
|
39
|
+
) -> Var:
|
|
40
|
+
v = Var(
|
|
41
|
+
name=name,
|
|
42
|
+
guess=guess,
|
|
43
|
+
system=self,
|
|
44
|
+
min_value=min_value,
|
|
45
|
+
max_value=max_value,
|
|
46
|
+
max_step=max_step,
|
|
47
|
+
deriv_step=deriv_step,
|
|
48
|
+
)
|
|
49
|
+
self._variables.append(v)
|
|
50
|
+
return v
|
|
51
|
+
|
|
52
|
+
def add_equation(self, name: str, func: Callable) -> None:
|
|
53
|
+
self._equations.append((name, func))
|
|
54
|
+
|
|
55
|
+
def _run_equation(self, eq_func: Callable) -> tuple[object, set[str]]:
|
|
56
|
+
self._tracker.start()
|
|
57
|
+
try:
|
|
58
|
+
result = eq_func()
|
|
59
|
+
except Exception:
|
|
60
|
+
self._tracker.stop()
|
|
61
|
+
raise
|
|
62
|
+
deps = self._tracker.stop()
|
|
63
|
+
return result, deps
|
|
64
|
+
|
|
65
|
+
def validate(self) -> ValidationStatus:
|
|
66
|
+
status, self._validation_issues = validate_system(
|
|
67
|
+
self._variables, self._equations, self._run_equation
|
|
68
|
+
)
|
|
69
|
+
return status
|
|
70
|
+
|
|
71
|
+
def validation_details(self) -> list[ValidationIssue]:
|
|
72
|
+
return list(self._validation_issues)
|
|
73
|
+
|
|
74
|
+
def solve(
|
|
75
|
+
self,
|
|
76
|
+
tol: float = 1e-8,
|
|
77
|
+
max_iter: int = 100,
|
|
78
|
+
on_iteration: Callable[[IterationInfo, SolverControl], None] | None = None,
|
|
79
|
+
) -> SolveStatus:
|
|
80
|
+
def eval_fn():
|
|
81
|
+
residuals = []
|
|
82
|
+
deps = {}
|
|
83
|
+
for eq_name, eq_func in self._equations:
|
|
84
|
+
self._tracker.start()
|
|
85
|
+
try:
|
|
86
|
+
r = eq_func()
|
|
87
|
+
finally:
|
|
88
|
+
eq_deps = self._tracker.stop()
|
|
89
|
+
residuals.append(r)
|
|
90
|
+
deps[eq_name] = eq_deps
|
|
91
|
+
return np.array(residuals, dtype=float), deps
|
|
92
|
+
|
|
93
|
+
return newton_raphson(
|
|
94
|
+
eval_fn=eval_fn,
|
|
95
|
+
equations=self._equations,
|
|
96
|
+
variables=self._variables,
|
|
97
|
+
diagnostics=self._diagnostics,
|
|
98
|
+
tol=tol,
|
|
99
|
+
max_iter=max_iter,
|
|
100
|
+
on_iteration=on_iteration,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def diagnostics(self) -> DiagnosticsData:
|
|
104
|
+
return copy.deepcopy(self._diagnostics)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class DependencyTracker:
|
|
2
|
+
def __init__(self):
|
|
3
|
+
self.active = False
|
|
4
|
+
self._deps: set[str] = set()
|
|
5
|
+
|
|
6
|
+
def start(self):
|
|
7
|
+
self._deps = set()
|
|
8
|
+
self.active = True
|
|
9
|
+
|
|
10
|
+
def stop(self) -> set[str]:
|
|
11
|
+
self.active = False
|
|
12
|
+
return self._deps
|
|
13
|
+
|
|
14
|
+
def register(self, var_name: str):
|
|
15
|
+
if self.active:
|
|
16
|
+
self._deps.add(var_name)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from eqsys.status import ValidationStatus
|
|
8
|
+
from eqsys.var import Var
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ValidationIssue:
|
|
13
|
+
level: str # "error" or "warning"
|
|
14
|
+
message: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_system(
|
|
18
|
+
variables: list[Var],
|
|
19
|
+
equations: list[tuple[str, Callable]],
|
|
20
|
+
run_equation: Callable[[Callable], tuple[object, set[str]]],
|
|
21
|
+
) -> tuple[ValidationStatus, list[ValidationIssue]]:
|
|
22
|
+
issues: list[ValidationIssue] = []
|
|
23
|
+
|
|
24
|
+
# Empty system is valid
|
|
25
|
+
if not variables and not equations:
|
|
26
|
+
return ValidationStatus.VALID, issues
|
|
27
|
+
|
|
28
|
+
# Duplicate variable names
|
|
29
|
+
var_names = [v.name for v in variables]
|
|
30
|
+
var_counts = Counter(var_names)
|
|
31
|
+
for name, count in var_counts.items():
|
|
32
|
+
if count > 1:
|
|
33
|
+
issues.append(ValidationIssue(
|
|
34
|
+
"error", f'Duplicate variable name "{name}" ({count} times)'))
|
|
35
|
+
|
|
36
|
+
# Duplicate equation names
|
|
37
|
+
eq_names = [name for name, _ in equations]
|
|
38
|
+
eq_counts = Counter(eq_names)
|
|
39
|
+
for name, count in eq_counts.items():
|
|
40
|
+
if count > 1:
|
|
41
|
+
issues.append(ValidationIssue(
|
|
42
|
+
"error", f'Duplicate equation name "{name}" ({count} times)'))
|
|
43
|
+
|
|
44
|
+
# Dimension check
|
|
45
|
+
if len(equations) != len(variables):
|
|
46
|
+
issues.append(ValidationIssue(
|
|
47
|
+
"error",
|
|
48
|
+
f"System has {len(equations)} equations but {len(variables)} variables"))
|
|
49
|
+
|
|
50
|
+
# Guess bounds check
|
|
51
|
+
for var in variables:
|
|
52
|
+
if var.value < var.min_value or var.value > var.max_value:
|
|
53
|
+
issues.append(ValidationIssue(
|
|
54
|
+
"error",
|
|
55
|
+
f'Variable "{var.name}" guess {var.value} is outside '
|
|
56
|
+
f'bounds [{var.min_value}, {var.max_value}]'))
|
|
57
|
+
|
|
58
|
+
# Trial run — call each equation via run_equation, capture deps
|
|
59
|
+
all_used_vars: set[str] = set()
|
|
60
|
+
for eq_name, eq_func in equations:
|
|
61
|
+
try:
|
|
62
|
+
result, deps = run_equation(eq_func)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
issues.append(ValidationIssue(
|
|
65
|
+
"error",
|
|
66
|
+
f'Equation "{eq_name}" raised {type(e).__name__}: {e}'))
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
if not isinstance(result, (int, float)):
|
|
70
|
+
issues.append(ValidationIssue(
|
|
71
|
+
"error",
|
|
72
|
+
f'Equation "{eq_name}" returned {type(result).__name__}, expected numeric'))
|
|
73
|
+
|
|
74
|
+
if not deps:
|
|
75
|
+
issues.append(ValidationIssue(
|
|
76
|
+
"warning",
|
|
77
|
+
f'Equation "{eq_name}" does not read any variables'))
|
|
78
|
+
|
|
79
|
+
all_used_vars.update(deps)
|
|
80
|
+
|
|
81
|
+
# Variables not used by any equation
|
|
82
|
+
for var in variables:
|
|
83
|
+
if var.name not in all_used_vars:
|
|
84
|
+
issues.append(ValidationIssue(
|
|
85
|
+
"error",
|
|
86
|
+
f'Variable "{var.name}" is not used by any equation'))
|
|
87
|
+
|
|
88
|
+
# Determine status
|
|
89
|
+
has_errors = any(i.level == "error" for i in issues)
|
|
90
|
+
has_warnings = any(i.level == "warning" for i in issues)
|
|
91
|
+
|
|
92
|
+
if has_errors:
|
|
93
|
+
return ValidationStatus.INVALID, issues
|
|
94
|
+
if has_warnings:
|
|
95
|
+
return ValidationStatus.WARNING, issues
|
|
96
|
+
return ValidationStatus.VALID, issues
|
eqsys-0.1.0/eqsys/var.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from eqsys.system import EquationSystem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Var:
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
name: str,
|
|
12
|
+
guess: float,
|
|
13
|
+
system: EquationSystem | None,
|
|
14
|
+
min_value: float = -1e15,
|
|
15
|
+
max_value: float = 1e15,
|
|
16
|
+
max_step: float = 1e10,
|
|
17
|
+
deriv_step: float = 1e-8,
|
|
18
|
+
):
|
|
19
|
+
self.name = name
|
|
20
|
+
self.value = float(guess)
|
|
21
|
+
self._system = system
|
|
22
|
+
self.min_value = min_value
|
|
23
|
+
self.max_value = max_value
|
|
24
|
+
self.max_step = max_step
|
|
25
|
+
self.deriv_step = deriv_step
|
|
26
|
+
|
|
27
|
+
def __repr__(self) -> str:
|
|
28
|
+
return f'Var("{self.name}", value={self.value})'
|
|
29
|
+
|
|
30
|
+
def __call__(self) -> float:
|
|
31
|
+
if self._system is not None and self._system._tracker.active:
|
|
32
|
+
self._system._tracker.register(self.name)
|
|
33
|
+
return self.value
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eqsys"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Nonlinear equation system solver with automatic dependency tracking"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"numpy",
|
|
12
|
+
"scipy",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest",
|
|
18
|
+
"pytest-benchmark",
|
|
19
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from eqsys import EquationSystem, SolveStatus
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _build_linear_system(n: int) -> tuple[EquationSystem, list]:
|
|
6
|
+
"""Build a system of n equations: x_i = i+1 for i in 0..n-1."""
|
|
7
|
+
sys = EquationSystem(f"bench_{n}")
|
|
8
|
+
variables = []
|
|
9
|
+
for i in range(n):
|
|
10
|
+
v = sys.var(f"x{i}", guess=0.0)
|
|
11
|
+
variables.append(v)
|
|
12
|
+
|
|
13
|
+
for i in range(n):
|
|
14
|
+
target = float(i + 1)
|
|
15
|
+
var = variables[i]
|
|
16
|
+
|
|
17
|
+
def make_eq(v=var, t=target):
|
|
18
|
+
def eq():
|
|
19
|
+
return v() - t
|
|
20
|
+
return eq
|
|
21
|
+
|
|
22
|
+
sys.add_equation(f"eq{i}", make_eq())
|
|
23
|
+
|
|
24
|
+
return sys, variables
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.benchmark
|
|
28
|
+
@pytest.mark.parametrize("n", [10, 100, 500, 1000])
|
|
29
|
+
def test_benchmark_solve(benchmark, n):
|
|
30
|
+
"""Benchmark solve time for N-equation linear system."""
|
|
31
|
+
sys, variables = _build_linear_system(n)
|
|
32
|
+
|
|
33
|
+
def run():
|
|
34
|
+
for v in variables:
|
|
35
|
+
v.value = 0.0
|
|
36
|
+
return sys.solve()
|
|
37
|
+
|
|
38
|
+
result = benchmark(run)
|
|
39
|
+
assert result == SolveStatus.CONVERGED
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.benchmark
|
|
43
|
+
@pytest.mark.parametrize("n", [10, 100, 500, 1000])
|
|
44
|
+
def test_benchmark_tracker_overhead(benchmark, n):
|
|
45
|
+
"""Benchmark tracker overhead: eval with tracking for N variables."""
|
|
46
|
+
sys = EquationSystem(f"tracker_bench_{n}")
|
|
47
|
+
variables = []
|
|
48
|
+
for i in range(n):
|
|
49
|
+
v = sys.var(f"x{i}", guess=float(i))
|
|
50
|
+
variables.append(v)
|
|
51
|
+
|
|
52
|
+
def read_all_vars():
|
|
53
|
+
sys._tracker.start()
|
|
54
|
+
total = 0.0
|
|
55
|
+
for v in variables:
|
|
56
|
+
total += v()
|
|
57
|
+
sys._tracker.stop()
|
|
58
|
+
return total
|
|
59
|
+
|
|
60
|
+
benchmark(read_all_vars)
|