stochops 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.
- stochops/__init__.py +55 -0
- stochops/adaptive/__init__.py +6 -0
- stochops/adaptive/solver_fallback.py +89 -0
- stochops/builder.py +140 -0
- stochops/exceptions.py +5 -0
- stochops/execution/__init__.py +5 -0
- stochops/execution/process_pool.py +151 -0
- stochops/execution/subprocess_pool.py +0 -0
- stochops/parameters.py +152 -0
- stochops/protocols/__init__.py +11 -0
- stochops/protocols/engine.py +35 -0
- stochops/protocols/model.py +19 -0
- stochops/protocols/sampler.py +17 -0
- stochops/protocols/sink.py +18 -0
- stochops/py.typed +0 -0
- stochops/samplers/__init__.py +6 -0
- stochops/samplers/lhs.py +141 -0
- stochops/samplers/mc.py +140 -0
- stochops/samplers/nataf.py +0 -0
- stochops/sinks/__init__.py +5 -0
- stochops/sinks/hdf5.py +0 -0
- stochops/sinks/parquet.py +136 -0
- stochops-0.1.0.dist-info/METADATA +140 -0
- stochops-0.1.0.dist-info/RECORD +26 -0
- stochops-0.1.0.dist-info/WHEEL +4 -0
- stochops-0.1.0.dist-info/licenses/LICENSE +21 -0
stochops/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""stochops: A fluent Monte Carlo & Latin Hypercube Sampling simulation engine for OpenSeesPy."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from stochops.adaptive.solver_fallback import AdaptiveAnalysisRunner, ConvergenceError
|
|
6
|
+
from stochops.builder import SimulationBuilder, SimulationEngine
|
|
7
|
+
from stochops.execution.process_pool import ProcessPoolEngine
|
|
8
|
+
from stochops.parameters import (
|
|
9
|
+
BaseParameter,
|
|
10
|
+
ContinuousParam,
|
|
11
|
+
DiscreteParam,
|
|
12
|
+
Distribution,
|
|
13
|
+
GroundMotionSetParam,
|
|
14
|
+
)
|
|
15
|
+
from stochops.protocols import (
|
|
16
|
+
ExecutionEngine,
|
|
17
|
+
ParametricModel,
|
|
18
|
+
ResultSink,
|
|
19
|
+
Sampler,
|
|
20
|
+
)
|
|
21
|
+
from stochops.samplers.lhs import LHSSampler
|
|
22
|
+
from stochops.samplers.mc import MCSampler
|
|
23
|
+
from stochops.sinks.parquet import ParquetSink
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = version("stochops")
|
|
27
|
+
except PackageNotFoundError: # pragma: no cover
|
|
28
|
+
__version__ = "0.0.0-dev"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Core Orchestration
|
|
32
|
+
"SimulationBuilder",
|
|
33
|
+
"SimulationEngine",
|
|
34
|
+
# Parameter Definitions
|
|
35
|
+
"BaseParameter",
|
|
36
|
+
"ContinuousParam",
|
|
37
|
+
"DiscreteParam",
|
|
38
|
+
"Distribution",
|
|
39
|
+
"GroundMotionSetParam",
|
|
40
|
+
# Sampling Strategies
|
|
41
|
+
"LHSSampler",
|
|
42
|
+
"MCSampler",
|
|
43
|
+
# Parallel Execution Engines
|
|
44
|
+
"ProcessPoolEngine",
|
|
45
|
+
# Data Persistence Sinks
|
|
46
|
+
"ParquetSink",
|
|
47
|
+
# Adaptive Solver Utilities
|
|
48
|
+
"AdaptiveAnalysisRunner",
|
|
49
|
+
"ConvergenceError",
|
|
50
|
+
# Strategy Protocols
|
|
51
|
+
"ParametricModel",
|
|
52
|
+
"Sampler",
|
|
53
|
+
"ExecutionEngine",
|
|
54
|
+
"ResultSink",
|
|
55
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Adaptive solver fallback and automated substepping for OpenSeesPy analyses."""
|
|
2
|
+
|
|
3
|
+
import openseespy.opensees as ops
|
|
4
|
+
|
|
5
|
+
from stochops.exceptions import ConvergenceError
|
|
6
|
+
|
|
7
|
+
__all__ = ["AdaptiveAnalysisRunner", "ConvergenceError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AdaptiveAnalysisRunner:
|
|
11
|
+
"""Orchestrates adaptive algorithm switching and recursive substepping during OpenSeesPy analyses.
|
|
12
|
+
|
|
13
|
+
Intercepts numerical divergence during transient step evaluations by cycling through alternative
|
|
14
|
+
solution algorithms and executing recursive step-halving before failing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
algorithms: list[str] | None = None,
|
|
20
|
+
enable_substepping: bool = True,
|
|
21
|
+
max_substep_depth: int = 4,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Initializes the AdaptiveAnalysisRunner.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
algorithms: Ordered list of OpenSees solution algorithms to attempt upon non-convergence.
|
|
27
|
+
Defaults to ["Newton", "KrylovNewton", "NewtonWithLineSearch", "BFGS", "Broyden"].
|
|
28
|
+
enable_substepping: Whether to execute recursive step-halving if all algorithms fail.
|
|
29
|
+
max_substep_depth: Maximum recursion depth for substepping (depth 4 = dt / 16).
|
|
30
|
+
"""
|
|
31
|
+
self.algorithms = algorithms or [
|
|
32
|
+
"Newton",
|
|
33
|
+
"KrylovNewton",
|
|
34
|
+
"NewtonWithLineSearch",
|
|
35
|
+
"BFGS",
|
|
36
|
+
"Broyden",
|
|
37
|
+
]
|
|
38
|
+
self.enable_substepping = enable_substepping
|
|
39
|
+
self.max_substep_depth = max_substep_depth
|
|
40
|
+
self.primary_algorithm = self.algorithms[0]
|
|
41
|
+
|
|
42
|
+
def analyze_step(self, dt: float) -> None:
|
|
43
|
+
"""Executes a single dynamic transient analysis step with adaptive retries.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
dt: Time step increment for the analysis step.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ConvergenceError: If all algorithm options and substepping levels fail to converge.
|
|
50
|
+
"""
|
|
51
|
+
# 1. Attempt primary algorithm
|
|
52
|
+
ops.algorithm(self.primary_algorithm)
|
|
53
|
+
ok = ops.analyze(1, dt)
|
|
54
|
+
if ok == 0:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# 2. Fallback sequence: try alternative solution algorithms
|
|
58
|
+
for fallback_alg in self.algorithms[1:]:
|
|
59
|
+
ops.algorithm(fallback_alg)
|
|
60
|
+
ok = ops.analyze(1, dt)
|
|
61
|
+
if ok == 0:
|
|
62
|
+
# Reset algorithm to primary for subsequent steps
|
|
63
|
+
ops.algorithm(self.primary_algorithm)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# 3. Fallback sequence: recursive step-halving
|
|
67
|
+
if self.enable_substepping:
|
|
68
|
+
self._execute_substep(dt / 2.0, current_depth=1)
|
|
69
|
+
ops.algorithm(self.primary_algorithm)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
raise ConvergenceError(
|
|
73
|
+
f"Analysis step failed to converge at dt={dt} across all candidate algorithms."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _execute_substep(self, reduced_dt: float, current_depth: int) -> None:
|
|
77
|
+
"""Recursively splits a failing step into two half-steps."""
|
|
78
|
+
if current_depth > self.max_substep_depth:
|
|
79
|
+
raise ConvergenceError(
|
|
80
|
+
f"Maximum substepping depth ({self.max_substep_depth}) exceeded at dt={reduced_dt}."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Execute two half-steps to cover the original step interval
|
|
84
|
+
for _ in range(2):
|
|
85
|
+
try:
|
|
86
|
+
self.analyze_step(reduced_dt)
|
|
87
|
+
except ConvergenceError:
|
|
88
|
+
# If a half-step fails, recurse deeper
|
|
89
|
+
self._execute_substep(reduced_dt / 2.0, current_depth + 1)
|
stochops/builder.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Simulation builder and engine orchestration for stochops."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from stochops.protocols import (
|
|
7
|
+
ExecutionEngine,
|
|
8
|
+
ParametricModel,
|
|
9
|
+
ResultSink,
|
|
10
|
+
Sampler,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SimulationEngine:
|
|
15
|
+
"""Orchestrates batch generation, execution dispatch, and data streaming."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
model: ParametricModel | Callable[[dict[str, Any]], dict[str, Any]],
|
|
20
|
+
sampler: Sampler,
|
|
21
|
+
execution_engine: ExecutionEngine,
|
|
22
|
+
sink: ResultSink,
|
|
23
|
+
batch_size: int = 100,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.model = model
|
|
26
|
+
self.sampler = sampler
|
|
27
|
+
self.execution_engine = execution_engine
|
|
28
|
+
self.sink = sink
|
|
29
|
+
self.batch_size = batch_size
|
|
30
|
+
|
|
31
|
+
def run(self, total_samples: int) -> None:
|
|
32
|
+
"""Executes the simulation pipeline across total_samples in chunks of batch_size.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
total_samples: Total number of Monte Carlo / LHS realizations to run.
|
|
36
|
+
"""
|
|
37
|
+
if total_samples <= 0:
|
|
38
|
+
raise ValueError("total_samples must be greater than zero.")
|
|
39
|
+
|
|
40
|
+
remaining = total_samples
|
|
41
|
+
try:
|
|
42
|
+
while remaining > 0:
|
|
43
|
+
current_batch_size = min(self.batch_size, remaining)
|
|
44
|
+
|
|
45
|
+
# 1. Generate sample realizations
|
|
46
|
+
samples = self.sampler.sample(current_batch_size)
|
|
47
|
+
|
|
48
|
+
# 2. Dispatch batch across worker processes
|
|
49
|
+
results = self.execution_engine.run_batch(self.model, samples)
|
|
50
|
+
|
|
51
|
+
# 3. Stream results to persistent sink
|
|
52
|
+
self.sink.write(results)
|
|
53
|
+
|
|
54
|
+
remaining -= current_batch_size
|
|
55
|
+
finally:
|
|
56
|
+
# Always ensure storage buffers are flushed, file locks released,
|
|
57
|
+
# and long-lived worker resources are shut down.
|
|
58
|
+
self.sink.close()
|
|
59
|
+
self.execution_engine.close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SimulationBuilder:
|
|
63
|
+
"""Fluent builder for constructing an immutable SimulationEngine instance."""
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self._model: (
|
|
67
|
+
ParametricModel | Callable[[dict[str, Any]], dict[str, Any]] | None
|
|
68
|
+
) = None
|
|
69
|
+
self._sampler: Sampler | None = None
|
|
70
|
+
self._execution_engine: ExecutionEngine | None = None
|
|
71
|
+
self._sink: ResultSink | None = None
|
|
72
|
+
self._batch_size: int = 100
|
|
73
|
+
|
|
74
|
+
def model(
|
|
75
|
+
self, model: ParametricModel | Callable[[dict[str, Any]], dict[str, Any]]
|
|
76
|
+
) -> "SimulationBuilder":
|
|
77
|
+
"""Sets the structural model evaluation callback."""
|
|
78
|
+
self._model = model
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def sampler(self, sampler: Sampler) -> "SimulationBuilder":
|
|
82
|
+
"""Sets the sampling strategy (e.g., LHSSampler)."""
|
|
83
|
+
self._sampler = sampler
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
def execution_engine(
|
|
87
|
+
self, engine: ExecutionEngine
|
|
88
|
+
) -> "SimulationBuilder":
|
|
89
|
+
"""Sets the parallel dispatch engine (e.g., ProcessPoolEngine)."""
|
|
90
|
+
self._execution_engine = engine
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def sink(self, sink: ResultSink) -> "SimulationBuilder":
|
|
94
|
+
"""Sets the data persistence sink (e.g., ParquetSink)."""
|
|
95
|
+
self._sink = sink
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def batch_size(self, batch_size: int) -> "SimulationBuilder":
|
|
99
|
+
"""Sets the chunk size for batch processing and memory management."""
|
|
100
|
+
if batch_size <= 0:
|
|
101
|
+
raise ValueError("batch_size must be greater than zero.")
|
|
102
|
+
self._batch_size = batch_size
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def build(self) -> SimulationEngine:
|
|
106
|
+
"""Validates strategy protocol slots and constructs the SimulationEngine.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ValueError: If any required strategy (model, sampler, engine, sink) is missing.
|
|
110
|
+
"""
|
|
111
|
+
missing_slots: list[str] = []
|
|
112
|
+
|
|
113
|
+
if self._model is None:
|
|
114
|
+
missing_slots.append("model (via .model())")
|
|
115
|
+
if self._sampler is None:
|
|
116
|
+
missing_slots.append("sampler (via .sampler())")
|
|
117
|
+
if self._execution_engine is None:
|
|
118
|
+
missing_slots.append("execution_engine (via .execution_engine())")
|
|
119
|
+
if self._sink is None:
|
|
120
|
+
missing_slots.append("sink (via .sink())")
|
|
121
|
+
|
|
122
|
+
if missing_slots:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"Cannot build SimulationEngine. Missing required strategy slots: "
|
|
125
|
+
+ ", ".join(missing_slots)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Assert non-None for mypy strict checking after validation
|
|
129
|
+
assert self._model is not None
|
|
130
|
+
assert self._sampler is not None
|
|
131
|
+
assert self._execution_engine is not None
|
|
132
|
+
assert self._sink is not None
|
|
133
|
+
|
|
134
|
+
return SimulationEngine(
|
|
135
|
+
model=self._model,
|
|
136
|
+
sampler=self._sampler,
|
|
137
|
+
execution_engine=self._execution_engine,
|
|
138
|
+
sink=self._sink,
|
|
139
|
+
batch_size=self._batch_size,
|
|
140
|
+
)
|
stochops/exceptions.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Process pool execution engine implementation for stochops.
|
|
2
|
+
|
|
3
|
+
Provides process-isolated parallel evaluation using Python's multiprocessing spawn context
|
|
4
|
+
to prevent C++ static memory contamination and segfaults in OpenSeesPy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import multiprocessing as mp
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from concurrent.futures import BrokenExecutor, ProcessPoolExecutor
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from stochops.exceptions import ConvergenceError
|
|
13
|
+
from stochops.protocols.model import ParametricModel
|
|
14
|
+
|
|
15
|
+
# Tokens used to conservatively classify failures thrown by user models as
|
|
16
|
+
# solver divergences even when the raised exception type is unknown to stochops.
|
|
17
|
+
_DIVERGENCE_TOKENS = ("convergence", "converge", "krylov", "substep", "step failed")
|
|
18
|
+
_MAX_RETRIES = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _worker_entrypoint(
|
|
22
|
+
evaluator: ParametricModel | Callable[[dict[str, Any]], dict[str, Any]],
|
|
23
|
+
sample_id: int,
|
|
24
|
+
sample: dict[str, Any],
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
"""Top-level worker execution wrapper for process boundary isolation.
|
|
27
|
+
|
|
28
|
+
Traps model execution exceptions (e.g. ConvergenceError, runtime errors) to ensure
|
|
29
|
+
individual realization failures do not crash the parallel worker pool.
|
|
30
|
+
"""
|
|
31
|
+
record: dict[str, Any] = {
|
|
32
|
+
"sample_id": sample_id,
|
|
33
|
+
**sample,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
edps = evaluator(sample)
|
|
38
|
+
if not isinstance(edps, dict):
|
|
39
|
+
raise TypeError(f"Model evaluator must return a dictionary of EDPs, got {type(edps).__name__}")
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
**record,
|
|
43
|
+
"status": "CONVERGED",
|
|
44
|
+
"error_type": None,
|
|
45
|
+
"error_message": None,
|
|
46
|
+
**edps,
|
|
47
|
+
}
|
|
48
|
+
except Exception as err:
|
|
49
|
+
error_class = type(err).__name__
|
|
50
|
+
status_flag = _classify_failure(err)
|
|
51
|
+
return {
|
|
52
|
+
**record,
|
|
53
|
+
"status": status_flag,
|
|
54
|
+
"error_type": error_class,
|
|
55
|
+
"error_message": str(err),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _classify_failure(err: Exception) -> str:
|
|
60
|
+
"""Classifies a trapped worker exception as ``DIVERGED`` or ``FAILED``.
|
|
61
|
+
|
|
62
|
+
Uses structural type checks (``isinstance``) against stochops' own
|
|
63
|
+
``ConvergenceError`` in preference to fragile string matching on exception
|
|
64
|
+
class names, with a conservative message-token fallback for exceptions
|
|
65
|
+
raised by user models that do not derive from stochops exceptions.
|
|
66
|
+
"""
|
|
67
|
+
if isinstance(err, ConvergenceError):
|
|
68
|
+
return "DIVERGED"
|
|
69
|
+
|
|
70
|
+
message = str(err).lower()
|
|
71
|
+
if any(token in message for token in _DIVERGENCE_TOKENS):
|
|
72
|
+
return "DIVERGED"
|
|
73
|
+
|
|
74
|
+
return "FAILED"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ProcessPoolEngine:
|
|
78
|
+
"""Parallel execution engine implementing the ExecutionEngine protocol.
|
|
79
|
+
|
|
80
|
+
Uses a spawned process pool executor to isolate each worker's memory space,
|
|
81
|
+
ensuring OpenSees C++ static variables do not leak across consecutive realizations.
|
|
82
|
+
|
|
83
|
+
The worker pool is created lazily on the first batch and reused across
|
|
84
|
+
subsequent batches, so OpenSeesPy is imported exactly once per worker
|
|
85
|
+
process. Call :meth:`close` to release the pool, or it is released
|
|
86
|
+
automatically when :class:`~stochops.builder.SimulationEngine.run` finishes.
|
|
87
|
+
|
|
88
|
+
``num_workers`` defaults to the system CPU count, which may be aggressive
|
|
89
|
+
for memory-heavy finite-element models; consider passing a lower value.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, num_workers: int | None = None) -> None:
|
|
93
|
+
"""Initializes the ProcessPoolEngine.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
num_workers: Number of parallel worker processes. Defaults to system CPU count.
|
|
97
|
+
"""
|
|
98
|
+
self.num_workers = num_workers or mp.cpu_count()
|
|
99
|
+
self._executor: ProcessPoolExecutor | None = None
|
|
100
|
+
|
|
101
|
+
def _get_executor(self) -> ProcessPoolExecutor:
|
|
102
|
+
"""Returns the lazily-created persistent process pool."""
|
|
103
|
+
if self._executor is None:
|
|
104
|
+
# Force 'spawn' context to guarantee clean process isolation for C++ libraries
|
|
105
|
+
ctx = mp.get_context("spawn")
|
|
106
|
+
self._executor = ProcessPoolExecutor(max_workers=self.num_workers, mp_context=ctx)
|
|
107
|
+
return self._executor
|
|
108
|
+
|
|
109
|
+
def run_batch(
|
|
110
|
+
self,
|
|
111
|
+
evaluator: ParametricModel | Callable[[dict[str, Any]], dict[str, Any]],
|
|
112
|
+
samples: list[dict[str, Any]],
|
|
113
|
+
) -> list[dict[str, Any]]:
|
|
114
|
+
"""Dispatches a batch of parameter realizations across the worker pool.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
evaluator: The model evaluation callback.
|
|
118
|
+
samples: List of parameter dictionaries to evaluate.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
List of result dictionaries containing execution metadata, input parameters,
|
|
122
|
+
and evaluated EDPs (or failure diagnostics).
|
|
123
|
+
"""
|
|
124
|
+
if not samples:
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
last_error: Exception | None = None
|
|
128
|
+
for attempt in range(_MAX_RETRIES + 1):
|
|
129
|
+
try:
|
|
130
|
+
executor = self._get_executor()
|
|
131
|
+
futures = [
|
|
132
|
+
executor.submit(_worker_entrypoint, evaluator, idx, sample)
|
|
133
|
+
for idx, sample in enumerate(samples)
|
|
134
|
+
]
|
|
135
|
+
return [future.result() for future in futures]
|
|
136
|
+
except BrokenExecutor as err:
|
|
137
|
+
# Worker crashed (e.g. segfault). Recreate the pool and retry once.
|
|
138
|
+
last_error = err
|
|
139
|
+
if attempt < _MAX_RETRIES and self._executor is not None:
|
|
140
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
141
|
+
self._executor = None
|
|
142
|
+
continue
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
raise RuntimeError("Process pool workers crashed repeatedly; batching aborted.") from last_error
|
|
146
|
+
|
|
147
|
+
def close(self) -> None:
|
|
148
|
+
"""Shuts down the persistent worker pool, releasing all worker processes."""
|
|
149
|
+
if self._executor is not None:
|
|
150
|
+
self._executor.shutdown(wait=True)
|
|
151
|
+
self._executor = None
|
|
File without changes
|
stochops/parameters.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Parameter definitions and distribution transformations for stochops."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy.stats import gumbel_r, lognorm, norm, uniform
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Distribution Enum
|
|
15
|
+
class Distribution(str, Enum):
|
|
16
|
+
NORMAL = "normal"
|
|
17
|
+
LOGNORMAL = "lognormal"
|
|
18
|
+
GUMBEL = "gumbel"
|
|
19
|
+
UNIFORM = "uniform"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Discrete Transformation Registry
|
|
23
|
+
TransformFunc = Callable[[float, float, float], float]
|
|
24
|
+
|
|
25
|
+
def _normal_ppf(u: float, mean: float, std: float) -> float:
|
|
26
|
+
return float(norm.ppf(u, loc=mean, scale=std))
|
|
27
|
+
|
|
28
|
+
def _lognormal_ppf(u: float, mean: float, std: float) -> float:
|
|
29
|
+
shape = np.sqrt(np.log(1.0 + (std / mean) ** 2))
|
|
30
|
+
scale = mean / np.sqrt(1.0 + (std / mean) ** 2)
|
|
31
|
+
return float(lognorm.ppf(u, s=shape, scale=scale))
|
|
32
|
+
|
|
33
|
+
def _gumbel_ppf(u: float, mean: float, std: float) -> float:
|
|
34
|
+
beta = std * np.sqrt(6) / np.pi
|
|
35
|
+
mu = mean - 0.5772156649 * beta
|
|
36
|
+
return float(gumbel_r.ppf(u, loc=mu, scale=beta))
|
|
37
|
+
|
|
38
|
+
def _uniform_ppf(u: float, mean: float, std: float) -> float:
|
|
39
|
+
low = mean - np.sqrt(3) * std
|
|
40
|
+
high = mean + np.sqrt(3) * std
|
|
41
|
+
return float(uniform.ppf(u, loc=low, scale=high - low))
|
|
42
|
+
|
|
43
|
+
DIST_REGISTRY: dict[Distribution, TransformFunc] = {
|
|
44
|
+
Distribution.NORMAL: _normal_ppf,
|
|
45
|
+
Distribution.LOGNORMAL: _lognormal_ppf,
|
|
46
|
+
Distribution.GUMBEL: _gumbel_ppf,
|
|
47
|
+
Distribution.UNIFORM: _uniform_ppf,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# 3. Base Parameter Contract
|
|
52
|
+
class BaseParameter(ABC):
|
|
53
|
+
name: str
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def transform_unit_draw(self, u: float) -> Any:
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# 4. Lean Continuous Parameter Class
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ContinuousParam(BaseParameter):
|
|
63
|
+
name: str
|
|
64
|
+
dist: Distribution
|
|
65
|
+
mean: float
|
|
66
|
+
std: float
|
|
67
|
+
max_value: float | None = None
|
|
68
|
+
min_value: float | None = None
|
|
69
|
+
|
|
70
|
+
def transform_unit_draw(self, u: float) -> float:
|
|
71
|
+
u_clamped = float(np.clip(u, 1e-9, 1.0 - 1e-9))
|
|
72
|
+
|
|
73
|
+
# O(1) Registry Dispatch
|
|
74
|
+
transform_fn = DIST_REGISTRY[self.dist] # Guaranteed valid by __post_init__
|
|
75
|
+
val = transform_fn(u_clamped, self.mean, self.std)
|
|
76
|
+
|
|
77
|
+
if self.min_value is not None:
|
|
78
|
+
val = max(self.min_value, val)
|
|
79
|
+
if self.max_value is not None:
|
|
80
|
+
val = min(self.max_value, val)
|
|
81
|
+
|
|
82
|
+
return val
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class DiscreteParam(BaseParameter):
|
|
87
|
+
name: str
|
|
88
|
+
choices: list[Any]
|
|
89
|
+
probabilities: list[float] | None = None
|
|
90
|
+
|
|
91
|
+
def __post_init__(self) -> None:
|
|
92
|
+
if not self.choices:
|
|
93
|
+
raise ValueError("DiscreteParam choices cannot be empty.")
|
|
94
|
+
|
|
95
|
+
if self.probabilities is not None:
|
|
96
|
+
if len(self.probabilities) != len(self.choices):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"DiscreteParam probabilities length {len(self.probabilities)} "
|
|
99
|
+
f"does not match choices length {len(self.choices)}."
|
|
100
|
+
)
|
|
101
|
+
probs = np.asarray(self.probabilities, dtype=float)
|
|
102
|
+
if not np.all(np.isfinite(probs)) or np.any(probs < 0.0):
|
|
103
|
+
raise ValueError(
|
|
104
|
+
"DiscreteParam probabilities must be finite and non-negative."
|
|
105
|
+
)
|
|
106
|
+
if probs.sum() <= 0.0:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"DiscreteParam probabilities must have a positive sum."
|
|
109
|
+
)
|
|
110
|
+
# Normalize so the probabilities sum to 1.0
|
|
111
|
+
normalized = (probs / probs.sum()).tolist()
|
|
112
|
+
object.__setattr__(self, "probabilities", normalized)
|
|
113
|
+
|
|
114
|
+
def transform_unit_draw(self, u: float) -> Any:
|
|
115
|
+
u_clamped = float(np.clip(u, 0.0, 1.0 - 1e-9))
|
|
116
|
+
if self.probabilities is None:
|
|
117
|
+
idx = min(int(np.floor(u_clamped * len(self.choices))), len(self.choices) - 1)
|
|
118
|
+
return self.choices[idx]
|
|
119
|
+
|
|
120
|
+
cdf = np.cumsum(self.probabilities)
|
|
121
|
+
idx = min(int(np.searchsorted(cdf, u_clamped, side="right")), len(self.choices) - 1)
|
|
122
|
+
return self.choices[idx]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class GroundMotionSetParam(BaseParameter):
|
|
127
|
+
name: str
|
|
128
|
+
records: list[Any]
|
|
129
|
+
|
|
130
|
+
def transform_unit_draw(self, u: float) -> Any:
|
|
131
|
+
u_clamped = float(np.clip(u, 0.0, 1.0 - 1e-9))
|
|
132
|
+
idx = min(int(np.floor(u_clamped * len(self.records))), len(self.records) - 1)
|
|
133
|
+
return self.records[idx]
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class Parameter:
|
|
137
|
+
"""Legacy simple parameter dataclass.
|
|
138
|
+
|
|
139
|
+
Deprecated in favour of :class:`ContinuousParam`. Kept for backwards
|
|
140
|
+
compatibility; constructing one emits a ``DeprecationWarning``.
|
|
141
|
+
"""
|
|
142
|
+
name: str
|
|
143
|
+
dist: Distribution
|
|
144
|
+
mean: float
|
|
145
|
+
std: float
|
|
146
|
+
|
|
147
|
+
def __post_init__(self) -> None:
|
|
148
|
+
warnings.warn(
|
|
149
|
+
"Parameter is deprecated; use ContinuousParam instead.",
|
|
150
|
+
DeprecationWarning,
|
|
151
|
+
stacklevel=2,
|
|
152
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from stochops.protocols.engine import ExecutionEngine
|
|
2
|
+
from stochops.protocols.model import ParametricModel
|
|
3
|
+
from stochops.protocols.sampler import Sampler
|
|
4
|
+
from stochops.protocols.sink import ResultSink
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ParametricModel",
|
|
8
|
+
"Sampler",
|
|
9
|
+
"ExecutionEngine",
|
|
10
|
+
"ResultSink",
|
|
11
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, Protocol, runtime_checkable
|
|
3
|
+
|
|
4
|
+
from stochops.protocols.model import ParametricModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class ExecutionEngine(Protocol):
|
|
9
|
+
"""Protocol for worker dispatch and isolation strategies."""
|
|
10
|
+
|
|
11
|
+
def run_batch(
|
|
12
|
+
self,
|
|
13
|
+
evaluator: ParametricModel | Callable[[dict[str, Any]], dict[str, Any]],
|
|
14
|
+
samples: list[dict[str, Any]],
|
|
15
|
+
) -> list[dict[str, Any]]:
|
|
16
|
+
"""Dispatches a batch of samples across worker processes.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
evaluator: The model evaluation callable.
|
|
20
|
+
samples: List of parameter dictionaries to evaluate.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
List of result dictionaries containing execution metadata, parameters,
|
|
24
|
+
and evaluated EDPs.
|
|
25
|
+
"""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
"""Releases any long-lived worker resources held by the engine.
|
|
30
|
+
|
|
31
|
+
Called once by the simulation engine after a campaign finishes.
|
|
32
|
+
Implementations must be safe to call multiple times and must be a
|
|
33
|
+
no-op if no resources were ever allocated.
|
|
34
|
+
"""
|
|
35
|
+
...
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Any, Protocol, runtime_checkable
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@runtime_checkable
|
|
5
|
+
class ParametricModel(Protocol):
|
|
6
|
+
"""Protocol for user-defined structural evaluation callbacks."""
|
|
7
|
+
|
|
8
|
+
def __call__(self, parameters: dict[str, Any]) -> dict[str, Any]:
|
|
9
|
+
"""Executes a single structural realization given input parameters.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
parameters: Dictionary mapping parameter names to sampled physical values
|
|
13
|
+
(e.g., {"fc": 30e6, "fy": 420e6}).
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Dictionary of extracted Engineering Demand Parameters (EDPs)
|
|
17
|
+
(e.g., {"max_drift": 0.012, "base_shear_kN": 150.5}).
|
|
18
|
+
"""
|
|
19
|
+
...
|