qnty 0.0.8__py3-none-any.whl → 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.
Files changed (74) hide show
  1. qnty/__init__.py +140 -59
  2. qnty/constants/__init__.py +10 -0
  3. qnty/constants/numerical.py +18 -0
  4. qnty/constants/solvers.py +6 -0
  5. qnty/constants/tests.py +6 -0
  6. qnty/dimensions/__init__.py +23 -0
  7. qnty/dimensions/base.py +97 -0
  8. qnty/dimensions/field_dims.py +126 -0
  9. qnty/dimensions/field_dims.pyi +128 -0
  10. qnty/dimensions/signature.py +111 -0
  11. qnty/equations/__init__.py +4 -0
  12. qnty/equations/equation.py +220 -0
  13. qnty/equations/system.py +130 -0
  14. qnty/expressions/__init__.py +40 -0
  15. qnty/expressions/formatter.py +188 -0
  16. qnty/expressions/functions.py +74 -0
  17. qnty/expressions/nodes.py +701 -0
  18. qnty/expressions/types.py +70 -0
  19. qnty/extensions/plotting/__init__.py +0 -0
  20. qnty/extensions/reporting/__init__.py +0 -0
  21. qnty/problems/__init__.py +145 -0
  22. qnty/problems/composition.py +1031 -0
  23. qnty/problems/problem.py +695 -0
  24. qnty/problems/rules.py +145 -0
  25. qnty/problems/solving.py +1216 -0
  26. qnty/problems/validation.py +127 -0
  27. qnty/quantities/__init__.py +29 -0
  28. qnty/quantities/base_qnty.py +677 -0
  29. qnty/quantities/field_converters.py +24004 -0
  30. qnty/quantities/field_qnty.py +1012 -0
  31. qnty/quantities/field_setter.py +12320 -0
  32. qnty/quantities/field_vars.py +6325 -0
  33. qnty/quantities/field_vars.pyi +4191 -0
  34. qnty/solving/__init__.py +0 -0
  35. qnty/solving/manager.py +96 -0
  36. qnty/solving/order.py +403 -0
  37. qnty/solving/solvers/__init__.py +13 -0
  38. qnty/solving/solvers/base.py +82 -0
  39. qnty/solving/solvers/iterative.py +165 -0
  40. qnty/solving/solvers/simultaneous.py +475 -0
  41. qnty/units/__init__.py +1 -0
  42. qnty/units/field_units.py +10507 -0
  43. qnty/units/field_units.pyi +2461 -0
  44. qnty/units/prefixes.py +203 -0
  45. qnty/{unit.py → units/registry.py} +89 -61
  46. qnty/utils/__init__.py +16 -0
  47. qnty/utils/caching/__init__.py +23 -0
  48. qnty/utils/caching/manager.py +401 -0
  49. qnty/utils/error_handling/__init__.py +66 -0
  50. qnty/utils/error_handling/context.py +39 -0
  51. qnty/utils/error_handling/exceptions.py +96 -0
  52. qnty/utils/error_handling/handlers.py +171 -0
  53. qnty/utils/logging.py +40 -0
  54. qnty/utils/protocols.py +164 -0
  55. qnty/utils/scope_discovery.py +420 -0
  56. qnty-0.1.0.dist-info/METADATA +199 -0
  57. qnty-0.1.0.dist-info/RECORD +60 -0
  58. qnty/dimension.py +0 -186
  59. qnty/equation.py +0 -297
  60. qnty/expression.py +0 -553
  61. qnty/prefixes.py +0 -229
  62. qnty/unit_types/base.py +0 -47
  63. qnty/units.py +0 -8113
  64. qnty/variable.py +0 -300
  65. qnty/variable_types/base.py +0 -58
  66. qnty/variable_types/expression_variable.py +0 -106
  67. qnty/variable_types/typed_variable.py +0 -87
  68. qnty/variables.py +0 -2298
  69. qnty/variables.pyi +0 -6148
  70. qnty-0.0.8.dist-info/METADATA +0 -355
  71. qnty-0.0.8.dist-info/RECORD +0 -19
  72. /qnty/{unit_types → extensions}/__init__.py +0 -0
  73. /qnty/{variable_types → extensions/integration}/__init__.py +0 -0
  74. {qnty-0.0.8.dist-info → qnty-0.1.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,165 @@
1
+ from typing import Any
2
+
3
+ from ...equations import Equation
4
+ from ...expressions import ConditionalExpression, VariableReference
5
+ from ...quantities.field_qnty import FieldQnty
6
+ from ..order import Order
7
+ from .base import BaseSolver, SolveResult
8
+
9
+
10
+ class IterativeSolver(BaseSolver):
11
+ """
12
+ Iterative solver that follows dependency order like solving engineering problems by hand.
13
+
14
+ This solver works by:
15
+ 1. Using dependency graph to determine the correct solving order
16
+ 2. Solving variables one by one in dependency order (just like manual solving)
17
+ 3. Preserving units throughout with Pint integration
18
+ 4. Verifying each solution with residual checking
19
+ 5. Repeating until all unknowns are solved
20
+
21
+ This approach mirrors how engineers solve problems by hand: solve what you can
22
+ with what you know, then use those results to solve the next level of dependencies.
23
+ """
24
+
25
+ def can_handle(self, equations: list[Equation], unknowns: set[str], dependency_graph: Order | None = None, analysis: dict[str, Any] | None = None) -> bool:
26
+ """
27
+ Can handle any system that has at least one unknown and a dependency graph.
28
+ """
29
+ return bool(unknowns and dependency_graph)
30
+
31
+ def solve(self, equations: list[Equation], variables: dict[str, FieldQnty], dependency_graph: Order | None = None, max_iterations: int = 100, tolerance: float = 1e-10) -> SolveResult:
32
+ """
33
+ Solve the system iteratively using dependency graph.
34
+ """
35
+ self.steps = []
36
+
37
+ if not dependency_graph:
38
+ return SolveResult(variables=variables, steps=self.steps, success=False, message="Dependency graph required for iterative solving", method="IterativeSolver")
39
+
40
+ # Make a copy of variables to work with
41
+ working_vars = dict(variables.items())
42
+ known_vars = self._get_known_variables(working_vars)
43
+
44
+ if self.logger:
45
+ self.logger.debug(f"Starting iterative solve with {len(known_vars)} known variables")
46
+
47
+ # Iterative solving
48
+ iteration = 0
49
+ for iteration in range(max_iterations):
50
+ iteration_start = len(known_vars)
51
+
52
+ # Get variables that can be solved in this iteration
53
+ solvable = dependency_graph.get_solvable_variables(known_vars)
54
+
55
+ # Fallback: attempt direct equations for remaining unknowns
56
+ if not solvable:
57
+ solvable = self._find_directly_solvable_variables(equations, working_vars, known_vars)
58
+
59
+ # Try to break conditional cycles if still no solvable variables
60
+ if not solvable:
61
+ solvable = self._solve_conditional_cycles(equations, working_vars, known_vars)
62
+
63
+ if not solvable:
64
+ break # No more variables can be solved
65
+
66
+ if self.logger:
67
+ self.logger.debug(f"Iteration {iteration + 1} solvable: {solvable}")
68
+
69
+ # Solve for each solvable variable
70
+ for var_symbol in solvable:
71
+ result = self._solve_single_variable(var_symbol, equations, working_vars, known_vars, dependency_graph, iteration, tolerance)
72
+ if not result:
73
+ return SolveResult(variables=working_vars, steps=self.steps, success=False, message=f"Failed to solve for {var_symbol}", method="IterativeSolver", iterations=iteration + 1)
74
+
75
+ # Check for progress
76
+ if len(known_vars) == iteration_start:
77
+ if self.logger:
78
+ self.logger.warning("No progress made, stopping early")
79
+ break
80
+
81
+ # Check if we solved all unknowns
82
+ remaining_unknowns = self._get_unknown_variables(working_vars)
83
+ success = len(remaining_unknowns) == 0
84
+
85
+ message = "All variables solved" if success else f"Could not solve: {remaining_unknowns}"
86
+
87
+ return SolveResult(variables=working_vars, steps=self.steps, success=success, message=message, method="IterativeSolver", iterations=iteration + 1)
88
+
89
+ def _find_directly_solvable_variables(self, equations: list[Equation], working_vars: dict[str, FieldQnty], known_vars: set[str]) -> list[str]:
90
+ """Find variables that can be directly solved from equations."""
91
+ solvable = []
92
+ remaining_unknowns = [v for v in self._get_unknown_variables(working_vars) if v not in known_vars]
93
+
94
+ for var_symbol in remaining_unknowns:
95
+ for eq in equations:
96
+ if eq.can_solve_for(var_symbol, known_vars):
97
+ solvable.append(var_symbol)
98
+ break
99
+
100
+ return solvable
101
+
102
+ def _solve_conditional_cycles(self, equations: list[Equation], working_vars: dict[str, FieldQnty], known_vars: set[str]) -> list[str]:
103
+ """Attempt to solve conditional cycles in the equation system."""
104
+ remaining_unknowns = [v for v in self._get_unknown_variables(working_vars) if v not in known_vars]
105
+
106
+ for var_symbol in remaining_unknowns:
107
+ for eq in equations:
108
+ # Check if this is a conditional equation that can be solved
109
+ if self._is_conditional_equation(eq, var_symbol):
110
+ try:
111
+ solved_var = eq.solve_for(var_symbol, working_vars)
112
+ working_vars[var_symbol] = solved_var
113
+ known_vars.add(var_symbol)
114
+
115
+ if self.logger:
116
+ self.logger.debug(f"Solved conditional cycle: {var_symbol} = {solved_var.quantity}")
117
+
118
+ return [var_symbol] # Return immediately after solving one
119
+ except Exception:
120
+ continue
121
+
122
+ return []
123
+
124
+ def _is_conditional_equation(self, equation: Equation, var_symbol: str) -> bool:
125
+ """Check if equation is a conditional equation for the given variable."""
126
+ return isinstance(equation.lhs, VariableReference) and equation.lhs.name == var_symbol and isinstance(equation.rhs, ConditionalExpression)
127
+
128
+ def _solve_single_variable(
129
+ self, var_symbol: str, equations: list[Equation], working_vars: dict[str, FieldQnty], known_vars: set[str], dependency_graph: Order, iteration: int, tolerance: float
130
+ ) -> bool:
131
+ """Solve for a single variable and update the system state."""
132
+ # Find equation that can solve for this variable
133
+ equation = dependency_graph.get_equation_for_variable(var_symbol, known_vars)
134
+
135
+ if equation is None:
136
+ # Try any equation that can solve it
137
+ for eq in equations:
138
+ if eq.can_solve_for(var_symbol, known_vars):
139
+ equation = eq
140
+ break
141
+
142
+ if equation is None:
143
+ return True # Skip this variable, not a failure
144
+
145
+ try:
146
+ solved_var = equation.solve_for(var_symbol, working_vars)
147
+ working_vars[var_symbol] = solved_var
148
+ known_vars.add(var_symbol)
149
+
150
+ # Verify solution by checking residual
151
+ if equation.check_residual(working_vars, tolerance):
152
+ if self.logger:
153
+ self.logger.debug(f"Solution verified for {var_symbol}")
154
+ else:
155
+ if self.logger:
156
+ self.logger.warning(f"Residual check failed for {var_symbol}")
157
+
158
+ self._log_step(iteration + 1, var_symbol, str(equation), str(solved_var.quantity), "iterative")
159
+
160
+ return True
161
+
162
+ except Exception as e:
163
+ if self.logger:
164
+ self.logger.error(f"Failed to solve for {var_symbol}: {e}")
165
+ return False
@@ -0,0 +1,475 @@
1
+ from typing import Any
2
+
3
+ import numpy as np
4
+
5
+ try:
6
+ from scipy.linalg import solve as scipy_solve # type: ignore[import-untyped]
7
+
8
+ HAS_SCIPY = True
9
+ except ImportError:
10
+ HAS_SCIPY = False
11
+ scipy_solve = None
12
+
13
+ from qnty.solving.order import Order
14
+
15
+ from ...equations import Equation
16
+ from ...quantities import Quantity
17
+ from ...quantities.field_qnty import FieldQnty
18
+ from .base import BaseSolver, SolveResult
19
+
20
+
21
+ class SimultaneousEquationSolver(BaseSolver):
22
+ """
23
+ Solver for n×n simultaneous linear equation systems using matrix operations.
24
+
25
+ This solver handles systems where equations are mutually dependent (forming cycles
26
+ in the dependency graph), requiring simultaneous solution rather than sequential
27
+ solution of individual equations.
28
+
29
+ Algorithm:
30
+ 1. Validate system requirements (square, n≥2, has cycles)
31
+ 2. Extract coefficient matrix A and constant vector b from equations
32
+ 3. Check numerical stability (condition number)
33
+ 4. Solve matrix system Ax = b using robust linear algebra
34
+ 5. Update variables with solutions and verify residuals
35
+
36
+ Supports any size square system (n equations, n unknowns) that is:
37
+ - Linearly independent (non-singular matrix)
38
+ - Well-conditioned (numerically stable)
39
+ - Composed of linear equations with units preserved throughout
40
+
41
+ Examples:
42
+ 2×2 system: x + y = 3, 2x - y = 0 → x=1, y=2
43
+ 3×3 system: Complex engineering systems with interdependent variables
44
+ """
45
+
46
+ # Constants for numerical stability and validation
47
+ MIN_SYSTEM_SIZE = 2
48
+ MAX_CONDITION_NUMBER = 1e12
49
+ DEFAULT_TOLERANCE = 1e-10
50
+
51
+ # Performance optimization constants
52
+ LARGE_SYSTEM_THRESHOLD = 100 # Switch to optimized algorithms for n > 100
53
+ SPARSE_THRESHOLD = 0.1 # Use sparse matrices if density < 10%
54
+
55
+ def can_handle(self, equations: list[Equation], unknowns: set[str], dependency_graph: Order | None = None, analysis: dict[str, Any] | None = None) -> bool:
56
+ """
57
+ Determine if this solver can handle the given system.
58
+
59
+ Args:
60
+ equations: List of equations to solve
61
+ unknowns: Set of unknown variable symbols
62
+ dependency_graph: Optional dependency graph (unused)
63
+ analysis: Optional system analysis containing cycle information
64
+
65
+ Returns:
66
+ True if this solver can handle the system:
67
+ - Square system (n equations = n unknowns)
68
+ - Minimum size (n ≥ 2)
69
+ - Contains cycles (indicating simultaneous equations needed)
70
+ """
71
+ # dependency_graph parameter unused but required for interface compatibility
72
+ _ = dependency_graph
73
+
74
+ system_size = len(equations)
75
+ num_unknowns = len(unknowns)
76
+
77
+ # Validate square system with minimum size
78
+ if system_size != num_unknowns or system_size < self.MIN_SYSTEM_SIZE:
79
+ return False
80
+
81
+ # Only handle systems with cycles (mutual dependencies)
82
+ if analysis is None:
83
+ return False
84
+ has_cycles = analysis.get("has_cycles", False)
85
+ return bool(has_cycles)
86
+
87
+ def solve(self, equations: list[Equation], variables: dict[str, FieldQnty], dependency_graph: Order | None = None, max_iterations: int = 100, tolerance: float = DEFAULT_TOLERANCE) -> SolveResult:
88
+ """
89
+ Solve the n×n simultaneous system using matrix operations.
90
+
91
+ Args:
92
+ equations: List of n linear equations to solve simultaneously
93
+ variables: Dictionary of all variables (known and unknown)
94
+ dependency_graph: Optional dependency graph
95
+ (unused for simultaneous systems)
96
+ max_iterations: Maximum iterations
97
+ (unused for direct matrix solving)
98
+ tolerance: Numerical tolerance for residual checking
99
+
100
+ Returns:
101
+ SolveResult containing the solution or error information
102
+ """
103
+ # Mark unused parameters to satisfy linter
104
+ _ = dependency_graph, max_iterations
105
+
106
+ self.steps = []
107
+
108
+ # Step 1: Validate system requirements
109
+ validation_result = self._validate_system(equations, variables)
110
+ if not validation_result.success:
111
+ return validation_result
112
+
113
+ unknown_variable_names = list(self._get_unknown_variables(variables))
114
+ working_variables = dict(variables)
115
+
116
+ # Step 2: Extract and solve matrix system
117
+ try:
118
+ solution_vector = self._solve_matrix_system(equations, unknown_variable_names, working_variables)
119
+ if solution_vector is None:
120
+ return SolveResult(variables=working_variables, steps=self.steps, success=False, message="Failed to solve matrix system", method="SimultaneousEquationSolver")
121
+
122
+ # Step 3: Update variables with solutions
123
+ self._apply_solution_to_variables(unknown_variable_names, solution_vector, working_variables)
124
+
125
+ # Step 4: Verify solution quality
126
+ verification_result = self._verify_solution_quality(equations, working_variables, tolerance)
127
+
128
+ return SolveResult(
129
+ variables=working_variables, steps=self.steps, success=verification_result.success, message=verification_result.message, method="SimultaneousEquationSolver", iterations=1
130
+ )
131
+
132
+ except Exception as general_error:
133
+ return SolveResult(variables=working_variables, steps=self.steps, success=False, message=f"Simultaneous solving failed: {general_error}", method="SimultaneousEquationSolver")
134
+
135
+ def _validate_system(self, equations: list[Equation], variables: dict[str, FieldQnty]) -> SolveResult:
136
+ """
137
+ Validate that the system meets requirements for simultaneous solving.
138
+
139
+ Returns:
140
+ SolveResult with success=True if valid, or error result if invalid
141
+ """
142
+ unknown_variable_names = list(self._get_unknown_variables(variables))
143
+ num_unknowns = len(unknown_variable_names)
144
+ num_equations = len(equations)
145
+
146
+ # Validate square system with minimum size
147
+ if num_unknowns != num_equations or num_unknowns < self.MIN_SYSTEM_SIZE:
148
+ return SolveResult(
149
+ variables=variables,
150
+ steps=self.steps,
151
+ success=False,
152
+ message=f"Simultaneous solver requires n×n system (got {num_equations} equations, {num_unknowns} unknowns)",
153
+ method="SimultaneousEquationSolver",
154
+ )
155
+
156
+ if self.logger:
157
+ self.logger.debug(f"Attempting {num_unknowns}×{num_unknowns} simultaneous solution for {unknown_variable_names}")
158
+
159
+ return SolveResult(variables=variables, steps=self.steps, success=True, message="System validation passed", method="SimultaneousEquationSolver")
160
+
161
+ def _solve_matrix_system(self, equations: list[Equation], unknown_variables: list[str], working_variables: dict[str, FieldQnty]) -> np.ndarray | None:
162
+ """
163
+ Extract coefficient matrix and solve the linear system.
164
+
165
+ Returns:
166
+ Solution vector if successful, None if failed
167
+ """
168
+ # Extract coefficient matrix A and constant vector b
169
+ coefficient_matrix, constant_vector = self._extract_matrix_system(equations, unknown_variables, working_variables)
170
+
171
+ if coefficient_matrix is None or constant_vector is None:
172
+ if self.logger:
173
+ self.logger.error("Could not extract linear coefficients from equations")
174
+ return None
175
+
176
+ # Check numerical stability via condition number
177
+ condition_number = np.linalg.cond(coefficient_matrix)
178
+ if condition_number > self.MAX_CONDITION_NUMBER:
179
+ if self.logger:
180
+ # Use debug level for expected fallback scenarios (systems with conditionals)
181
+ if np.isinf(condition_number):
182
+ self.logger.debug("Matrix is singular (cond=inf), falling back to iterative solver")
183
+ else:
184
+ self.logger.debug(f"System is ill-conditioned (cond={condition_number:.2e}), falling back to iterative solver")
185
+ return None
186
+
187
+ try:
188
+ # Choose optimal solving algorithm based on system size
189
+ system_size = coefficient_matrix.shape[0]
190
+ solution_vector = self._solve_optimized_system(coefficient_matrix, constant_vector, system_size)
191
+ return solution_vector
192
+
193
+ except np.linalg.LinAlgError as linear_algebra_error:
194
+ if self.logger:
195
+ self.logger.error(f"Linear algebra error: {linear_algebra_error}")
196
+ return None
197
+
198
+ def _solve_optimized_system(self, coefficient_matrix: np.ndarray, constant_vector: np.ndarray, system_size: int) -> np.ndarray:
199
+ """
200
+ Solve the matrix system using optimized algorithms based on system characteristics.
201
+
202
+ Args:
203
+ coefficient_matrix: The coefficient matrix A
204
+ constant_vector: The constant vector b
205
+ system_size: Size of the system (n for n×n)
206
+
207
+ Returns:
208
+ Solution vector for the system Ax = b
209
+ """
210
+ if system_size <= self.LARGE_SYSTEM_THRESHOLD:
211
+ # Use standard NumPy solver for small-medium systems
212
+ return np.linalg.solve(coefficient_matrix, constant_vector)
213
+ else:
214
+ # For large systems, use more efficient algorithms
215
+ if self.logger:
216
+ self.logger.debug(f"Using optimized algorithms for large system (n={system_size})")
217
+
218
+ # Check matrix density to decide between dense/sparse algorithms
219
+ density = np.count_nonzero(coefficient_matrix) / coefficient_matrix.size
220
+
221
+ if density < self.SPARSE_THRESHOLD:
222
+ # Use sparse matrix algorithms
223
+ if self.logger:
224
+ self.logger.debug(f"Matrix density {density:.3f} < {self.SPARSE_THRESHOLD}, using sparse algorithms")
225
+ return self._solve_sparse_system(coefficient_matrix, constant_vector)
226
+ else:
227
+ # Use optimized dense algorithms
228
+ if self.logger:
229
+ self.logger.debug(f"Matrix density {density:.3f} >= {self.SPARSE_THRESHOLD}, using optimized dense algorithms")
230
+ return self._solve_large_dense_system(coefficient_matrix, constant_vector)
231
+
232
+ def _solve_sparse_system(self, coefficient_matrix: np.ndarray, constant_vector: np.ndarray) -> np.ndarray:
233
+ """
234
+ Solve sparse matrix system. Currently falls back to dense solver.
235
+ """
236
+ return np.linalg.solve(coefficient_matrix, constant_vector)
237
+
238
+ def _solve_large_dense_system(self, coefficient_matrix: np.ndarray, constant_vector: np.ndarray) -> np.ndarray:
239
+ """
240
+ Solve large dense matrix system using optimized algorithms.
241
+ """
242
+ if HAS_SCIPY and scipy_solve is not None:
243
+ return scipy_solve(coefficient_matrix, constant_vector, assume_a="gen")
244
+ return np.linalg.solve(coefficient_matrix, constant_vector)
245
+
246
+ def _apply_solution_to_variables(self, unknown_variables: list[str], solution_vector: np.ndarray, working_variables: dict[str, FieldQnty]):
247
+ """
248
+ Apply solution values to variables and record solving steps.
249
+ """
250
+ for i, variable_name in enumerate(unknown_variables):
251
+ solution_value = float(solution_vector[i])
252
+ self._update_variable_with_solution(variable_name, solution_value, working_variables)
253
+
254
+ def _verify_solution_quality(self, equations: list[Equation], working_variables: dict[str, FieldQnty], tolerance: float) -> SolveResult:
255
+ """
256
+ Verify solution quality by checking equation residuals.
257
+
258
+ Returns:
259
+ SolveResult indicating whether solution meets quality requirements
260
+ """
261
+ max_residual = 0.0
262
+ for equation in equations:
263
+ if equation.check_residual(working_variables, tolerance):
264
+ if self.logger:
265
+ self.logger.debug(f"Equation {equation.name} verified")
266
+ else:
267
+ residual = self._calculate_equation_residual(equation, working_variables)
268
+ max_residual = max(max_residual, abs(residual))
269
+ if self.logger:
270
+ self.logger.warning(f"Equation {equation.name} residual: {residual}")
271
+
272
+ is_successful = max_residual < tolerance
273
+ success_message = "Simultaneous system solved successfully" if is_successful else f"Large residuals detected (max={max_residual:.2e})"
274
+
275
+ if self.logger and is_successful:
276
+ num_unknowns = len([v for v in working_variables.values() if not v.is_known])
277
+ variable_solutions = {var: f"{working_variables[var].quantity}" for var in working_variables if not working_variables[var].is_known}
278
+ self.logger.debug(f"Solved {num_unknowns}×{num_unknowns} system: {variable_solutions}")
279
+
280
+ return SolveResult(variables=working_variables, steps=[], success=is_successful, message=success_message, method="SimultaneousEquationSolver")
281
+
282
+ def _extract_matrix_system(self, equations: list[Equation], unknown_variables: list[str], variables: dict[str, FieldQnty]) -> tuple[np.ndarray | None, np.ndarray | None]:
283
+ """
284
+ Extract coefficient matrix A and constant vector b from the system of equations.
285
+
286
+ Args:
287
+ equations: List of linear equations to extract coefficients from
288
+ unknown_variables: List of unknown variable symbols (determines column order)
289
+ variables: Dictionary of all variables for evaluation
290
+
291
+ Returns:
292
+ Tuple of (coefficient_matrix, constant_vector) for system Ax = b
293
+ Returns (None, None) if extraction fails
294
+
295
+ Algorithm:
296
+ For each equation, extract coefficients by numerical differentiation:
297
+ 1. Test each unknown variable with value 1, others with 0
298
+ 2. Calculate residual to determine coefficient
299
+ 3. Build coefficient matrix row by row
300
+ """
301
+ try:
302
+ num_equations = len(equations)
303
+
304
+ # Use consistent float64 precision for numerical stability
305
+ dtype = np.float64
306
+
307
+ coefficient_matrix = np.zeros((num_equations, num_equations), dtype=dtype)
308
+ constant_vector = np.zeros(num_equations, dtype=dtype)
309
+
310
+ # Process equations in batches for large systems to reduce memory pressure
311
+ for equation_index, equation in enumerate(equations):
312
+ coefficient_list = self._extract_linear_coefficients_vector(equation, unknown_variables, variables)
313
+ if coefficient_list is None:
314
+ return None, None
315
+
316
+ # coefficient_list contains [a1, a2, ..., an, constant]
317
+ # where equation is a1*x1 + a2*x2 + ... + an*xn = constant
318
+ coefficient_matrix[equation_index, :] = coefficient_list[:-1] # Coefficients
319
+ constant_vector[equation_index] = coefficient_list[-1] # Constant term
320
+
321
+ return coefficient_matrix, constant_vector
322
+
323
+ except Exception:
324
+ return None, None
325
+
326
+ def _extract_linear_coefficients_vector(self, equation: Equation, unknown_variables: list[str], variables: dict[str, FieldQnty]) -> list[float] | None:
327
+ """
328
+ Extract linear coefficients from equation using numerical differentiation.
329
+
330
+ Args:
331
+ equation: The equation to extract coefficients from
332
+ unknown_variables: List of unknown variable symbols
333
+ variables: Dictionary of all variables
334
+
335
+ Returns:
336
+ List [a1, a2, ..., an, c] for equation a1*x1 + a2*x2 + ... + an*xn = c
337
+ Returns None if extraction fails
338
+
339
+ Algorithm:
340
+ Uses finite difference approximation:
341
+ 1. Set all unknowns to 0, calculate residual → gives constant term
342
+ 2. Set one unknown to 1, others to 0 → gives coefficient for that variable
343
+ 3. Repeat for all unknowns to build coefficient vector
344
+ """
345
+ try:
346
+ num_unknowns = len(unknown_variables)
347
+ coefficients = []
348
+
349
+ # Memory optimization: Pre-allocate arrays for large systems
350
+ residual_test_cases: list[float] = []
351
+
352
+ # Reuse test variables dictionary to reduce object creation overhead
353
+ test_vars = variables.copy()
354
+
355
+ # Test case for each unknown variable (finite difference)
356
+ for variable_index in range(num_unknowns):
357
+ self._set_test_variables(test_vars, unknown_variables, variable_index)
358
+ residual = self._calculate_equation_residual(equation, test_vars)
359
+ residual_test_cases.append(residual)
360
+
361
+ # Test case with all unknowns = 0 (baseline)
362
+ self._set_test_variables(test_vars, unknown_variables, -1) # -1 means set all to 0
363
+ baseline_residual = self._calculate_equation_residual(equation, test_vars)
364
+
365
+ # Extract coefficients: for equation sum(ai*xi) - c = 0
366
+ # When xi=1, xj=0 (j≠i): ai - c = residual_i → ai = residual_i + c
367
+ # When all xi=0: -c = baseline_residual → c = -baseline_residual
368
+
369
+ constant_term = -baseline_residual
370
+ for residual in residual_test_cases:
371
+ coefficient = residual + constant_term
372
+ coefficients.append(coefficient)
373
+
374
+ coefficients.append(constant_term) # Add constant term
375
+ return coefficients
376
+
377
+ except Exception:
378
+ return None
379
+
380
+ def _calculate_equation_residual(self, equation: Equation, test_variables: dict[str, FieldQnty]) -> float:
381
+ """
382
+ Calculate equation residual (LHS - RHS) with proper unit handling.
383
+
384
+ Args:
385
+ equation: The equation to evaluate
386
+ test_variables: Dictionary of variables with test values
387
+
388
+ Returns:
389
+ Numerical residual value (dimensionless)
390
+ Returns infinity if evaluation fails
391
+ """
392
+ try:
393
+ left_hand_side = equation.lhs.evaluate(test_variables)
394
+ right_hand_side = equation.rhs.evaluate(test_variables)
395
+
396
+ # Calculate residual and extract numerical value
397
+ residual = left_hand_side - right_hand_side
398
+ return self._extract_numerical_value(residual)
399
+
400
+ except Exception:
401
+ # Fallback for cases where evaluation fails
402
+ return float("inf")
403
+
404
+ def _update_variable_with_solution(self, variable_symbol: str, solution_value: float, variables: dict[str, FieldQnty]):
405
+ """
406
+ Update a variable with its solved value and record the solving step.
407
+
408
+ Args:
409
+ variable_symbol: Symbol of the variable to update
410
+ solution_value: Numerical solution value
411
+ variables: Dictionary of variables to update
412
+ """
413
+ original_variable = variables[variable_symbol]
414
+ if original_variable.quantity is None:
415
+ raise ValueError(f"Variable {variable_symbol} has no quantity")
416
+ result_unit = original_variable.quantity.unit
417
+ solution_quantity = Quantity(solution_value, result_unit)
418
+
419
+ # Preserve the original variable name and create solved variable
420
+ original_name = original_variable.name
421
+ solved_variable = FieldQnty(name=original_name, expected_dimension=solution_quantity.dimension, is_known=True)
422
+ solved_variable.quantity = solution_quantity
423
+ solved_variable.symbol = variable_symbol
424
+ variables[variable_symbol] = solved_variable
425
+
426
+ # Record solving step for tracking
427
+ self._log_step(
428
+ 1, # iteration number
429
+ variable_symbol,
430
+ "simultaneous_system",
431
+ str(solution_quantity),
432
+ "simultaneous",
433
+ )
434
+
435
+ def _set_test_variables(self, test_vars: dict[str, FieldQnty], unknown_variables: list[str], active_index: int):
436
+ """
437
+ Set test variables for coefficient extraction.
438
+
439
+ Args:
440
+ test_vars: Dictionary of test variables to modify
441
+ unknown_variables: List of unknown variable names
442
+ active_index: Index of variable to set to 1.0, others set to 0.0. If -1, all set to 0.0
443
+ """
444
+ for unknown_index, var_name in enumerate(unknown_variables):
445
+ test_value = 1.0 if unknown_index == active_index else 0.0
446
+ original_var = test_vars[var_name]
447
+ if original_var.quantity is None:
448
+ raise ValueError(f"Variable {var_name} has no quantity")
449
+ test_var = FieldQnty(name=f"test_{var_name}", expected_dimension=original_var.quantity.dimension, is_known=True)
450
+ test_var.quantity = Quantity(test_value, original_var.quantity.unit)
451
+ test_var.symbol = var_name
452
+ test_vars[var_name] = test_var
453
+
454
+ def _extract_numerical_value(self, value: Any) -> float:
455
+ """
456
+ Extract numerical value from various quantity types.
457
+
458
+ Args:
459
+ value: Value that may be a Quantity, float, int, or other numeric type
460
+
461
+ Returns:
462
+ Float representation of the value
463
+ """
464
+ # Check for Quantity type first (most common case)
465
+ if isinstance(value, Quantity):
466
+ return float(value.value)
467
+ # Handle primitive numeric types
468
+ elif isinstance(value, int | float):
469
+ return float(value)
470
+ # Handle objects with .value attribute as last resort
471
+ elif hasattr(value, "value"):
472
+ return float(value.value)
473
+ else:
474
+ # Last resort: try direct conversion
475
+ return float(value)
qnty/units/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .field_units import *