eqsys 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.
- eqsys/__init__.py +7 -0
- eqsys/diagnostics.py +57 -0
- eqsys/jacobian.py +37 -0
- eqsys/solver.py +71 -0
- eqsys/status.py +13 -0
- eqsys/system.py +104 -0
- eqsys/tracker.py +16 -0
- eqsys/validator.py +96 -0
- eqsys/var.py +33 -0
- eqsys-0.1.0.dist-info/METADATA +10 -0
- eqsys-0.1.0.dist-info/RECORD +12 -0
- eqsys-0.1.0.dist-info/WHEEL +4 -0
eqsys/__init__.py
ADDED
eqsys/diagnostics.py
ADDED
|
@@ -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]
|
eqsys/jacobian.py
ADDED
|
@@ -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()
|
eqsys/solver.py
ADDED
|
@@ -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
|
eqsys/status.py
ADDED
eqsys/system.py
ADDED
|
@@ -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)
|
eqsys/tracker.py
ADDED
|
@@ -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)
|
eqsys/validator.py
ADDED
|
@@ -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/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,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'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
eqsys/__init__.py,sha256=PZtvsoxfGCafr9Bon6L42R_s4Br1N4sSqR3D3_AjeDw,244
|
|
2
|
+
eqsys/diagnostics.py,sha256=9rnU5ci61SDYY953LtxUe6KeZLhbgKb3mZJNyXGQEis,1609
|
|
3
|
+
eqsys/jacobian.py,sha256=TgI-Ck0eu23zsbjo67J_kWxVn89o_JAVAA6oa9Hcnfk,1017
|
|
4
|
+
eqsys/solver.py,sha256=dyG2J6lBQrsIIOXP42DedmdWOhC7DVcStZqoGw6Uum0,2128
|
|
5
|
+
eqsys/status.py,sha256=gx0wsan9d1coNpJE90S9t-2Q3qL0UDUU7tnUqTQiK3Y,233
|
|
6
|
+
eqsys/system.py,sha256=xthQRwAhVqasZXPE0cg3ddArRhhNYonBfCYL51SxHtM,3110
|
|
7
|
+
eqsys/tracker.py,sha256=eokqyLWLWU21vmSZPxKpvWHh92U_K-8fyke3laCrRTU,378
|
|
8
|
+
eqsys/validator.py,sha256=r4UQjYtpRbjnShyWTG2890HQnoBZHF-7O8agpyisYwY,3164
|
|
9
|
+
eqsys/var.py,sha256=Lh0_7tx-7iY8UC5myhll0c8wyvPf0jOYJQAfgHz41Ic,898
|
|
10
|
+
eqsys-0.1.0.dist-info/METADATA,sha256=uIieW5trglQuvLFgXvAHjuTlcXvfayhRvrmRuU8aVQc,298
|
|
11
|
+
eqsys-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
eqsys-0.1.0.dist-info/RECORD,,
|