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 ADDED
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .benchmarks/
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,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"]
@@ -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,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"
@@ -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
@@ -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)