pydasa 0.4.7__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 (58) hide show
  1. pydasa/__init__.py +103 -0
  2. pydasa/_version.py +6 -0
  3. pydasa/analysis/__init__.py +0 -0
  4. pydasa/analysis/scenario.py +584 -0
  5. pydasa/analysis/simulation.py +1158 -0
  6. pydasa/context/__init__.py +0 -0
  7. pydasa/context/conversion.py +11 -0
  8. pydasa/context/system.py +17 -0
  9. pydasa/context/units.py +15 -0
  10. pydasa/core/__init__.py +15 -0
  11. pydasa/core/basic.py +287 -0
  12. pydasa/core/cfg/default.json +136 -0
  13. pydasa/core/constants.py +27 -0
  14. pydasa/core/io.py +102 -0
  15. pydasa/core/setup.py +269 -0
  16. pydasa/dimensional/__init__.py +0 -0
  17. pydasa/dimensional/buckingham.py +728 -0
  18. pydasa/dimensional/fundamental.py +146 -0
  19. pydasa/dimensional/model.py +1077 -0
  20. pydasa/dimensional/vaschy.py +633 -0
  21. pydasa/elements/__init__.py +19 -0
  22. pydasa/elements/parameter.py +218 -0
  23. pydasa/elements/specs/__init__.py +22 -0
  24. pydasa/elements/specs/conceptual.py +161 -0
  25. pydasa/elements/specs/numerical.py +469 -0
  26. pydasa/elements/specs/statistical.py +229 -0
  27. pydasa/elements/specs/symbolic.py +394 -0
  28. pydasa/serialization/__init__.py +27 -0
  29. pydasa/serialization/parser.py +133 -0
  30. pydasa/structs/__init__.py +0 -0
  31. pydasa/structs/lists/__init__.py +0 -0
  32. pydasa/structs/lists/arlt.py +578 -0
  33. pydasa/structs/lists/dllt.py +18 -0
  34. pydasa/structs/lists/ndlt.py +262 -0
  35. pydasa/structs/lists/sllt.py +746 -0
  36. pydasa/structs/tables/__init__.py +0 -0
  37. pydasa/structs/tables/htme.py +182 -0
  38. pydasa/structs/tables/scht.py +774 -0
  39. pydasa/structs/tools/__init__.py +0 -0
  40. pydasa/structs/tools/hashing.py +53 -0
  41. pydasa/structs/tools/math.py +149 -0
  42. pydasa/structs/tools/memory.py +54 -0
  43. pydasa/structs/types/__init__.py +0 -0
  44. pydasa/structs/types/functions.py +131 -0
  45. pydasa/structs/types/generics.py +54 -0
  46. pydasa/validations/__init__.py +0 -0
  47. pydasa/validations/decorators.py +510 -0
  48. pydasa/validations/error.py +100 -0
  49. pydasa/validations/patterns.py +32 -0
  50. pydasa/workflows/__init__.py +1 -0
  51. pydasa/workflows/influence.py +497 -0
  52. pydasa/workflows/phenomena.py +529 -0
  53. pydasa/workflows/practical.py +765 -0
  54. pydasa-0.4.7.dist-info/METADATA +320 -0
  55. pydasa-0.4.7.dist-info/RECORD +58 -0
  56. pydasa-0.4.7.dist-info/WHEEL +5 -0
  57. pydasa-0.4.7.dist-info/licenses/LICENSE +674 -0
  58. pydasa-0.4.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1158 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Module simulation.py
4
+ ===========================================
5
+
6
+ Module for Monte Carlo Simulation execution and analysis in *PyDASA*.
7
+
8
+ This module provides the MonteCarlo class for performing Monte Carlo simulations on dimensionless coefficients derived from dimensional analysis.
9
+
10
+ Classes:
11
+
12
+ **MonteCarlo**: Performs Monte Carlo simulations on dimensionless coefficients.
13
+
14
+ *IMPORTANT:* Based on the theory from:
15
+
16
+ # H.Gorter, *Dimensionalanalyse: Eine Theoririe der physikalischen Dimensionen mit Anwendungen*
17
+ """
18
+
19
+ from __future__ import annotations
20
+ from dataclasses import dataclass, field
21
+ from typing import Optional, List, Dict, Any, Callable, Tuple, Union
22
+
23
+ # python third-party modules
24
+ import numpy as np
25
+ from numpy.typing import NDArray
26
+ from sympy import lambdify
27
+ # from sympy import Expr, Symbol
28
+ from scipy import stats
29
+ import sympy as sp
30
+
31
+ # Import validation base classes
32
+ from pydasa.core.basic import Foundation
33
+
34
+ # Import validation decorators
35
+ from pydasa.validations.decorators import validate_range, validate_type, validate_custom
36
+
37
+ # Import related classes
38
+ from pydasa.dimensional.buckingham import Coefficient
39
+ from pydasa.elements.parameter import Variable
40
+
41
+ # Import utils
42
+ from pydasa.serialization.parser import parse_latex, create_latex_mapping
43
+
44
+ # Import configuration
45
+ from pydasa.serialization.parser import latex_to_python
46
+
47
+ # # Type aliases
48
+ # SymbolDict = Dict[str, sp.Symbol]
49
+ # # FIX: Allow Basic or Expr since subs() returns Basic
50
+ # SymExpr = Union[sp.Expr, sp.Basic]
51
+
52
+
53
+ @dataclass
54
+ class MonteCarlo(Foundation):
55
+ """**MonteCarlo** class for stochastic analysis in *PyDASA*.
56
+
57
+ Performs Monte Carlo simulations on dimensionless coefficients to analyze the coefficient's distribution and sensitivity to input parameter
58
+ variations.
59
+
60
+ Args:
61
+ Foundation: Foundation class for validation of symbols and frameworks.
62
+
63
+ Attributes:
64
+ # Core Identification
65
+ name (str): User-friendly name of the Monte Carlo simulation.
66
+ description (str): Brief summary of the simulation.
67
+ _idx (int): Index/precedence of the simulation.
68
+ _sym (str): Symbol representation (LaTeX or alphanumeric).
69
+ _alias (str): Python-compatible alias for use in code.
70
+ _fwk (str): Frameworks context (PHYSICAL, COMPUTATION, SOFTWARE, CUSTOM).
71
+ _cat (str): Category of analysis (SYM, NUM, HYB).
72
+
73
+ # Coefficient and Expression Management
74
+ _coefficient (Optional[Coefficient]): Coefficient for the simulation.
75
+ _pi_expr (str): LaTeX expression to analyze.
76
+ _sym_func (Callable): Sympy function of the simulation.
77
+ _exe_func (Callable): Executable function for numerical evaluation.
78
+
79
+ # Variable Management
80
+ _variables (Dict[str, Variable]): Variable symbols in the expression.
81
+ _symbols (Dict[str, Any]): Python symbols for the variables.
82
+ _aliases (Dict[str, Any]): Variable aliases for use in code.
83
+ _latex_to_py (Dict[str, str]): Mapping from LaTeX to Python variable names.
84
+ _py_to_latex (Dict[str, str]): Mapping from Python to LaTeX variable names.
85
+
86
+ # Simulation Configuration
87
+ _experiments (int): Number of simulation experiments to run. Default is -1.
88
+ _distributions (Dict[str, Dict[str, Any]]): Variable sampling distributions.
89
+ _simul_cache (Dict[str, NDArray[np.float64]]): Working sampled values cache.
90
+
91
+ # Results and Inputs
92
+ inputs (Optional[np.ndarray]): Variable simulated inputs.
93
+ _results (Optional[np.ndarray]): Raw simulation results.
94
+
95
+ # Statistics
96
+ _mean (float): Mean value of simulation results.
97
+ _median (float): Median value of simulation results.
98
+ _std_dev (float): Standard deviation of simulation results.
99
+ _variance (float): Variance of simulation results.
100
+ _min (float): Minimum value in simulation results.
101
+ _max (float): Maximum value in simulation results.
102
+ _count (int): Number of valid simulation results.
103
+ _statistics (Optional[Dict[str, float]]): Statistical summary.
104
+ """
105
+
106
+ # ========================================================================
107
+ # Core Identification
108
+ # ========================================================================
109
+
110
+ # :attr: name
111
+ _name: str = ""
112
+ """User-friendly name of the Monte Carlo simulation."""
113
+
114
+ # :attr: description
115
+ description: str = ""
116
+ """Brief summary of the simulation."""
117
+
118
+ # :attr: _idx
119
+ _idx: int = -1
120
+ """Index/precedence of the simulation."""
121
+
122
+ # :attr: _sym
123
+ _sym: str = ""
124
+ """Symbol representation (LaTeX or alphanumeric)."""
125
+
126
+ # :attr: _alias
127
+ _alias: str = ""
128
+ """Python-compatible alias for use in code."""
129
+
130
+ # :attr: _fwk
131
+ _fwk: str = "PHYSICAL"
132
+ """Frameworks context (PHYSICAL, COMPUTATION, SOFTWARE, CUSTOM)."""
133
+
134
+ # :attr: _cat
135
+ _cat: str = "NUM"
136
+ """Category of analysis (SYM, NUM, HYB)."""
137
+
138
+ # ========================================================================
139
+ # Coefficient and Expression Management
140
+ # ========================================================================
141
+
142
+ # :attr: _coefficient
143
+ _coefficient: Coefficient = field(default_factory=Coefficient)
144
+ """Coefficient for the simulation."""
145
+
146
+ # :attr: _pi_expr
147
+ _pi_expr: Optional[str] = None
148
+ """LaTeX expression to analyze."""
149
+
150
+ # :attr: _sym_func
151
+ _sym_func: Optional[Union[sp.Expr, sp.Basic]] = None
152
+ """Sympy expression object for the coefficient (Mul, Add, Pow, Symbol, etc.)."""
153
+
154
+ # :attr: _exe_func
155
+ _exe_func: Optional[Callable[..., Union[float, np.ndarray]]] = None
156
+ """Compiled executable function for evaluation of the coefficient."""
157
+
158
+ # ========================================================================
159
+ # Variable Management
160
+ # ========================================================================
161
+
162
+ # :attr: _variables
163
+ _variables: Dict[str, Variable] = field(default_factory=dict)
164
+ """Dictionary of variables in the expression."""
165
+
166
+ # :attr: _symbols
167
+ _symbols: Dict[str, sp.Symbol] = field(default_factory=dict)
168
+ """Map from variable names (strings) to sympy Symbols."""
169
+
170
+ # :attr: _aliases
171
+ _aliases: Dict[str, sp.Symbol] = field(default_factory=dict)
172
+ """Map from Variable aliases to sympy Symbols."""
173
+
174
+ # :attr: _latex_to_py
175
+ _latex_to_py: Dict[str, str] = field(default_factory=dict)
176
+ """Map from LaTeX symbols to Python-compatible names."""
177
+
178
+ # :attr: _py_to_latex
179
+ _py_to_latex: Dict[str, str] = field(default_factory=dict)
180
+ """Map from Python-compatible names to LaTeX symbols."""
181
+
182
+ # :attr: _var_symbols
183
+ _var_symbols: List[str] = field(default_factory=list)
184
+ """List of variable names extracted from expression."""
185
+ # ========================================================================
186
+ # Simulation Configuration
187
+ # ========================================================================
188
+
189
+ # :attr: _experiments
190
+ _experiments: int = -1
191
+ """Number of simulation iterations to run."""
192
+
193
+ # :attr: _distributions
194
+ _distributions: Dict[str, Dict[str, Any]] = field(default_factory=dict)
195
+ """Variable sampling distributions and specifications that includes:
196
+ - 'dtype': Distribution type name.
197
+ - 'params': Distribution parameters (mean, std_dev, etc.).
198
+ - 'func': Function for sampling, usually in Lambda format.
199
+ - 'depends': List of variables this variable depends on.
200
+ """
201
+
202
+ # :attr: _dependencies
203
+ _dependencies: Dict[str, List[str]] = field(default_factory=dict, init=False)
204
+ """Variable dependencies for simulations."""
205
+
206
+ # :attr: _simul_cache
207
+ _simul_cache: Dict[str, NDArray[np.float64]] = field(default_factory=dict)
208
+ """Working sampled values during each simulation iteration. Memory cache."""
209
+
210
+ # ========================================================================
211
+ # Results and Inputs
212
+ # ========================================================================
213
+
214
+ # :attr: inputs
215
+ inputs: NDArray[np.float64] = field(
216
+ default_factory=lambda: np.array([], dtype=np.float64))
217
+ """Sample value range for the simulation."""
218
+
219
+ # :attr: _results
220
+ _results: NDArray[np.float64] = field(
221
+ default_factory=lambda: np.array([], dtype=np.float64))
222
+ """Raw simulation results."""
223
+
224
+ # ========================================================================
225
+ # Statistics
226
+ # ========================================================================
227
+
228
+ # :attr: _mean
229
+ _mean: float = np.nan
230
+ """Mean value of simulation results."""
231
+
232
+ # :attr: _median
233
+ _median: float = np.nan
234
+ """Median value of simulation results."""
235
+
236
+ # :attr: _std_dev
237
+ _std_dev: float = np.nan
238
+ """Standard deviation of simulation results."""
239
+
240
+ # :attr: _variance
241
+ _variance: float = np.nan
242
+ """Variance of simulation results."""
243
+
244
+ # :attr: _min
245
+ _min: float = np.nan
246
+ """Minimum value in simulation results."""
247
+
248
+ # :attr: _max
249
+ _max: float = np.nan
250
+ """Maximum value in simulation results."""
251
+
252
+ # :attr: _count
253
+ _count: int = 0
254
+ """Number of valid simulation results."""
255
+
256
+ # :attr: _statistics
257
+ _statistics: Optional[Dict[str, float]] = None
258
+ """Statistical summary of the Monte Carlo simulation results."""
259
+
260
+ # ========================================================================
261
+ # Initialization
262
+ # ========================================================================
263
+
264
+ def _validate_dist(self, value: Dict[str, Dict[str, Any]], field_name: str) -> None:
265
+ """*_validate_dist()* Custom validator to ensure all distributions have callable 'func'.
266
+
267
+ Args:
268
+ value: The distributions dictionary to validate.
269
+ field_name: Name of the field being validated.
270
+
271
+ Raises:
272
+ ValueError: If distributions don't have callable 'func' functions.
273
+ """
274
+ if not all(callable(v["func"]) for v in value.values()):
275
+ inv = [k for k, v in value.items() if not callable(v["func"])]
276
+ raise ValueError(
277
+ f"All distributions must have callable 'func' functions. "
278
+ f"Invalid entries: {inv}"
279
+ )
280
+
281
+ def __post_init__(self) -> None:
282
+ """*__post_init__()* Initializes the Monte Carlo simulation."""
283
+ # Initialize from base class
284
+ super().__post_init__()
285
+
286
+ # Validate coefficient
287
+ if not self._coefficient.pi_expr:
288
+ raise ValueError("Coefficient must have a valid expression")
289
+
290
+ # Derive expression from coefficient
291
+ self._pi_expr = self._coefficient.pi_expr
292
+
293
+ # Set default symbol if not specified
294
+ if not self._sym:
295
+ self._sym = f"MC_\\Pi_{{{self._idx}}}" if self._idx >= 0 else "MC_\\Pi_{}"
296
+
297
+ # Set default Python alias if not specified
298
+ if not self._alias:
299
+ self._alias = latex_to_python(self._sym)
300
+
301
+ # Set name and description if not already set
302
+ if not self._name:
303
+ self._name = f"{self._sym} Monte Carlo"
304
+
305
+ if not self.description:
306
+ self.description = f"Monte Carlo simulation for {self._sym}"
307
+
308
+ if self._pi_expr:
309
+ # Parse the expression
310
+ self._parse_expression(self._pi_expr)
311
+
312
+ # Preallocate full array space with NaN only if experiments > 0
313
+ n_sym = len(self._symbols)
314
+ if n_sym > 0 and self._experiments > 0:
315
+ # Only allocate if we have variables and valid experiment count
316
+ if self.inputs.size == 0: # Check size, not None
317
+ self.inputs = np.full((self._experiments, n_sym),
318
+ np.nan,
319
+ dtype=np.float64)
320
+ if self._results.size == 0: # Check size, not None
321
+ self._results = np.full((self._experiments, 1),
322
+ np.nan,
323
+ dtype=np.float64)
324
+
325
+ # Only initialize cache if not already provided and experiments > 0
326
+ if not self._simul_cache and self._experiments > 0:
327
+ # Create local cache only if no external cache provided
328
+ for var in self._variables.keys():
329
+ self._simul_cache[var] = np.full((self._experiments, 1),
330
+ np.nan,
331
+ dtype=np.float64)
332
+
333
+ # Statistics initialized to NaN (not calculated yet)
334
+ self._mean = np.nan
335
+ self._median = np.nan
336
+ self._std_dev = np.nan
337
+ self._variance = np.nan
338
+ self._min = np.nan
339
+ self._max = np.nan
340
+
341
+ # Zero makes sense here
342
+ self._count = 0
343
+
344
+ # ========================================================================
345
+ # Foundation and Configuration
346
+ # ========================================================================
347
+
348
+ def _validate_readiness(self) -> None:
349
+ """*_validate_readiness()* Checks if the simulation can be performed.
350
+
351
+ Raises:
352
+ ValueError: If the simulation is not ready due to missing variables, executable function, distributions, or invalid number of iterations.
353
+ """
354
+ if not self._variables:
355
+ raise ValueError("No variables found in the expression.")
356
+ if not self._sym_func:
357
+ raise ValueError("No expression has been defined for analysis.")
358
+ if not self._distributions:
359
+ _vars = self._variables
360
+ missing = [v for v in _vars if v not in self._distributions]
361
+ if missing:
362
+ _msg = f"Missing distributions for variables: {missing}"
363
+ raise ValueError(_msg)
364
+ if self._experiments < 1:
365
+ _msg = f"Invalid number of iterations: {self._experiments}"
366
+ raise ValueError(_msg)
367
+
368
+ def set_coefficient(self, coef: Coefficient) -> None:
369
+ """*set_coefficient()* Configure analysis from a coefficient.
370
+
371
+ Args:
372
+ coef (Coefficient): Dimensionless coefficient to analyze.
373
+
374
+ Raises:
375
+ ValueError: If the coefficient doesn't have a valid expression.
376
+ """
377
+ if not coef.pi_expr:
378
+ raise ValueError("Coefficient does not have a valid expression.")
379
+
380
+ # Save coefficient
381
+ self._coefficient = coef
382
+
383
+ # Set expression
384
+ self._pi_expr = coef.pi_expr
385
+
386
+ # Parse coefficient expression
387
+ if coef._pi_expr:
388
+ self._parse_expression(self._pi_expr)
389
+
390
+ # Set name and description if not already set
391
+ if not self._name:
392
+ self._name = f"{coef.name} Monte Carlo Experiments"
393
+ if not self.description:
394
+ self.description = f"Monte Carlo simulation for {coef.name}"
395
+
396
+ def _parse_expression(self, expr: str) -> None:
397
+ """*_parse_expression()* Parse the LaTeX expression into a sympy function.
398
+
399
+ Args:
400
+ expr (str): LaTeX expression to parse.
401
+
402
+ Raises:
403
+ ValueError: If the expression cannot be parsed.
404
+ """
405
+ try:
406
+ # Parse the expression
407
+ self._sym_func = parse_latex(expr)
408
+
409
+ if self._sym_func is None:
410
+ raise ValueError("Parsing returned None")
411
+
412
+ # Store the sympy expression
413
+ self._sym_func = self._sym_func
414
+
415
+ # Create symbol mapping
416
+ maps = create_latex_mapping(expr)
417
+
418
+ symbols_raw: Dict[Any, sp.Symbol] = maps[0]
419
+ aliases_raw: Dict[str, sp.Symbol] = maps[1]
420
+ latex_to_py: Dict[str, str] = maps[2]
421
+ py_to_latex: Dict[str, str] = maps[3]
422
+
423
+ # Convert Symbol keys to strings
424
+ self._symbols = {
425
+ str(k): v for k, v in symbols_raw.items()
426
+ }
427
+ self._aliases = aliases_raw
428
+ self._latex_to_py = latex_to_py
429
+ self._py_to_latex = py_to_latex
430
+
431
+ # Substitute LaTeX symbols with Python symbols
432
+ for latex_sym_key, py_sym in symbols_raw.items():
433
+ if self._sym_func is None:
434
+ break
435
+
436
+ # Handle both string and Symbol keys
437
+ if isinstance(latex_sym_key, sp.Symbol):
438
+ # subs() returns Basic, which is fine
439
+ self._sym_func = self._sym_func.subs(latex_sym_key, py_sym)
440
+ else:
441
+ # Try to find the symbol by name
442
+ latex_symbol = sp.Symbol(str(latex_sym_key))
443
+ self._sym_func = self._sym_func.subs(latex_symbol, py_sym)
444
+
445
+ # Get Python variable names as strings
446
+ if self._sym_func is not None and hasattr(self._sym_func, 'free_symbols'):
447
+ free_symbols = self._sym_func.free_symbols
448
+ self._var_symbols = sorted([str(s) for s in free_symbols])
449
+ else:
450
+ raise ValueError("Expression has no free symbols")
451
+
452
+ except Exception as e:
453
+ _msg = f"Failed to parse expression: {str(e)}"
454
+ raise ValueError(_msg)
455
+
456
+ # ========================================================================
457
+ # Simulation Execution
458
+ # ========================================================================
459
+
460
+ def _generate_sample(self,
461
+ var: Variable,
462
+ memory: Dict[str, float]) -> float:
463
+ """*_generate_sample()* Generate a sample for a given variable.
464
+
465
+ Args:
466
+ var (Variable): The variable to generate a sample for.
467
+ memory (Dict[str, float]): The current iteration values.
468
+
469
+ Returns:
470
+ float: The generated sample.
471
+ """
472
+ # Initialize sample
473
+ data: float = -1.0
474
+
475
+ # relevant data type, HOTFIX
476
+ _type = (list, tuple, np.ndarray)
477
+
478
+ # Get dependency values from memory
479
+ chace_deps = []
480
+ for dep in var.depends:
481
+ if dep in memory:
482
+ dep_val = memory[dep]
483
+ # If dependency is a list/tuple/array, take the last value
484
+ if isinstance(dep_val, (list, tuple, np.ndarray)):
485
+ dep_val = dep_val[-1]
486
+ chace_deps.append(dep_val)
487
+
488
+ # print(f"chace_deps: {chace_deps}")
489
+
490
+ # if the distribution function is defined
491
+ if var._dist_func is not None:
492
+ # If the variable is independent
493
+ if not var.depends:
494
+ data = var.sample()
495
+
496
+ # If the variable has dependencies
497
+ elif len(var.depends) == len(chace_deps):
498
+ raw_data = var.sample(*chace_deps)
499
+ # print(f"raw_data: {raw_data}")
500
+
501
+ # Handle array-like results
502
+ if isinstance(raw_data, _type):
503
+ # get the last number
504
+ data = raw_data[-1]
505
+
506
+ # adjust the memory accordingly to the rest of the list
507
+ for dep in var.depends:
508
+ if dep in memory:
509
+ memory[dep] = raw_data[var.depends.index(dep)]
510
+ # otherwise, its a number
511
+ else:
512
+ data = raw_data
513
+
514
+ # print(f"dependencies keys {var.depends}")
515
+ # print(f"memory: {memory}")
516
+
517
+ # Store sample in memory
518
+ memory[var.sym] = float(data)
519
+
520
+ # return sampled data
521
+ return data
522
+
523
+ def run(self, iters: Optional[int] = None) -> None:
524
+ """*run()* Execute the Monte Carlo simulation.
525
+
526
+ Args:
527
+ iters (int, optional): Number of iterations to run. If None, uses _experiments.
528
+
529
+ Raises:
530
+ ValueError: If simulation is not ready or encounters errors during execution.
531
+ """
532
+ # Validate simulation readiness
533
+ self._validate_readiness()
534
+
535
+ # Set iterations if necessary
536
+ if iters is not None:
537
+ self._experiments = iters
538
+
539
+ # Clear previous results, inputs, and intermediate values
540
+ self._reset_memory()
541
+
542
+ # Create lambdify function using Python symbols
543
+ aliases = [self._aliases[v] for v in self._var_symbols]
544
+ self._exe_func = lambdify(aliases, self._sym_func, "numpy")
545
+
546
+ if self._exe_func is None:
547
+ raise ValueError("Failed to create executable function")
548
+
549
+ # Run experiment loop
550
+ for _iter in range(self._experiments):
551
+ try:
552
+ # Dict to store sample memory for the iteration
553
+ memory: Dict[str, float] = {}
554
+
555
+ # run through all variables
556
+ for var in self._variables.values():
557
+ # Check for cached value
558
+ cached_val = self._get_cached_value(var.sym, _iter)
559
+
560
+ # if no cached value, generate new sample
561
+ if cached_val is None or np.isnan(cached_val):
562
+ # Generate sample for the variable
563
+ val = self._generate_sample(var, memory)
564
+ # Store the sample in the iteration values
565
+ memory[var.sym] = val
566
+ self._set_cached_value(var.sym, _iter, val)
567
+
568
+ # otherwise use cached value
569
+ else:
570
+ # Use cached value
571
+ memory[var.sym] = cached_val
572
+
573
+ # Prepare sorted/ordered values from memory for evaluation
574
+ sorted_vals = [memory[var] for var in self._latex_to_py]
575
+
576
+ # FIXME hotfix for queue functions
577
+ _type = (list, tuple, np.ndarray)
578
+ # Handle adjusted values
579
+ if any(isinstance(v, _type) for v in sorted_vals):
580
+ sorted_vals = [
581
+ v[-1] if isinstance(v, _type) else v for v in sorted_vals]
582
+
583
+ # Evaluate the coefficient
584
+ result = float(self._exe_func(*sorted_vals))
585
+
586
+ # Handle array results
587
+ if isinstance(result, _type):
588
+ result = result[-1]
589
+ sorted_vals = [v[-1] for v in result]
590
+
591
+ # Save simulation inputs and results
592
+ self.inputs[_iter, :] = sorted_vals
593
+ self._results[_iter] = result
594
+
595
+ except Exception as e:
596
+ _msg = f"Error during simulation run {_iter}: {str(e)}"
597
+ raise ValueError(_msg)
598
+
599
+ # Calculate statistics
600
+ self._calculate_statistics()
601
+
602
+ # ========================================================================
603
+ # Memory and Statistics Management
604
+ # ========================================================================
605
+
606
+ def _reset_memory(self) -> None:
607
+ """*_reset_memory()* Reset results and inputs arrays."""
608
+ # reseting full array space with NaN
609
+ n_sym = len(self._symbols)
610
+ self.inputs = np.full((self._experiments, n_sym), np.nan)
611
+ self._results = np.full((self._experiments, 1), np.nan)
612
+
613
+ # # reset intermediate values
614
+ # for var in self._variables.keys():
615
+ # self._simul_cache[var] = np.full((self._experiments, 1),
616
+ # np.nan,
617
+ # dtype=np.float64)
618
+
619
+ def _reset_statistics(self) -> None:
620
+ """*_reset_statistics()* Reset all statistical attributes to default values."""
621
+ # reset statistics to NaN or zero
622
+ self._mean = np.nan
623
+ self._median = np.nan
624
+ self._std_dev = np.nan
625
+ self._variance = np.nan
626
+ self._min = np.nan
627
+ self._max = np.nan
628
+ self._count = 0
629
+
630
+ def _calculate_statistics(self) -> None:
631
+ """*_calculate_statistics()* Calculate statistical properties of simulation results."""
632
+ # Check for empty array (size == 0), not None
633
+ if self._results.size == 0:
634
+ raise ValueError("No results available. Run simulation first.")
635
+
636
+ else:
637
+ self._mean = float(np.mean(self._results))
638
+ self._median = float(np.median(self._results))
639
+ self._std_dev = float(np.std(self._results))
640
+ self._variance = float(np.var(self._results))
641
+ self._min = float(np.min(self._results))
642
+ self._max = float(np.max(self._results))
643
+ self._count = len(self._results)
644
+
645
+ def get_confidence_interval(self,
646
+ conf: float = 0.95) -> Tuple[float, float]:
647
+ """*get_confidence_interval()* Calculate the confidence interval.
648
+
649
+ Args:
650
+ conf (float, optional): Confidence level for the interval. Defaults to 0.95.
651
+
652
+ Raises:
653
+ ValueError: If no results are available or if the confidence level is invalid.
654
+
655
+ Returns:
656
+ Tuple[float, float]: Lower and upper bounds of the confidence interval.
657
+ """
658
+ if self._results.size == 0:
659
+ _msg = "No results available. Run the simulation first."
660
+ raise ValueError(_msg)
661
+
662
+ if not 0 < conf < 1:
663
+ _msg = f"Confidence must be between 0 and 1. Got: {conf}"
664
+ raise ValueError(_msg)
665
+
666
+ # Calculate the margin of error using the t-distribution
667
+ alpha = stats.t.ppf((1 + conf) / 2, self._count - 1)
668
+ margin = alpha * self._std_dev / np.sqrt(self._count)
669
+ ans = (self._mean - margin, self._mean + margin)
670
+ return ans
671
+
672
+ # ========================================================================
673
+ # simulation cache management
674
+ # ========================================================================
675
+
676
+ def _validate_cache_locations(self,
677
+ var_syms: Union[str, List[str]],
678
+ idx: int) -> bool:
679
+ """*_validate_cache_locations()* Check if cache locations are valid for variable(s) at the iteration.
680
+
681
+ Args:
682
+ var_syms (Union[str, List[str]]): Variable symbol(s) to check.
683
+ idx (int): Iteration index to check.
684
+
685
+ Returns:
686
+ bool: True if all cache locations are valid (including NaN placeholders), False otherwise.
687
+ """
688
+ # Convert single string to list for uniform handling
689
+ syms = [var_syms] if isinstance(var_syms, str) else var_syms
690
+
691
+ # Start with assumption that cache is invalid
692
+ valid = False
693
+
694
+ # Check each symbol
695
+ for var_sym in syms:
696
+ # Reset validity check for each variable
697
+ var_valid = False
698
+
699
+ # Get cache array for the variable
700
+ cache_array = self._simul_cache.get(var_sym, None)
701
+
702
+ # Check if cache exists and location is valid
703
+ if cache_array is not None:
704
+ # Check if index is within bounds
705
+ if idx < cache_array.shape[0] and idx >= 0:
706
+ # Location exists - valid regardless of whether value is NaN
707
+ # (NaN is a valid placeholder for uncomputed values)
708
+ var_valid = True
709
+
710
+ # If any variable is invalid, entire check fails
711
+ if not var_valid:
712
+ return False
713
+
714
+ # All variables passed validation
715
+ valid = True
716
+ return valid
717
+
718
+ def _get_cached_value(self, var_sym: str, idx: int) -> Optional[float]:
719
+ """*_get_cached_value()* Retrieve cached value for variable at the iteration.
720
+
721
+ Args:
722
+ var_sym (str): Variable symbol.
723
+ idx (int): Iteration index.
724
+
725
+ Returns:
726
+ Optional[float]: Cached value if valid, None otherwise.
727
+ """
728
+ # Initialize return value
729
+ cache_data = None
730
+
731
+ # Check if cache location is valid
732
+ if self._validate_cache_locations(var_sym, idx):
733
+ # Retrieve cached data
734
+ cache_data = self._simul_cache[var_sym][idx, 0]
735
+ # if value is not NaN (valid location, but no data yet)
736
+ if not np.isnan(cache_data):
737
+ # cast to float the computed value
738
+ cache_data = float(cache_data)
739
+ # return valid cache location
740
+ return cache_data
741
+
742
+ def _set_cached_value(self,
743
+ var_sym: str,
744
+ idx: int,
745
+ val: Union[float, Dict]) -> None:
746
+ """*_set_cached_value()* Store value in cache for variable at the iteration.
747
+
748
+ Args:
749
+ var_sym (str): Variable symbol.
750
+ idx (int): Iteration index.
751
+ val (Union[float, Dict]): Value to cache. It can be a normal number (float) or a memory cache correction (dict).
752
+
753
+ Raises:
754
+ ValueError: If cache location is invalid.
755
+ """
756
+ # Normalize input to dictionary format
757
+ cache_updates = val if isinstance(val, dict) else {var_sym: val}
758
+
759
+ # Validate all cache locations
760
+ if not self._validate_cache_locations(list(cache_updates.keys()), idx):
761
+ invalid_vars = list(cache_updates.keys())
762
+ _msg = f"Invalid cache location at index {idx}. "
763
+ _msg += f"For variables: {invalid_vars}"
764
+ raise ValueError(_msg)
765
+
766
+ # Store all values
767
+ for k, v in cache_updates.items():
768
+ self._simul_cache[k][idx, 0] = v
769
+
770
+ # ========================================================================
771
+ # Results Extraction
772
+ # ========================================================================
773
+
774
+ def extract_results(self) -> Dict[str, NDArray[np.float64]]:
775
+ """*extract_results()* Extract simulation results.
776
+
777
+ Returns:
778
+ Dict[str, NDArray[np.float64]]: Dictionary containing simulation results.
779
+ """
780
+ export: Dict[str, NDArray[np.float64]] = {}
781
+
782
+ # Extract all values for each variable (column)
783
+ for i, var in enumerate(self._py_to_latex.values()):
784
+ # Get the entire column for this variable (all simulation runs)
785
+ column = self.inputs[:, i]
786
+
787
+ # Use a meaningful key that includes variable name and coefficient
788
+ key = f"{var}@{self._coefficient.sym}"
789
+ export[key] = column
790
+
791
+ # Add the coefficient results
792
+ export[self._coefficient.sym] = self._results.flatten()
793
+ return export
794
+
795
+ # ========================================================================
796
+ # Properties
797
+ # ========================================================================
798
+
799
+ @property
800
+ def variables(self) -> Dict[str, Variable]:
801
+ """*variables* Get the variables involved in the simulation.
802
+
803
+ Returns:
804
+ Dict[str, Variable]: Dictionary of variable symbols and Variable objects.
805
+ """
806
+ return self._variables.copy()
807
+
808
+ @property
809
+ def coefficient(self) -> Optional[Coefficient]:
810
+ """*coefficient* Get the coefficient associated with the simulation.
811
+
812
+ Returns:
813
+ Optional[Coefficient]: The associated Coefficient object, or None.
814
+ """
815
+ return self._coefficient
816
+
817
+ @property
818
+ def results(self) -> NDArray[np.float64]:
819
+ """*results* Raw simulation results.
820
+
821
+ Returns:
822
+ NDArray[np.float64]: Copy of the simulation results.
823
+
824
+ Raises:
825
+ ValueError: If no results are available.
826
+ """
827
+ if self._results.size == 0:
828
+ raise ValueError("No results available. Run the simulation first.")
829
+ return self._results.copy()
830
+
831
+ @property
832
+ def statistics(self) -> Dict[str, float]:
833
+ """*statistics* Get the statistical analysis of simulation results.
834
+
835
+ Raises:
836
+ ValueError: If no results are available.
837
+
838
+ Returns:
839
+ Dict[str, float]: Dictionary containing statistical properties.
840
+ """
841
+ if self._results.size == 0:
842
+ _msg = "No statistics available. Run the simulation first."
843
+ raise ValueError(_msg)
844
+
845
+ # Build statistics dictionary from individual attributes
846
+ self._statistics = {
847
+ "mean": self._mean,
848
+ "median": self._median,
849
+ "std_dev": self._std_dev,
850
+ "variance": self._variance,
851
+ "min": self._min,
852
+ "max": self._max,
853
+ "count": self._count
854
+ }
855
+ return self._statistics
856
+
857
+ @property
858
+ def experiments(self) -> int:
859
+ """*experiments* Number of simulation experiments.
860
+
861
+ Returns:
862
+ int: Current number of experiments.
863
+ """
864
+ return self._experiments
865
+
866
+ @experiments.setter
867
+ @validate_range(min_value=1)
868
+ def experiments(self, val: int) -> None:
869
+ """*experiments* Set the number of simulation runs.
870
+
871
+ Args:
872
+ val (int): Number of experiments to run the simulation.
873
+
874
+ Raises:
875
+ ValueError: If the number of experiments is not positive.
876
+ """
877
+ self._experiments = val
878
+
879
+ @property
880
+ def distributions(self) -> Dict[str, Dict[str, Any]]:
881
+ """*distributions* Get the variable distributions.
882
+
883
+ Returns:
884
+ Dict[str, Dict[str, Any]]: Current variable distributions.
885
+ """
886
+ return self._distributions.copy()
887
+
888
+ @distributions.setter
889
+ @validate_custom(lambda self, val: self._validate_dist(val,
890
+ "distributions"))
891
+ def distributions(self, val: Dict[str, Dict[str, Any]]) -> None:
892
+ """*distributions* Set the variable distributions.
893
+
894
+ Args:
895
+ val (Dict[str, Dict[str, Any]]): New variable distributions.
896
+
897
+ Raises:
898
+ ValueError: If the distributions are invalid.
899
+ """
900
+ self._distributions = val
901
+
902
+ @property
903
+ def dependencies(self) -> Dict[str, List[str]]:
904
+ """*dependencies* Get variable dependencies.
905
+
906
+ Returns:
907
+ Dict[str, List[str]]: Dictionary of variable dependencies.
908
+ """
909
+ return self._dependencies
910
+
911
+ @dependencies.setter
912
+ @validate_type(dict)
913
+ def dependencies(self, val: Dict[str, List[str]]) -> None:
914
+ """*dependencies* Set variable dependencies.
915
+
916
+ Args:
917
+ val (Dict[str, List[str]]): New variable dependencies.
918
+ """
919
+ self._dependencies = val
920
+
921
+ # Individual statistics properties
922
+
923
+ @property
924
+ def mean(self) -> float:
925
+ """*mean* Mean value of simulation results.
926
+
927
+ Returns:
928
+ float: Mean value.
929
+
930
+ Raises:
931
+ ValueError: If no results are available.
932
+ """
933
+ if self._results.size == 0:
934
+ _msg = "No statistics available. Run the simulation first."
935
+ raise ValueError(_msg)
936
+ return self._mean
937
+
938
+ @property
939
+ def median(self) -> float:
940
+ """*median* Median value of simulation results.
941
+
942
+ Returns:
943
+ float: Median value.
944
+
945
+ Raises:
946
+ ValueError: If no results are available.
947
+ """
948
+ if self._results.size == 0:
949
+ _msg = "No statistics available. Run the simulation first."
950
+ raise ValueError(_msg)
951
+ return self._median
952
+
953
+ @property
954
+ def std_dev(self) -> float:
955
+ """*std_dev* Standard deviation of simulation results.
956
+
957
+ Returns:
958
+ float: Standard deviation.
959
+
960
+ Raises:
961
+ ValueError: If no results are available.
962
+ """
963
+ if self._results.size == 0:
964
+ _msg = "No statistics available. Run the simulation first."
965
+ raise ValueError(_msg)
966
+ return self._std_dev
967
+
968
+ @property
969
+ def variance(self) -> float:
970
+ """*variance* Variance of simulation results.
971
+
972
+ Returns:
973
+ float: Variance value.
974
+
975
+ Raises:
976
+ ValueError: If no results are available.
977
+ """
978
+ if self._results.size == 0:
979
+ _msg = "No statistics available. Run the simulation first."
980
+ raise ValueError(_msg)
981
+ return self._variance
982
+
983
+ @property
984
+ def min_value(self) -> float:
985
+ """*min_value* Minimum value in simulation results.
986
+
987
+ Returns:
988
+ float: Minimum value.
989
+
990
+ Raises:
991
+ ValueError: If no results are available.
992
+ """
993
+ if self._results.size == 0:
994
+ _msg = "No statistics available. Run the simulation first."
995
+ raise ValueError(_msg)
996
+ return self._min
997
+
998
+ @property
999
+ def max_value(self) -> float:
1000
+ """*max_value* Maximum value in simulation results.
1001
+
1002
+ Returns:
1003
+ float: Maximum value.
1004
+
1005
+ Raises:
1006
+ ValueError: If no results are available.
1007
+ """
1008
+ if self._results.size == 0:
1009
+ _msg = "No statistics available. Run the simulation first."
1010
+ raise ValueError(_msg)
1011
+ return self._max
1012
+
1013
+ @property
1014
+ def count(self) -> int:
1015
+ """*count* Number of valid simulation results.
1016
+
1017
+ Returns:
1018
+ int: Result count.
1019
+
1020
+ Raises:
1021
+ ValueError: If no results are available.
1022
+ """
1023
+ if self._results.size == 0:
1024
+ _msg = "No statistics available. Run the simulation first."
1025
+ raise ValueError(_msg)
1026
+ return self._count
1027
+
1028
+ @property
1029
+ def summary(self) -> Dict[str, float]:
1030
+ """*summary* Get the statistical analysis of simulation results.
1031
+
1032
+ Raises:
1033
+ ValueError: If no results are available.
1034
+
1035
+ Returns:
1036
+ Dict[str, float]: Dictionary containing statistical properties.
1037
+ """
1038
+ if self._results.size == 0:
1039
+ _msg = "No statistics available. Run the simulation first."
1040
+ raise ValueError(_msg)
1041
+
1042
+ # Build summary dictionary from individual attributes
1043
+ self._summary = {
1044
+ "mean": self._mean,
1045
+ "median": self._median,
1046
+ "std_dev": self._std_dev,
1047
+ "variance": self._variance,
1048
+ "min": self._min,
1049
+ "max": self._max,
1050
+ "count": self._count
1051
+ }
1052
+ return self._summary
1053
+
1054
+ # ========================================================================
1055
+ # Utility Methods
1056
+ # ========================================================================
1057
+
1058
+ def clear(self) -> None:
1059
+ """*clear()* Reset all attributes to default values."""
1060
+ # Reset base class attributes
1061
+ self._idx = -1
1062
+ self._sym = "MC_\\Pi_{}"
1063
+ self._alias = ""
1064
+ self._fwk = "PHYSICAL"
1065
+ self._name = ""
1066
+ self.description = ""
1067
+
1068
+ # Reset simulation attributes
1069
+ self._pi_expr = None
1070
+ self._sym_func = None
1071
+ self._exe_func = None
1072
+ self._variables = {}
1073
+ self._latex_to_py = {}
1074
+ self._py_to_latex = {}
1075
+ self._experiments = -1
1076
+ self._distributions = {}
1077
+
1078
+ # reset results, inputs and intermediate values
1079
+ self._reset_memory()
1080
+
1081
+ # Reset statistics
1082
+ self._reset_statistics()
1083
+
1084
+ # ========================================================================
1085
+ # Serialization
1086
+ # ========================================================================
1087
+
1088
+ def to_dict(self) -> Dict[str, Any]:
1089
+ """*to_dict()* Convert simulation to dictionary representation."""
1090
+ return {
1091
+ # Foundation class attributes
1092
+ "idx": self._idx,
1093
+ "sym": self._sym,
1094
+ "alias": self._alias,
1095
+ "fwk": self._fwk,
1096
+ "name": self._name,
1097
+ "description": self.description,
1098
+ # Simulation attributes
1099
+ "pi_expr": self._pi_expr,
1100
+ "variables": self._variables,
1101
+ "iterations": self._experiments,
1102
+ # Results
1103
+ "mean": self._mean,
1104
+ "median": self._median,
1105
+ "std_dev": self._std_dev,
1106
+ "variance": self._variance,
1107
+ "min": self._min,
1108
+ "max": self._max,
1109
+ "count": self._count,
1110
+ "inputs": self.inputs.tolist() if self.inputs.size > 0 else None,
1111
+ "results": self._results.tolist() if self._results.size > 0 else None,
1112
+ }
1113
+
1114
+ @classmethod
1115
+ def from_dict(cls, data: Dict[str, Any]) -> "MonteCarlo":
1116
+ """*from_dict()* Create simulation from dictionary representation.
1117
+
1118
+ Args:
1119
+ data (Dict[str, Any]): Dictionary representation.
1120
+
1121
+ Returns:
1122
+ MonteCarlo: New simulation instance.
1123
+ """
1124
+ # Create basic instance
1125
+ instance = cls(
1126
+ _name=data.get("name", ""),
1127
+ description=data.get("description", ""),
1128
+ _idx=data.get("idx", -1),
1129
+ _sym=data.get("sym", "MC_\\Pi_{}"),
1130
+ _fwk=data.get("fwk", "PHYSICAL"),
1131
+ _alias=data.get("alias", ""),
1132
+ _cat=data.get("cat", "NUM"),
1133
+ _pi_expr=data.get("pi_expr", None),
1134
+ _experiments=data.get("iterations", -1),
1135
+ )
1136
+
1137
+ # The to_dict() method stores them at the top level
1138
+ instance._mean = data.get("mean", np.nan)
1139
+ instance._median = data.get("median", np.nan)
1140
+ instance._std_dev = data.get("std_dev", np.nan)
1141
+ instance._variance = data.get("variance", np.nan)
1142
+ instance._min = data.get("min", np.nan)
1143
+ instance._max = data.get("max", np.nan)
1144
+ instance._count = data.get("count", 0)
1145
+
1146
+ # Optionally set inputs and results if available
1147
+ if "inputs" in data and data["inputs"] is not None:
1148
+ instance.inputs = np.array(data["inputs"], dtype=np.float64)
1149
+
1150
+ if "results" in data and data["results"] is not None:
1151
+ instance._results = np.array(data["results"], dtype=np.float64)
1152
+
1153
+ # Optionally set variables if available
1154
+ if "variables" in data and data["variables"]:
1155
+ for sym, specs in data["variables"].items():
1156
+ instance._variables[sym] = Variable(**specs)
1157
+
1158
+ return instance