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 ADDED
@@ -0,0 +1,7 @@
1
+ """eqsys — Nonlinear equation system solver."""
2
+
3
+ from eqsys.status import SolveStatus, ValidationStatus
4
+ from eqsys.system import EquationSystem
5
+ from eqsys.var import Var
6
+
7
+ __all__ = ["EquationSystem", "Var", "SolveStatus", "ValidationStatus"]
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
@@ -0,0 +1,13 @@
1
+ from enum import Enum
2
+
3
+
4
+ class SolveStatus(Enum):
5
+ CONVERGED = "converged"
6
+ NOT_CONVERGED = "not_converged"
7
+ ERROR = "error"
8
+
9
+
10
+ class ValidationStatus(Enum):
11
+ VALID = "valid"
12
+ INVALID = "invalid"
13
+ WARNING = "warning"
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any