uncertainty-tools 0.0.1__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.
unc_tools/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Public package interface for uncertainty-aware tools."""
2
+
3
+ from __future__ import annotations
4
+ from importlib.metadata import PackageNotFoundError, version
5
+
6
+ from .default_functions import FunctionBase1D, Hyper, Poly
7
+ from .exceptions import DataError, ExpressionError, InitialGuessError, ModelTypeError
8
+ from .unc_regression import UncRegression
9
+
10
+
11
+ try:
12
+ __version__ = version("uncertainty_tools")
13
+ except PackageNotFoundError:
14
+ __version__ = "unknown"
15
+
16
+ __all__ = [
17
+ "DataError",
18
+ "ExpressionError",
19
+ "FunctionBase1D",
20
+ "Hyper",
21
+ "InitialGuessError",
22
+ "ModelTypeError",
23
+ "Poly",
24
+ "UncRegression",
25
+ "__version__",
26
+ ]
@@ -0,0 +1,516 @@
1
+ """Symbolic helper utilities with uncertainty-aware coefficients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import cached_property
6
+ from typing import Callable, Sequence
7
+
8
+ import numpy as np
9
+ import re
10
+ import sympy as sym
11
+ import uncertainties as unc
12
+
13
+ from .exceptions import ExpressionError
14
+
15
+ __all__ = ["FunctionBase1D", "Hyper", "Poly"]
16
+
17
+ Uncertain = unc.core.AffineScalarFunc | unc.core.Variable
18
+ Numeric = float | int | np.number
19
+ Coefficient = sym.Expr | Numeric | Uncertain
20
+ CoefficientsInput = Sequence[Coefficient] | np.ndarray
21
+ Solution = sym.Expr | Uncertain | tuple[Uncertain, Uncertain]
22
+ LambdaReturn = float | complex | np.ndarray | Uncertain
23
+ InputValue = Numeric | np.ndarray | Uncertain
24
+
25
+
26
+ class FunctionBase1D:
27
+ """Represent a one-dimensional symbolic expression with optional uncertainty data."""
28
+
29
+ def __init__(self, expr_str: str = "") -> None:
30
+ """Initialize the expression and extract coefficient symbols.
31
+
32
+ Args:
33
+ expr_str: String representation of the sympy expression to manage.
34
+
35
+ Raises:
36
+ sym.SympifyError: If the expression string cannot be parsed.
37
+ """
38
+ self.expr_str = expr_str
39
+ self.expr_unc: str = ""
40
+ self.expr_sym = sym.parse_expr(expr_str)
41
+ self.args = self.ordered_symbols(expr_str)
42
+
43
+ self._added_coefs = False
44
+ self._unc_coefs = False
45
+ self._complex = False
46
+ self._show_complex = False
47
+
48
+ return None
49
+
50
+ @staticmethod
51
+ def ordered_symbols(expr_str: str) -> list[sym.Symbol]:
52
+ """Return sympy symbols in the order they appear in the expression string.
53
+
54
+ Args:
55
+ expr_str: Expression string to scan.
56
+
57
+ Returns:
58
+ A list of symbols ordered by their first appearance in the expression.
59
+ """
60
+ expr = sym.sympify(expr_str)
61
+
62
+ symbols = expr.free_symbols
63
+
64
+ name_to_symbol = {str(s): s for s in symbols}
65
+
66
+ ordered = []
67
+ used = set("x")
68
+
69
+ i = 0
70
+ n = len(expr_str)
71
+
72
+ while i < n:
73
+ for name, symb in name_to_symbol.items():
74
+ if expr_str.startswith(name, i):
75
+ if name not in used:
76
+ ordered.append(symb)
77
+ used.add(name)
78
+ i += len(name)
79
+ break
80
+ else:
81
+ i += 1
82
+
83
+ return ordered
84
+
85
+ @cached_property
86
+ def coefs(self) -> list[Coefficient]:
87
+ """Return coefficient placeholders or bound values.
88
+
89
+ Before coefficients are bound, this list contains sympy symbols in a stable
90
+ order. After calling `add_coefs`, it contains the supplied coefficient values.
91
+
92
+ Returns:
93
+ A list of coefficient symbols or values.
94
+ """
95
+ return self.args
96
+
97
+ @cached_property
98
+ def sols(self) -> list[Solution]:
99
+ """Solve the expression set equal to zero.
100
+
101
+ Returns analytical solutions, optionally excluding complex values unless
102
+ complex display is enabled.
103
+
104
+ Returns:
105
+ A list of solutions for the equation ``expr_sym = 0``.
106
+ """
107
+ # try:
108
+ # solution = sym.solvers.solve(self.expr_sym - 0, sym.Symbol("x"))
109
+ #
110
+ # # if not solution:
111
+ # # raise TypeError("Cant find analytical solution for given expression")
112
+ #
113
+ # if self._show_complex:
114
+ # return solution
115
+ # else:
116
+ # solution = [x for x in solution if not x.is_imaginary]
117
+ # return solution
118
+ # except:
119
+ # return []
120
+ #
121
+ solution = sym.solvers.solve(self.expr_sym - 0, sym.Symbol("x"))
122
+
123
+ # if not solution:
124
+ # raise TypeError("Cant find analytical solution for given expression")
125
+
126
+ if self._show_complex:
127
+ return solution
128
+ else:
129
+ solution = [x for x in solution if not x.is_imaginary]
130
+ return solution
131
+
132
+ def find_sols(
133
+ self, y: int | float | sym.Expr | str | Uncertain = 0
134
+ ) -> list[Solution] | Solution:
135
+ """Solve the expression for a specified target value.
136
+
137
+ Args:
138
+ y: Target value for ``expr_sym = y``. Strings are parsed as sympy expressions.
139
+
140
+ Returns:
141
+ A list of solutions or a single solution when only one exists.
142
+ """
143
+ if isinstance(y, (unc.core.Variable, unc.core.AffineScalarFunc)):
144
+ new_expr = FunctionBase1D(self.expr_str + " - NEW_1")
145
+
146
+ if isinstance(self.coefs, np.ndarray):
147
+ self.coefs = self.coefs.tolist()
148
+ # print(type(self.coefs))
149
+ print(y)
150
+ list_coefs = self.coefs + [y]
151
+ # print(new_expr.coefs)
152
+ # print(list_coefs)
153
+ if self._added_coefs:
154
+ new_expr.add_coefs(list_coefs)
155
+
156
+ else:
157
+ if isinstance(y, str):
158
+ y = sym.parse_expr(y)
159
+ new_expr = FunctionBase1D(self.expr_str)
160
+ new_expr.expr_sym -= y
161
+ if self._added_coefs:
162
+ new_expr.add_coefs(self.coefs)
163
+
164
+ if len(new_expr.sols) > 1 and hasattr(new_expr.sols, "__iter__"):
165
+ return new_expr.sols
166
+ elif len(new_expr.sols) == 0:
167
+ return []
168
+ else:
169
+ return new_expr.sols[0]
170
+
171
+ @cached_property
172
+ def lambda_fun(self) -> Callable[..., LambdaReturn]:
173
+ """Generate a numpy-evaluable callable for the expression.
174
+
175
+ Returns:
176
+ A callable accepting ``x`` followed by coefficient values.
177
+ """
178
+ lambda_args = [sym.Symbol("x")] + self.args
179
+ return sym.lambdify(lambda_args, self.expr_sym, "numpy")
180
+
181
+ def show_complex(self, show: bool = False) -> "FunctionBase1D":
182
+ """Toggle the display of complex solutions.
183
+
184
+ Args:
185
+ show: Whether to include complex-valued solutions.
186
+
187
+ Returns:
188
+ The current instance with updated display preference.
189
+ """
190
+ self._show_complex = show
191
+
192
+ return self
193
+
194
+ @staticmethod
195
+ def _calculate_uncertainty_analyticaly(
196
+ expr: sym.Expr,
197
+ parms: Sequence[sym.Symbol] | None = None,
198
+ ) -> sym.Expr:
199
+ """Compute propagated uncertainty for a symbolic expression.
200
+
201
+ Args:
202
+ expr: Expression whose uncertainty is estimated.
203
+ parms: Parameters treated as uncertain; defaults to free symbols.
204
+
205
+ Returns:
206
+ A symbolic expression representing the combined uncertainty.
207
+ """
208
+ if parms is None:
209
+ parms = expr.free_symbols
210
+
211
+ unc_list: list[sym.Expr] = []
212
+ for parm in parms:
213
+ unc_list.append(
214
+ (sym.diff(expr, parm) * sym.Symbol(f"Delta_{str(parm)}")) ** 2
215
+ )
216
+
217
+ return sym.sqrt(np.sum(unc_list))
218
+
219
+ def add_coefs(self, coefs: CoefficientsInput) -> "FunctionBase1D":
220
+ """Apply coefficients to the expression and rebuild cached artifacts.
221
+
222
+ Args:
223
+ coefs: Coefficient values matching the detected symbols.
224
+
225
+ Returns:
226
+ The current instance with coefficients applied.
227
+
228
+ Raises:
229
+ ExpressionError: If the number of coefficients differs from expectations.
230
+ """
231
+ if len(coefs) != len(self.coefs):
232
+ raise ExpressionError(
233
+ "Number of arguments does not match the number of coefficients."
234
+ )
235
+
236
+ self.coefs = coefs
237
+
238
+ if any(
239
+ [
240
+ isinstance(x, (unc.core.Variable, unc.core.AffineScalarFunc))
241
+ for x in coefs
242
+ ]
243
+ ):
244
+ self._unc_coefs = True
245
+
246
+ coefs_dict = {}
247
+ for i in range(len(coefs)):
248
+ coefs_dict[self.args[i]] = coefs[i]
249
+
250
+ if self._unc_coefs:
251
+ # Separating nominal values and standard deviations.
252
+ nominal_coefs_dict = {}
253
+ std_coefs_dict = {}
254
+ for key in coefs_dict:
255
+ delta = sym.Symbol(f"Delta_{str(key)}")
256
+ if isinstance(
257
+ coefs_dict[key], (unc.core.Variable, unc.core.AffineScalarFunc)
258
+ ):
259
+ nominal_coefs_dict[key] = coefs_dict[key].nominal_value
260
+ std_coefs_dict[delta] = coefs_dict[key].std_dev
261
+ else:
262
+ nominal_coefs_dict[key] = coefs_dict[key]
263
+ std_coefs_dict[delta] = 0
264
+
265
+ # making solutions
266
+ sols_list = []
267
+ for sol in self.sols:
268
+ # print(sol)
269
+ nominal = sol.subs(nominal_coefs_dict)
270
+ # print(nominal)
271
+ if nominal.is_complex and not nominal.is_real:
272
+ self._complex = True
273
+
274
+ if self._show_complex:
275
+ std = self._calculate_uncertainty_analyticaly(sol).subs(
276
+ {**nominal_coefs_dict, **std_coefs_dict}
277
+ )
278
+ real_part = unc.ufloat(sym.re(nominal), abs(sym.re(std)))
279
+ im_part = unc.ufloat(sym.im(nominal), abs(sym.im(std)))
280
+ sols_list.append((real_part, im_part))
281
+
282
+ else:
283
+ std = self._calculate_uncertainty_analyticaly(sol).subs(
284
+ {**nominal_coefs_dict, **std_coefs_dict}
285
+ )
286
+ sols_list.append(unc.ufloat(nominal, std))
287
+
288
+ self.sols = sols_list
289
+
290
+ # making lambda function
291
+ def wrap_lambdify(
292
+ func: Callable[..., LambdaReturn], *coefs: Coefficient
293
+ ) -> Callable[[InputValue], LambdaReturn]:
294
+ def new_lambda(x: InputValue) -> LambdaReturn:
295
+ return func(x, *coefs)
296
+
297
+ return new_lambda
298
+
299
+ self.lambda_fun = wrap_lambdify(
300
+ sym.lambdify([sym.Symbol("x")] + self.args, self.expr_sym, "numpy"),
301
+ *coefs,
302
+ )
303
+
304
+ # making str expr
305
+ self.expr_unc = self.expr_str
306
+ for key in coefs_dict:
307
+ if isinstance(coefs_dict[key], unc.core.Variable):
308
+ replacement = f"unc.ufloat({coefs_dict[key].nominal_value},{coefs_dict[key].std_dev})"
309
+ else:
310
+ replacement = str(coefs_dict[key])
311
+
312
+ self.expr_unc = str(self.expr_unc).replace(str(key), replacement)
313
+
314
+ self._added_coefs = True
315
+
316
+ else:
317
+ self.expr_sym = self.expr_sym.subs(coefs_dict)
318
+ self.lambda_fun = sym.lambdify(sym.Symbol("x"), self.expr_sym, "numpy")
319
+ self._added_coefs = True
320
+
321
+ sols = self.sols
322
+ self.sols = [x.subs(coefs_dict) for x in sols]
323
+ if any([x.is_complex for x in self.sols]):
324
+ self._complex = True
325
+
326
+ return self
327
+
328
+ def to_latex_expr(self) -> str:
329
+ """Convert the expression (with coefficients) to a LaTeX string.
330
+
331
+ Returns:
332
+ LaTeX-formatted expression string.
333
+ """
334
+ latex = sym.latex(self.expr_sym)
335
+
336
+ if self._added_coefs:
337
+ for i in range(len(self.coefs)):
338
+ if isinstance(self.coefs[i], unc.core.Variable):
339
+ replacement = f"({self.coefs[i]})"
340
+ else:
341
+ replacement = f"{self.coefs[i]}"
342
+
343
+ latex = latex.replace(sym.latex(self.args[i]), replacement)
344
+
345
+ latex = latex.replace("+/-", r" \pm ")
346
+ latex = re.sub(
347
+ r"e([+-])(\d+)",
348
+ lambda m: rf"\cdot 10^{{{m.group(1)}{str(int(m.group(2)))}}}",
349
+ latex,
350
+ )
351
+ return rf"{latex}"
352
+
353
+ def _calculate_sols(self, show_unc: bool | None = None) -> list[str]:
354
+ """Prepare LaTeX solution strings with optional uncertainty notation.
355
+
356
+ Args:
357
+ show_unc: Whether to include propagated uncertainties.
358
+
359
+ Returns:
360
+ A list of LaTeX-formatted solution strings.
361
+ """
362
+ latex_sols = []
363
+
364
+ if self._added_coefs:
365
+ if self._show_complex:
366
+ if self.sols:
367
+ del self.__dict__["sols"]
368
+ self.add_coefs(self.coefs)
369
+ latex_sols = [
370
+ str(x) if not isinstance(x, tuple) else f"({x[0]}) + ({x[1]}) i"
371
+ for x in self.sols
372
+ ]
373
+
374
+ else:
375
+ latex_sols = self.sols
376
+
377
+ latex_sols = [f"{x}".replace("+/-", " \\pm ") for x in latex_sols]
378
+
379
+ else:
380
+ if show_unc:
381
+ if self.sols:
382
+ del self.__dict__["sols"]
383
+
384
+ latex_sols = [
385
+ f"{sym.latex(x)} + {sym.latex(self._calculate_uncertainty_analyticaly(x))}"
386
+ for x in self.sols
387
+ ]
388
+
389
+ else:
390
+ if self.sols:
391
+ del self.__dict__["sols"]
392
+ latex_sols = [sym.latex(x) for x in self.sols]
393
+
394
+ return latex_sols
395
+
396
+ def to_latex_sols(
397
+ self, y: Coefficient | str | None = None, show_unc: bool = False
398
+ ) -> str:
399
+ """Format solutions as a LaTeX array with an optional target offset.
400
+
401
+ Args:
402
+ y: Optional target value to subtract prior to solving.
403
+ show_unc: Whether to include uncertainties in the output.
404
+
405
+ Returns:
406
+ LaTeX-formatted solution array.
407
+ """
408
+ show_unc = show_unc if not self._unc_coefs else self._unc_coefs
409
+
410
+ if not y:
411
+ latex_sols = self._calculate_sols(show_unc=show_unc)
412
+ elif isinstance(y, str):
413
+ new_expr = FunctionBase1D(str(self.expr_sym) + " - " + y)
414
+ latex_sols = new_expr._calculate_sols(show_unc=show_unc)
415
+ elif isinstance(y, unc.core.Variable) or isinstance(
416
+ y, unc.core.AffineScalarFunc
417
+ ):
418
+ new_expr = FunctionBase1D(self.expr_str + " - A100")
419
+ new_expr.add_coefs(self.coefs + [y])
420
+ latex_sols = new_expr._calculate_sols(show_unc=show_unc)
421
+ else:
422
+ new_expr = FunctionBase1D(self.expr_str + "-" + str(y))
423
+ if self._added_coefs:
424
+ new_expr.add_coefs(self.coefs)
425
+
426
+ latex_sols = new_expr._calculate_sols(show_unc=show_unc)
427
+
428
+ for i, sol in enumerate(latex_sols):
429
+ latex_sols[i] = f"x_{i + 1} = {sol} \\\\"
430
+
431
+ lines = f"\\begin{{array}}{{l}}\n{'\n'.join(latex_sols)}\n\\end{{array}}"
432
+ lines = re.sub(
433
+ r"e([+-])(\d+)",
434
+ lambda m: rf"\cdot 10^{{{m.group(1)}{str(int(m.group(2)))}}}",
435
+ lines,
436
+ )
437
+ return lines
438
+
439
+ def deriv(self, level: int = 1) -> "FunctionBase1D":
440
+ """Compute a derivative of the expression.
441
+
442
+ Args:
443
+ level: Order of the derivative to compute.
444
+
445
+ Returns:
446
+ New instance representing the derivative.
447
+ """
448
+ expr = self.expr_sym
449
+
450
+ deriv = sym.diff(expr, sym.Symbol("x"), level)
451
+
452
+ new_expr_str = str(deriv)
453
+
454
+ new_func = FunctionBase1D(new_expr_str)
455
+
456
+ if self._added_coefs:
457
+ new_func.add_coefs(self.coefs)
458
+
459
+ return new_func
460
+
461
+
462
+ class Poly(FunctionBase1D):
463
+ """Construct a polynomial expression of the specified degree."""
464
+
465
+ def __init__(self, degree: int = 1) -> None:
466
+ """Create a polynomial expression of the specified degree.
467
+
468
+ Args:
469
+ degree: Polynomial degree.
470
+ """
471
+ self.degree = degree
472
+
473
+ expr_str = ""
474
+ for i in range(degree):
475
+ expr_str += f"p_{i}*x**{degree - i} + "
476
+ expr_str += f"p_{degree}"
477
+
478
+ super().__init__(expr_str)
479
+ # self.expr_str = expr_str
480
+ return None
481
+
482
+ @cached_property
483
+ def sols(self) -> list[Solution]:
484
+ """Return analytical solutions for the polynomial when solvable.
485
+
486
+ Returns:
487
+ A list of solutions for the polynomial equation.
488
+
489
+ Raises:
490
+ ExpressionError: If the polynomial degree exceeds four.
491
+ """
492
+ if self.degree > 4:
493
+ raise ExpressionError(
494
+ "Cannot find analytical solutions for polynomials with degree > 4."
495
+ )
496
+ else:
497
+ return super().sols
498
+
499
+
500
+ class Hyper(FunctionBase1D):
501
+ """Construct a hyperbolic expression variant."""
502
+
503
+ def __init__(self, style: int = 0) -> None:
504
+ """Initialize a hyperbolic expression variant.
505
+
506
+ Args:
507
+ style: Expression selector; ``0`` uses ``p_0*x / (x + p_1)``, otherwise
508
+ uses ``p_0 / (x + p_1) + p_2``.
509
+ """
510
+ if style == 0:
511
+ expr_str = "p_0*x / (x + p_1)"
512
+ else:
513
+ expr_str = "p_0 / (x + p_1) + p_2"
514
+
515
+ super().__init__(expr_str)
516
+ return None
@@ -0,0 +1,31 @@
1
+ """Custom exceptions for unc_tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "UncToolsError",
7
+ "DataError",
8
+ "ExpressionError",
9
+ "InitialGuessError",
10
+ "ModelTypeError",
11
+ ]
12
+
13
+
14
+ class UncToolsError(Exception):
15
+ """Base exception for unc_tools."""
16
+
17
+
18
+ class DataError(ValueError, UncToolsError):
19
+ """Raised when input data is missing or malformed."""
20
+
21
+
22
+ class ModelTypeError(TypeError, UncToolsError):
23
+ """Raised when a model or callable has an unsupported type."""
24
+
25
+
26
+ class ExpressionError(TypeError, UncToolsError):
27
+ """Raised when an expression or coefficient set is invalid."""
28
+
29
+
30
+ class InitialGuessError(TypeError, UncToolsError):
31
+ """Raised when a numerical solver lacks a valid initial guess."""