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.
- qnty/__init__.py +140 -59
- qnty/constants/__init__.py +10 -0
- qnty/constants/numerical.py +18 -0
- qnty/constants/solvers.py +6 -0
- qnty/constants/tests.py +6 -0
- qnty/dimensions/__init__.py +23 -0
- qnty/dimensions/base.py +97 -0
- qnty/dimensions/field_dims.py +126 -0
- qnty/dimensions/field_dims.pyi +128 -0
- qnty/dimensions/signature.py +111 -0
- qnty/equations/__init__.py +4 -0
- qnty/equations/equation.py +220 -0
- qnty/equations/system.py +130 -0
- qnty/expressions/__init__.py +40 -0
- qnty/expressions/formatter.py +188 -0
- qnty/expressions/functions.py +74 -0
- qnty/expressions/nodes.py +701 -0
- qnty/expressions/types.py +70 -0
- qnty/extensions/plotting/__init__.py +0 -0
- qnty/extensions/reporting/__init__.py +0 -0
- qnty/problems/__init__.py +145 -0
- qnty/problems/composition.py +1031 -0
- qnty/problems/problem.py +695 -0
- qnty/problems/rules.py +145 -0
- qnty/problems/solving.py +1216 -0
- qnty/problems/validation.py +127 -0
- qnty/quantities/__init__.py +29 -0
- qnty/quantities/base_qnty.py +677 -0
- qnty/quantities/field_converters.py +24004 -0
- qnty/quantities/field_qnty.py +1012 -0
- qnty/quantities/field_setter.py +12320 -0
- qnty/quantities/field_vars.py +6325 -0
- qnty/quantities/field_vars.pyi +4191 -0
- qnty/solving/__init__.py +0 -0
- qnty/solving/manager.py +96 -0
- qnty/solving/order.py +403 -0
- qnty/solving/solvers/__init__.py +13 -0
- qnty/solving/solvers/base.py +82 -0
- qnty/solving/solvers/iterative.py +165 -0
- qnty/solving/solvers/simultaneous.py +475 -0
- qnty/units/__init__.py +1 -0
- qnty/units/field_units.py +10507 -0
- qnty/units/field_units.pyi +2461 -0
- qnty/units/prefixes.py +203 -0
- qnty/{unit.py → units/registry.py} +89 -61
- qnty/utils/__init__.py +16 -0
- qnty/utils/caching/__init__.py +23 -0
- qnty/utils/caching/manager.py +401 -0
- qnty/utils/error_handling/__init__.py +66 -0
- qnty/utils/error_handling/context.py +39 -0
- qnty/utils/error_handling/exceptions.py +96 -0
- qnty/utils/error_handling/handlers.py +171 -0
- qnty/utils/logging.py +40 -0
- qnty/utils/protocols.py +164 -0
- qnty/utils/scope_discovery.py +420 -0
- qnty-0.1.0.dist-info/METADATA +199 -0
- qnty-0.1.0.dist-info/RECORD +60 -0
- qnty/dimension.py +0 -186
- qnty/equation.py +0 -297
- qnty/expression.py +0 -553
- qnty/prefixes.py +0 -229
- qnty/unit_types/base.py +0 -47
- qnty/units.py +0 -8113
- qnty/variable.py +0 -300
- qnty/variable_types/base.py +0 -58
- qnty/variable_types/expression_variable.py +0 -106
- qnty/variable_types/typed_variable.py +0 -87
- qnty/variables.py +0 -2298
- qnty/variables.pyi +0 -6148
- qnty-0.0.8.dist-info/METADATA +0 -355
- qnty-0.0.8.dist-info/RECORD +0 -19
- /qnty/{unit_types → extensions}/__init__.py +0 -0
- /qnty/{variable_types → extensions/integration}/__init__.py +0 -0
- {qnty-0.0.8.dist-info → qnty-0.1.0.dist-info}/WHEEL +0 -0
qnty/problems/problem.py
ADDED
@@ -0,0 +1,695 @@
|
|
1
|
+
"""
|
2
|
+
Main Problem class with consolidated functionality.
|
3
|
+
|
4
|
+
This module combines the core Problem functionality including:
|
5
|
+
- Problem base class with state management and initialization (formerly base.py)
|
6
|
+
- Variable lifecycle management (formerly variables.py)
|
7
|
+
- Equation processing pipeline (formerly equations.py)
|
8
|
+
"""
|
9
|
+
|
10
|
+
from __future__ import annotations
|
11
|
+
|
12
|
+
from collections.abc import Callable
|
13
|
+
from copy import deepcopy
|
14
|
+
from typing import Any
|
15
|
+
|
16
|
+
from qnty.expressions import BinaryOperation, Constant, VariableReference
|
17
|
+
from qnty.solving.order import Order
|
18
|
+
from qnty.solving.solvers import SolverManager
|
19
|
+
from qnty.utils.logging import get_logger
|
20
|
+
|
21
|
+
from ..constants import SOLVER_DEFAULT_MAX_ITERATIONS, SOLVER_DEFAULT_TOLERANCE
|
22
|
+
from ..equations import Equation, EquationSystem
|
23
|
+
from ..quantities import FieldQnty, Quantity
|
24
|
+
from ..units import DimensionlessUnits
|
25
|
+
from .solving import EquationReconstructor
|
26
|
+
|
27
|
+
# Constants for equation processing
|
28
|
+
MATHEMATICAL_OPERATORS = ["+", "-", "*", "/", " / ", " * ", " + ", " - "]
|
29
|
+
COMMON_COMPOSITE_VARIABLES = ["P", "c", "S", "E", "W", "Y"]
|
30
|
+
MAX_ITERATIONS_DEFAULT = SOLVER_DEFAULT_MAX_ITERATIONS
|
31
|
+
TOLERANCE_DEFAULT = SOLVER_DEFAULT_TOLERANCE
|
32
|
+
|
33
|
+
|
34
|
+
# Custom Exceptions
|
35
|
+
class VariableNotFoundError(KeyError):
|
36
|
+
"""Raised when trying to access a variable that doesn't exist."""
|
37
|
+
|
38
|
+
pass
|
39
|
+
|
40
|
+
|
41
|
+
class EquationValidationError(ValueError):
|
42
|
+
"""Raised when an equation fails validation."""
|
43
|
+
|
44
|
+
pass
|
45
|
+
|
46
|
+
|
47
|
+
class SolverError(RuntimeError):
|
48
|
+
"""Raised when the solving process fails."""
|
49
|
+
|
50
|
+
pass
|
51
|
+
|
52
|
+
|
53
|
+
class ValidationMixin:
|
54
|
+
"""Mixin class providing validation functionality."""
|
55
|
+
|
56
|
+
# These attributes will be provided by other mixins in the final Problem class
|
57
|
+
logger: Any
|
58
|
+
warnings: list[dict[str, Any]]
|
59
|
+
validation_checks: list[Callable]
|
60
|
+
|
61
|
+
def add_validation_check(self, check_function: Callable) -> None:
|
62
|
+
"""Add a validation check function."""
|
63
|
+
self.validation_checks.append(check_function)
|
64
|
+
|
65
|
+
def validate(self) -> list[dict[str, Any]]:
|
66
|
+
"""Run all validation checks and return any warnings."""
|
67
|
+
validation_warnings = []
|
68
|
+
|
69
|
+
for check in self.validation_checks:
|
70
|
+
try:
|
71
|
+
result = check(self)
|
72
|
+
if result:
|
73
|
+
validation_warnings.append(result)
|
74
|
+
except Exception as e:
|
75
|
+
self.logger.debug(f"Validation check failed: {e}")
|
76
|
+
|
77
|
+
return validation_warnings
|
78
|
+
|
79
|
+
def get_warnings(self) -> list[dict[str, Any]]:
|
80
|
+
"""Get all warnings from the problem."""
|
81
|
+
warnings = self.warnings.copy()
|
82
|
+
warnings.extend(self.validate())
|
83
|
+
return warnings
|
84
|
+
|
85
|
+
def _recreate_validation_checks(self):
|
86
|
+
"""Collect and integrate validation checks from class-level Check objects."""
|
87
|
+
# Clear existing checks
|
88
|
+
self.validation_checks = []
|
89
|
+
|
90
|
+
# Collect Check objects from metaclass
|
91
|
+
class_checks = getattr(self.__class__, "_class_checks", {})
|
92
|
+
|
93
|
+
for check in class_checks.values():
|
94
|
+
# Create a validation function from the Check object
|
95
|
+
def make_check_function(check_obj):
|
96
|
+
def check_function(problem_instance):
|
97
|
+
return check_obj.evaluate(problem_instance.variables)
|
98
|
+
|
99
|
+
return check_function
|
100
|
+
|
101
|
+
self.validation_checks.append(make_check_function(check))
|
102
|
+
|
103
|
+
|
104
|
+
class Problem(ValidationMixin):
|
105
|
+
"""
|
106
|
+
Main container class for engineering problems.
|
107
|
+
|
108
|
+
This class coordinates all aspects of engineering problem definition, solving, and analysis.
|
109
|
+
It supports both programmatic problem construction and class-level inheritance patterns
|
110
|
+
for defining domain-specific engineering problems.
|
111
|
+
|
112
|
+
Key Features:
|
113
|
+
- Automatic dependency graph construction and topological solving order
|
114
|
+
- Dual solving approach: SymPy symbolic solving with numerical fallback
|
115
|
+
- Sub-problem composition with automatic variable namespacing
|
116
|
+
- Comprehensive validation and error handling
|
117
|
+
- Professional report generation capabilities
|
118
|
+
|
119
|
+
Usage Patterns:
|
120
|
+
1. Inheritance Pattern (Recommended for domain problems):
|
121
|
+
class MyProblem(Problem):
|
122
|
+
x = Variable("x", Qty(5.0, length))
|
123
|
+
y = Variable("y", Qty(0.0, length), is_known=False)
|
124
|
+
eq = y.equals(x * 2)
|
125
|
+
|
126
|
+
2. Programmatic Pattern (For dynamic problems):
|
127
|
+
problem = Problem("Dynamic Problem")
|
128
|
+
problem.add_variables(x, y)
|
129
|
+
problem.add_equation(y.equals(x * 2))
|
130
|
+
|
131
|
+
3. Composition Pattern (For reusable sub-problems):
|
132
|
+
class ComposedProblem(Problem):
|
133
|
+
sub1 = create_sub_problem()
|
134
|
+
sub2 = create_sub_problem()
|
135
|
+
# Equations can reference sub1.variable, sub2.variable
|
136
|
+
|
137
|
+
Attributes:
|
138
|
+
name (str): Human-readable name for the problem
|
139
|
+
description (str): Detailed description of the problem
|
140
|
+
variables (dict[str, Variable]): All variables in the problem
|
141
|
+
equations (list[Equation]): All equations in the problem
|
142
|
+
is_solved (bool): Whether the problem has been successfully solved
|
143
|
+
solution (dict[str, Variable]): Solved variable values
|
144
|
+
sub_problems (dict[str, Problem]): Integrated sub-problems
|
145
|
+
"""
|
146
|
+
|
147
|
+
def __init__(self, name: str | None = None, description: str = ""):
|
148
|
+
# Handle subclass mode (class-level name/description) vs explicit name
|
149
|
+
self.name = name or getattr(self.__class__, "name", self.__class__.__name__)
|
150
|
+
self.description = description or getattr(self.__class__, "description", "")
|
151
|
+
|
152
|
+
# Core storage
|
153
|
+
self.variables: dict[str, FieldQnty] = {}
|
154
|
+
self.equations: list[Equation] = []
|
155
|
+
|
156
|
+
# Internal systems
|
157
|
+
self.equation_system = EquationSystem()
|
158
|
+
self.dependency_graph = Order()
|
159
|
+
|
160
|
+
# Solving state
|
161
|
+
self.is_solved = False
|
162
|
+
self.solution: dict[str, FieldQnty] = {}
|
163
|
+
self.solving_history: list[dict[str, Any]] = []
|
164
|
+
|
165
|
+
# Performance optimization caches
|
166
|
+
self._known_variables_cache: dict[str, FieldQnty] | None = None
|
167
|
+
self._unknown_variables_cache: dict[str, FieldQnty] | None = None
|
168
|
+
self._cache_dirty = True
|
169
|
+
|
170
|
+
# Validation and warning system
|
171
|
+
self.warnings: list[dict[str, Any]] = []
|
172
|
+
self.validation_checks: list[Callable] = []
|
173
|
+
|
174
|
+
self.logger = get_logger()
|
175
|
+
self.solver_manager = SolverManager(self.logger)
|
176
|
+
|
177
|
+
# Sub-problem composition support
|
178
|
+
self.sub_problems: dict[str, Any] = {}
|
179
|
+
self.variable_aliases: dict[str, str] = {}
|
180
|
+
|
181
|
+
# Initialize equation reconstructor
|
182
|
+
self.equation_reconstructor = None
|
183
|
+
self._init_reconstructor()
|
184
|
+
|
185
|
+
def _init_reconstructor(self):
|
186
|
+
"""Initialize the equation reconstructor."""
|
187
|
+
try:
|
188
|
+
self.equation_reconstructor = EquationReconstructor(self)
|
189
|
+
except Exception as e:
|
190
|
+
self.logger.debug(f"Could not initialize equation reconstructor: {e}")
|
191
|
+
self.equation_reconstructor = None
|
192
|
+
|
193
|
+
# ========== CACHE MANAGEMENT ==========
|
194
|
+
|
195
|
+
def _invalidate_caches(self) -> None:
|
196
|
+
"""Invalidate performance caches when variables change."""
|
197
|
+
self._cache_dirty = True
|
198
|
+
|
199
|
+
def _update_variable_caches(self) -> None:
|
200
|
+
"""Update the variable caches for performance."""
|
201
|
+
if not self._cache_dirty:
|
202
|
+
return
|
203
|
+
|
204
|
+
self._known_variables_cache = {symbol: var for symbol, var in self.variables.items() if var.is_known}
|
205
|
+
self._unknown_variables_cache = {symbol: var for symbol, var in self.variables.items() if not var.is_known}
|
206
|
+
self._cache_dirty = False
|
207
|
+
|
208
|
+
# ========== VARIABLE MANAGEMENT ==========
|
209
|
+
|
210
|
+
def add_variable(self, variable: FieldQnty) -> None:
|
211
|
+
"""
|
212
|
+
Add a variable to the problem.
|
213
|
+
|
214
|
+
The variable will be available for use in equations and can be accessed
|
215
|
+
via both dictionary notation (problem['symbol']) and attribute notation
|
216
|
+
(problem.symbol).
|
217
|
+
|
218
|
+
Args:
|
219
|
+
variable: Variable object to add to the problem
|
220
|
+
|
221
|
+
Note:
|
222
|
+
If a variable with the same symbol already exists, it will be replaced
|
223
|
+
and a warning will be logged.
|
224
|
+
"""
|
225
|
+
if variable.symbol in self.variables:
|
226
|
+
self.logger.warning(f"Variable {variable.symbol} already exists. Replacing.")
|
227
|
+
|
228
|
+
if variable.symbol is not None:
|
229
|
+
self.variables[variable.symbol] = variable
|
230
|
+
# Set parent problem reference for dependency invalidation
|
231
|
+
if hasattr(variable, "_parent_problem"):
|
232
|
+
setattr(variable, "_parent_problem", self)
|
233
|
+
# Also set as instance attribute for dot notation access
|
234
|
+
if variable.symbol is not None:
|
235
|
+
setattr(self, variable.symbol, variable)
|
236
|
+
self.is_solved = False
|
237
|
+
self._invalidate_caches()
|
238
|
+
|
239
|
+
def add_variables(self, *variables: FieldQnty) -> None:
|
240
|
+
"""Add multiple variables to the problem."""
|
241
|
+
for var in variables:
|
242
|
+
self.add_variable(var)
|
243
|
+
|
244
|
+
def get_variable(self, symbol: str) -> FieldQnty:
|
245
|
+
"""Get a variable by its symbol."""
|
246
|
+
if symbol not in self.variables:
|
247
|
+
raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'.")
|
248
|
+
return self.variables[symbol]
|
249
|
+
|
250
|
+
def get_known_variables(self) -> dict[str, FieldQnty]:
|
251
|
+
"""Get all known variables."""
|
252
|
+
if self._cache_dirty or self._known_variables_cache is None:
|
253
|
+
self._update_variable_caches()
|
254
|
+
return self._known_variables_cache.copy() if self._known_variables_cache else {}
|
255
|
+
|
256
|
+
def get_unknown_variables(self) -> dict[str, FieldQnty]:
|
257
|
+
"""Get all unknown variables."""
|
258
|
+
if self._cache_dirty or self._unknown_variables_cache is None:
|
259
|
+
self._update_variable_caches()
|
260
|
+
return self._unknown_variables_cache.copy() if self._unknown_variables_cache else {}
|
261
|
+
|
262
|
+
def get_known_symbols(self) -> set[str]:
|
263
|
+
"""Get symbols of all known variables."""
|
264
|
+
return {symbol for symbol, var in self.variables.items() if var.is_known}
|
265
|
+
|
266
|
+
def get_unknown_symbols(self) -> set[str]:
|
267
|
+
"""Get symbols of all unknown variables."""
|
268
|
+
return {symbol for symbol, var in self.variables.items() if not var.is_known}
|
269
|
+
|
270
|
+
def get_known_variable_symbols(self) -> set[str]:
|
271
|
+
"""Alias for get_known_symbols for compatibility."""
|
272
|
+
return self.get_known_symbols()
|
273
|
+
|
274
|
+
def get_unknown_variable_symbols(self) -> set[str]:
|
275
|
+
"""Alias for get_unknown_symbols for compatibility."""
|
276
|
+
return self.get_unknown_symbols()
|
277
|
+
|
278
|
+
# Properties for compatibility
|
279
|
+
@property
|
280
|
+
def known_variables(self) -> dict[str, FieldQnty]:
|
281
|
+
"""Get all variables marked as known."""
|
282
|
+
return self.get_known_variables()
|
283
|
+
|
284
|
+
@property
|
285
|
+
def unknown_variables(self) -> dict[str, FieldQnty]:
|
286
|
+
"""Get all variables marked as unknown."""
|
287
|
+
return self.get_unknown_variables()
|
288
|
+
|
289
|
+
def mark_unknown(self, *symbols: str):
|
290
|
+
"""Mark variables as unknown (to be solved for)."""
|
291
|
+
for symbol in symbols:
|
292
|
+
if symbol in self.variables:
|
293
|
+
self.variables[symbol].mark_unknown()
|
294
|
+
else:
|
295
|
+
raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'")
|
296
|
+
self.is_solved = False
|
297
|
+
self._invalidate_caches()
|
298
|
+
return self
|
299
|
+
|
300
|
+
def mark_known(self, **symbol_values: Quantity):
|
301
|
+
"""Mark variables as known and set their values."""
|
302
|
+
for symbol, quantity in symbol_values.items():
|
303
|
+
if symbol in self.variables:
|
304
|
+
# Set the quantity first, then mark as known
|
305
|
+
self.variables[symbol].quantity = quantity
|
306
|
+
self.variables[symbol].mark_known()
|
307
|
+
else:
|
308
|
+
raise VariableNotFoundError(f"Variable '{symbol}' not found in problem '{self.name}'")
|
309
|
+
self.is_solved = False
|
310
|
+
self._invalidate_caches()
|
311
|
+
return self
|
312
|
+
|
313
|
+
def invalidate_dependents(self, changed_variable_symbol: str) -> None:
|
314
|
+
"""
|
315
|
+
Mark all variables that depend on the changed variable as unknown.
|
316
|
+
This ensures they get recalculated when the problem is re-solved.
|
317
|
+
|
318
|
+
Args:
|
319
|
+
changed_variable_symbol: Symbol of the variable whose value changed
|
320
|
+
"""
|
321
|
+
if not hasattr(self, "dependency_graph") or not self.dependency_graph:
|
322
|
+
# If dependency graph hasn't been built yet, we can't invalidate
|
323
|
+
return
|
324
|
+
|
325
|
+
# Get all variables that depend on the changed variable
|
326
|
+
dependent_vars = self.dependency_graph.graph.get(changed_variable_symbol, [])
|
327
|
+
|
328
|
+
# Mark each dependent variable as unknown
|
329
|
+
for dependent_symbol in dependent_vars:
|
330
|
+
if dependent_symbol in self.variables:
|
331
|
+
var = self.variables[dependent_symbol]
|
332
|
+
# Only mark as unknown if it was previously solved (known)
|
333
|
+
if var.is_known:
|
334
|
+
var.mark_unknown()
|
335
|
+
# Recursively invalidate variables that depend on this one
|
336
|
+
self.invalidate_dependents(dependent_symbol)
|
337
|
+
|
338
|
+
# Mark problem as needing re-solving
|
339
|
+
self.is_solved = False
|
340
|
+
self._invalidate_caches()
|
341
|
+
|
342
|
+
def _create_placeholder_variable(self, symbol: str) -> None:
|
343
|
+
"""Create a placeholder variable for a missing symbol."""
|
344
|
+
placeholder_var = FieldQnty(name=f"Auto-created: {symbol}", expected_dimension=DimensionlessUnits.dimensionless.dimension, is_known=False)
|
345
|
+
placeholder_var.symbol = symbol
|
346
|
+
placeholder_var.quantity = Quantity(0.0, DimensionlessUnits.dimensionless)
|
347
|
+
self.add_variable(placeholder_var)
|
348
|
+
self.logger.debug(f"Auto-created placeholder variable: {symbol}")
|
349
|
+
|
350
|
+
def _clone_variable(self, variable: FieldQnty) -> FieldQnty:
|
351
|
+
"""Create a copy of a variable to avoid shared state without corrupting global units."""
|
352
|
+
# Create a new variable of the same exact type to preserve .equals() method
|
353
|
+
# This ensures domain-specific variables (Length, Pressure, etc.) keep their type
|
354
|
+
variable_type = type(variable)
|
355
|
+
|
356
|
+
# Use __new__ to avoid constructor parameter issues
|
357
|
+
cloned = variable_type.__new__(variable_type)
|
358
|
+
|
359
|
+
# Initialize manually with the same attributes as the original
|
360
|
+
cloned.name = variable.name
|
361
|
+
cloned.symbol = variable.symbol
|
362
|
+
cloned.expected_dimension = variable.expected_dimension
|
363
|
+
cloned.quantity = variable.quantity # Keep reference to same quantity - units must not be copied
|
364
|
+
cloned.is_known = variable.is_known
|
365
|
+
|
366
|
+
# Ensure the cloned variable has fresh validation checks
|
367
|
+
if hasattr(variable, "validation_checks") and hasattr(cloned, "validation_checks"):
|
368
|
+
cloned.validation_checks = []
|
369
|
+
return cloned
|
370
|
+
|
371
|
+
def _sync_variables_to_instance_attributes(self):
|
372
|
+
"""
|
373
|
+
Sync variable objects to instance attributes after solving.
|
374
|
+
This ensures that self.P refers to the same Variable object that's in self.variables.
|
375
|
+
Variables maintain their original dimensional types (e.g., AreaVariable, PressureVariable).
|
376
|
+
"""
|
377
|
+
for var_symbol, var in self.variables.items():
|
378
|
+
# Update instance attribute if it exists
|
379
|
+
if hasattr(self, var_symbol):
|
380
|
+
# Variables preserve their dimensional types during solving
|
381
|
+
setattr(self, var_symbol, var)
|
382
|
+
|
383
|
+
# Also update sub-problem namespace objects
|
384
|
+
for namespace, sub_problem in self.sub_problems.items():
|
385
|
+
if hasattr(self, namespace):
|
386
|
+
namespace_obj = getattr(self, namespace)
|
387
|
+
for var_symbol in sub_problem.variables:
|
388
|
+
namespaced_symbol = f"{namespace}_{var_symbol}"
|
389
|
+
if namespaced_symbol in self.variables and hasattr(namespace_obj, var_symbol):
|
390
|
+
setattr(namespace_obj, var_symbol, self.variables[namespaced_symbol])
|
391
|
+
|
392
|
+
# ========== EQUATION MANAGEMENT ==========
|
393
|
+
|
394
|
+
def add_equation(self, equation: Equation) -> None:
|
395
|
+
"""
|
396
|
+
Add an equation to the problem.
|
397
|
+
|
398
|
+
The equation will be validated to ensure all referenced variables exist.
|
399
|
+
Missing variables that look like simple identifiers will be auto-created
|
400
|
+
as unknown placeholders.
|
401
|
+
|
402
|
+
Args:
|
403
|
+
equation: Equation object to add to the problem
|
404
|
+
|
405
|
+
Raises:
|
406
|
+
EquationValidationError: If the equation is invalid or cannot be processed
|
407
|
+
|
408
|
+
Note:
|
409
|
+
Adding an equation resets the problem to unsolved state.
|
410
|
+
"""
|
411
|
+
if equation is None:
|
412
|
+
raise EquationValidationError("Cannot add None equation to problem")
|
413
|
+
|
414
|
+
# Fix VariableReferences in equation to point to correct Variables
|
415
|
+
equation = self._fix_variable_references(equation)
|
416
|
+
|
417
|
+
# Validate that all variables in the equation exist
|
418
|
+
try:
|
419
|
+
equation_vars = equation.get_all_variables()
|
420
|
+
except Exception as e:
|
421
|
+
raise EquationValidationError(f"Failed to extract variables from equation: {e}") from e
|
422
|
+
|
423
|
+
missing_vars = [var for var in equation_vars if var not in self.variables]
|
424
|
+
|
425
|
+
if missing_vars:
|
426
|
+
self._handle_missing_variables(missing_vars)
|
427
|
+
|
428
|
+
# Check again for remaining missing variables
|
429
|
+
equation_vars = equation.get_all_variables()
|
430
|
+
remaining_missing = [var for var in equation_vars if var not in self.variables]
|
431
|
+
if remaining_missing:
|
432
|
+
self.logger.warning(f"Equation references missing variables: {remaining_missing}")
|
433
|
+
|
434
|
+
self.equations.append(equation)
|
435
|
+
self.equation_system.add_equation(equation)
|
436
|
+
self.is_solved = False
|
437
|
+
|
438
|
+
def add_equations(self, *equations: Equation):
|
439
|
+
"""Add multiple equations to the problem."""
|
440
|
+
for eq in equations:
|
441
|
+
self.add_equation(eq)
|
442
|
+
return self
|
443
|
+
|
444
|
+
def _handle_missing_variables(self, missing_vars: list[str]) -> None:
|
445
|
+
"""Handle missing variables by creating placeholders for simple symbols."""
|
446
|
+
for missing_var in missing_vars:
|
447
|
+
if self._is_simple_variable_symbol(missing_var):
|
448
|
+
self._create_placeholder_variable(missing_var)
|
449
|
+
|
450
|
+
def _is_simple_variable_symbol(self, symbol: str) -> bool:
|
451
|
+
"""Check if a symbol looks like a simple variable identifier."""
|
452
|
+
return symbol.isidentifier() and not any(char in symbol for char in ["(", ")", "+", "-", "*", "/", " "])
|
453
|
+
|
454
|
+
def _fix_variable_references(self, equation: Equation) -> Equation:
|
455
|
+
"""
|
456
|
+
Fix VariableReferences in equation expressions to point to Variables in problem.variables.
|
457
|
+
|
458
|
+
This resolves issues where expression trees contain VariableReferences pointing to
|
459
|
+
proxy Variables from class creation time instead of the actual Variables in the problem.
|
460
|
+
"""
|
461
|
+
try:
|
462
|
+
# Fix the RHS expression
|
463
|
+
fixed_rhs = self._fix_expression_variables(equation.rhs)
|
464
|
+
|
465
|
+
# Create new equation with fixed RHS (LHS should already be correct)
|
466
|
+
return Equation(equation.name, equation.lhs, fixed_rhs)
|
467
|
+
|
468
|
+
except Exception as e:
|
469
|
+
self.logger.debug(f"Error fixing variable references in equation {equation.name}: {e}")
|
470
|
+
return equation # Return original if fixing fails
|
471
|
+
|
472
|
+
def _fix_expression_variables(self, expr):
|
473
|
+
"""
|
474
|
+
Recursively fix VariableReferences in an expression tree to point to correct Variables.
|
475
|
+
"""
|
476
|
+
|
477
|
+
if isinstance(expr, VariableReference):
|
478
|
+
# Check if this VariableReference points to the wrong Variable
|
479
|
+
symbol = getattr(expr, "symbol", None)
|
480
|
+
if symbol and symbol in self.variables:
|
481
|
+
correct_var = self.variables[symbol]
|
482
|
+
if expr.variable is not correct_var:
|
483
|
+
# Create new VariableReference pointing to correct Variable
|
484
|
+
return VariableReference(correct_var)
|
485
|
+
return expr
|
486
|
+
|
487
|
+
elif isinstance(expr, BinaryOperation):
|
488
|
+
# Recursively fix left and right operands
|
489
|
+
fixed_left = self._fix_expression_variables(expr.left)
|
490
|
+
fixed_right = self._fix_expression_variables(expr.right)
|
491
|
+
return BinaryOperation(expr.operator, fixed_left, fixed_right)
|
492
|
+
|
493
|
+
elif hasattr(expr, "operand"):
|
494
|
+
# Recursively fix operand
|
495
|
+
fixed_operand = self._fix_expression_variables(expr.operand)
|
496
|
+
return type(expr)(expr.operator, fixed_operand)
|
497
|
+
|
498
|
+
elif hasattr(expr, "function_name"):
|
499
|
+
# Recursively fix left and right operands
|
500
|
+
fixed_left = self._fix_expression_variables(expr.left)
|
501
|
+
fixed_right = self._fix_expression_variables(expr.right)
|
502
|
+
return type(expr)(expr.function_name, fixed_left, fixed_right)
|
503
|
+
|
504
|
+
elif isinstance(expr, Constant):
|
505
|
+
return expr
|
506
|
+
|
507
|
+
else:
|
508
|
+
# Unknown expression type, return as-is
|
509
|
+
return expr
|
510
|
+
|
511
|
+
# ========== SOLVING ==========
|
512
|
+
|
513
|
+
def solve(self, max_iterations: int = MAX_ITERATIONS_DEFAULT, tolerance: float = TOLERANCE_DEFAULT) -> dict[str, Any]:
|
514
|
+
"""
|
515
|
+
Solve the engineering problem by finding values for all unknown variables.
|
516
|
+
|
517
|
+
This method orchestrates the complete solving process:
|
518
|
+
1. Builds dependency graph from equations
|
519
|
+
2. Determines optimal solving order using topological sorting
|
520
|
+
3. Solves equations iteratively using symbolic/numerical methods
|
521
|
+
4. Verifies solution against all equations
|
522
|
+
5. Updates variable states and synchronizes instance attributes
|
523
|
+
|
524
|
+
Args:
|
525
|
+
max_iterations: Maximum number of solving iterations (default: 100)
|
526
|
+
tolerance: Numerical tolerance for convergence (default: SOLVER_DEFAULT_TOLERANCE)
|
527
|
+
|
528
|
+
Returns:
|
529
|
+
dict mapping variable symbols to solved Variable objects
|
530
|
+
|
531
|
+
Raises:
|
532
|
+
SolverError: If solving fails or times out
|
533
|
+
|
534
|
+
Example:
|
535
|
+
>>> problem = MyEngineeringProblem()
|
536
|
+
>>> solution = problem.solve()
|
537
|
+
>>> print(f"Force = {solution['F'].quantity}")
|
538
|
+
"""
|
539
|
+
self.logger.info(f"Solving problem: {self.name}")
|
540
|
+
|
541
|
+
try:
|
542
|
+
# Clear previous solution
|
543
|
+
self.solution = {}
|
544
|
+
self.is_solved = False
|
545
|
+
self.solving_history = []
|
546
|
+
|
547
|
+
# Build dependency graph
|
548
|
+
self._build_dependency_graph()
|
549
|
+
|
550
|
+
# Use solver manager to solve the system
|
551
|
+
solve_result = self.solver_manager.solve(self.equations, self.variables, self.dependency_graph, max_iterations, tolerance)
|
552
|
+
|
553
|
+
if solve_result.success:
|
554
|
+
# Update variables with the result
|
555
|
+
self.variables = solve_result.variables
|
556
|
+
self.solving_history.extend(solve_result.steps)
|
557
|
+
|
558
|
+
# Sync solved values back to instance attributes
|
559
|
+
self._sync_variables_to_instance_attributes()
|
560
|
+
|
561
|
+
# Verify solution
|
562
|
+
self.solution = self.variables
|
563
|
+
verification_passed = self.verify_solution()
|
564
|
+
|
565
|
+
# Mark as solved based on solver result and verification
|
566
|
+
if verification_passed:
|
567
|
+
self.is_solved = True
|
568
|
+
self.logger.info("Solution verified successfully")
|
569
|
+
return self.solution
|
570
|
+
else:
|
571
|
+
self.logger.warning("Solution verification failed")
|
572
|
+
return self.solution
|
573
|
+
else:
|
574
|
+
raise SolverError(f"Solving failed: {solve_result.message}")
|
575
|
+
|
576
|
+
except SolverError:
|
577
|
+
raise
|
578
|
+
except Exception as e:
|
579
|
+
self.logger.error(f"Solving failed: {e}")
|
580
|
+
raise SolverError(f"Unexpected error during solving: {e}") from e
|
581
|
+
|
582
|
+
def _build_dependency_graph(self):
|
583
|
+
"""Build the dependency graph for solving order determination."""
|
584
|
+
# Reset the dependency graph
|
585
|
+
self.dependency_graph = Order()
|
586
|
+
|
587
|
+
# Get known variables
|
588
|
+
known_vars = self.get_known_symbols()
|
589
|
+
|
590
|
+
# Add dependencies from equations
|
591
|
+
for equation in self.equations:
|
592
|
+
self.dependency_graph.add_equation(equation, known_vars)
|
593
|
+
|
594
|
+
def verify_solution(self, tolerance: float = SOLVER_DEFAULT_TOLERANCE) -> bool:
|
595
|
+
"""Verify that all equations are satisfied."""
|
596
|
+
if not self.equations:
|
597
|
+
return True
|
598
|
+
|
599
|
+
try:
|
600
|
+
for equation in self.equations:
|
601
|
+
if not equation.check_residual(self.variables, tolerance):
|
602
|
+
self.logger.debug(f"Equation verification failed: {equation}")
|
603
|
+
return False
|
604
|
+
return True
|
605
|
+
except Exception as e:
|
606
|
+
self.logger.debug(f"Solution verification error: {e}")
|
607
|
+
return False
|
608
|
+
|
609
|
+
def analyze_system(self) -> dict[str, Any]:
|
610
|
+
"""Analyze the equation system for solvability, cycles, etc."""
|
611
|
+
try:
|
612
|
+
self._build_dependency_graph()
|
613
|
+
known_vars = self.get_known_symbols()
|
614
|
+
analysis = self.dependency_graph.analyze_system(known_vars)
|
615
|
+
|
616
|
+
# Add some additional info
|
617
|
+
analysis["total_equations"] = len(self.equations)
|
618
|
+
analysis["is_determined"] = len(self.get_unknown_variables()) <= len(self.equations)
|
619
|
+
|
620
|
+
return analysis
|
621
|
+
except Exception as e:
|
622
|
+
self.logger.debug(f"Dependency analysis failed: {e}")
|
623
|
+
# Return basic analysis on failure
|
624
|
+
return {
|
625
|
+
"total_variables": len(self.variables),
|
626
|
+
"known_variables": len(self.get_known_variables()),
|
627
|
+
"unknown_variables": len(self.get_unknown_variables()),
|
628
|
+
"total_equations": len(self.equations),
|
629
|
+
"is_determined": len(self.get_unknown_variables()) <= len(self.equations),
|
630
|
+
"has_cycles": False,
|
631
|
+
"solving_order": [],
|
632
|
+
"can_solve_completely": False,
|
633
|
+
"unsolvable_variables": [],
|
634
|
+
}
|
635
|
+
|
636
|
+
# ========== UTILITY METHODS ==========
|
637
|
+
|
638
|
+
def reset_solution(self):
|
639
|
+
"""Reset the problem to unsolved state."""
|
640
|
+
self.is_solved = False
|
641
|
+
self.solution = {}
|
642
|
+
self.solving_history = []
|
643
|
+
|
644
|
+
# Reset unknown variables to unknown state
|
645
|
+
for var in self.variables.values():
|
646
|
+
if not var.is_known:
|
647
|
+
var.is_known = False
|
648
|
+
|
649
|
+
def copy(self):
|
650
|
+
"""Create a copy of this problem."""
|
651
|
+
return deepcopy(self)
|
652
|
+
|
653
|
+
def __str__(self) -> str:
|
654
|
+
"""String representation of the problem."""
|
655
|
+
status = "SOLVED" if self.is_solved else "UNSOLVED"
|
656
|
+
return f"EngineeringProblem('{self.name}', vars={len(self.variables)}, eqs={len(self.equations)}, {status})"
|
657
|
+
|
658
|
+
def __repr__(self) -> str:
|
659
|
+
"""Detailed representation of the problem."""
|
660
|
+
return self.__str__()
|
661
|
+
|
662
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
663
|
+
"""Custom attribute setting to maintain variable synchronization."""
|
664
|
+
# During initialization, use normal attribute setting
|
665
|
+
if not hasattr(self, "variables") or name.startswith("_"):
|
666
|
+
super().__setattr__(name, value)
|
667
|
+
return
|
668
|
+
|
669
|
+
# If setting a variable that exists in our variables dict, update both
|
670
|
+
if isinstance(value, FieldQnty) and hasattr(self, "variables") and name in self.variables:
|
671
|
+
self.variables[name] = value
|
672
|
+
|
673
|
+
super().__setattr__(name, value)
|
674
|
+
|
675
|
+
def __getitem__(self, key: str):
|
676
|
+
"""Allow dict-like access to variables."""
|
677
|
+
return self.get_variable(key)
|
678
|
+
|
679
|
+
def __setitem__(self, key: str, value) -> None:
|
680
|
+
"""Allow dict-like assignment of variables."""
|
681
|
+
if isinstance(value, FieldQnty):
|
682
|
+
# Update the symbol to match the key if they differ
|
683
|
+
if value.symbol != key:
|
684
|
+
value.symbol = key
|
685
|
+
self.add_variable(value)
|
686
|
+
|
687
|
+
# ========== CLASS-LEVEL EXTRACTION ==========
|
688
|
+
# Note: _extract_from_class_variables() is provided by CompositionMixin in the full Problem class
|
689
|
+
|
690
|
+
|
691
|
+
# Alias for backward compatibility
|
692
|
+
EngineeringProblem = Problem
|
693
|
+
|
694
|
+
# Export all relevant classes and exceptions
|
695
|
+
__all__ = ["Problem", "EngineeringProblem", "VariableNotFoundError", "EquationValidationError", "SolverError", "ValidationMixin"]
|