SmartMDAO 1.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.
- smartmdao/__init__.py +24 -0
- smartmdao/cache.py +150 -0
- smartmdao/core.py +74 -0
- smartmdao/executor.py +90 -0
- smartmdao/logging_config.py +42 -0
- smartmdao/main.py +8 -0
- smartmdao/models.py +48 -0
- smartmdao/optimization.py +69 -0
- smartmdao/solvers.py +288 -0
- smartmdao/utils.py +22 -0
- smartmdao/visualization.py +313 -0
- smartmdao-1.1.0.dist-info/METADATA +138 -0
- smartmdao-1.1.0.dist-info/RECORD +15 -0
- smartmdao-1.1.0.dist-info/WHEEL +4 -0
- smartmdao-1.1.0.dist-info/licenses/LICENSE +7 -0
smartmdao/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .core import Pipeline
|
|
2
|
+
from .models import Step
|
|
3
|
+
from .solvers import Solver, DAGSolver, IterativeSolver, HybridSolver
|
|
4
|
+
from .cache import cached, MemoryBackend, HistoryBackend, HDF5Backend, PickleDiskBackend
|
|
5
|
+
from .logging_config import configure_logging
|
|
6
|
+
|
|
7
|
+
# Expose the configuration helper so users can easily do:
|
|
8
|
+
# import pipeline; pipeline.configure_logging()
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Pipeline",
|
|
12
|
+
"Step",
|
|
13
|
+
"Solver",
|
|
14
|
+
"DAGSolver",
|
|
15
|
+
"IterativeSolver",
|
|
16
|
+
"HybridSolver",
|
|
17
|
+
"cached",
|
|
18
|
+
"MemoryBackend",
|
|
19
|
+
"HistoryBackend",
|
|
20
|
+
"HDF5Backend",
|
|
21
|
+
"PickleDiskBackend",
|
|
22
|
+
"configure_logging",
|
|
23
|
+
"PipelineEvaluator"
|
|
24
|
+
]
|
smartmdao/cache.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import hashlib
|
|
3
|
+
import pickle
|
|
4
|
+
import os
|
|
5
|
+
import logging
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
|
|
9
|
+
# Initialize module-level logger
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# --- 1. Abstract Backend Interface ---
|
|
13
|
+
class CacheBackend(ABC):
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def get(self, func_name, key):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def set(self, func_name, key, value):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def has(self, func_name, key):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
# --- 2. In-Memory Backend (Dictionary) ---
|
|
27
|
+
class MemoryBackend(CacheBackend):
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.store = {}
|
|
30
|
+
|
|
31
|
+
def _make_key(self, func_name, key):
|
|
32
|
+
return f"{func_name}::{key}"
|
|
33
|
+
|
|
34
|
+
def has(self, func_name, key):
|
|
35
|
+
return self._make_key(func_name, key) in self.store
|
|
36
|
+
|
|
37
|
+
def get(self, func_name, key):
|
|
38
|
+
logger.debug(f"[Memory] Cache hit for {func_name}")
|
|
39
|
+
return self.store[self._make_key(func_name, key)]
|
|
40
|
+
|
|
41
|
+
def set(self, func_name, key, value):
|
|
42
|
+
self.store[self._make_key(func_name, key)] = value
|
|
43
|
+
|
|
44
|
+
class HistoryBackend(MemoryBackend):
|
|
45
|
+
"""
|
|
46
|
+
A simple extension of MemoryBackend that keeps a chronological
|
|
47
|
+
list of all values computed by the cached functions.
|
|
48
|
+
"""
|
|
49
|
+
def __init__(self):
|
|
50
|
+
super().__init__()
|
|
51
|
+
# Dictionary mapping function_name -> list of values
|
|
52
|
+
self.history = defaultdict(list)
|
|
53
|
+
|
|
54
|
+
def set(self, func_name, key, value):
|
|
55
|
+
# 1. Store in the standard cache (MemoryBackend logic)
|
|
56
|
+
super().set(func_name, key, value)
|
|
57
|
+
|
|
58
|
+
# 2. Append to our history list for plotting
|
|
59
|
+
self.history[func_name].append(value)
|
|
60
|
+
|
|
61
|
+
# --- 3. HDF5 Backend ---
|
|
62
|
+
class HDF5Backend(CacheBackend):
|
|
63
|
+
"""
|
|
64
|
+
Best for Large Numpy Arrays.
|
|
65
|
+
Limitation: Can only store data types HDF5 supports (scalars, strings, numpy arrays).
|
|
66
|
+
For generic Python objects (classes, dicts), use Pickle instead.
|
|
67
|
+
"""
|
|
68
|
+
def __init__(self, filepath):
|
|
69
|
+
self.filepath = filepath
|
|
70
|
+
import h5py
|
|
71
|
+
self.h5py = h5py # lazy import
|
|
72
|
+
|
|
73
|
+
def has(self, func_name, key):
|
|
74
|
+
if not os.path.exists(self.filepath):
|
|
75
|
+
return False
|
|
76
|
+
with self.h5py.File(self.filepath, 'r') as f:
|
|
77
|
+
return f"{func_name}/{key}" in f
|
|
78
|
+
|
|
79
|
+
def get(self, func_name, key):
|
|
80
|
+
logger.debug(f"[HDF5] Cache hit for {func_name}")
|
|
81
|
+
with self.h5py.File(self.filepath, 'r') as f:
|
|
82
|
+
dataset = f[f"{func_name}/{key}"]
|
|
83
|
+
# Convert back to numpy or scalar
|
|
84
|
+
if dataset.shape == ():
|
|
85
|
+
return dataset[()] # scalar
|
|
86
|
+
return dataset[:] # array
|
|
87
|
+
|
|
88
|
+
def set(self, func_name, key, value):
|
|
89
|
+
with self.h5py.File(self.filepath, 'a') as f:
|
|
90
|
+
group_path = f"{func_name}"
|
|
91
|
+
if group_path not in f:
|
|
92
|
+
f.create_group(group_path)
|
|
93
|
+
|
|
94
|
+
# Delete if exists to overwrite
|
|
95
|
+
if key in f[group_path]:
|
|
96
|
+
del f[group_path][key]
|
|
97
|
+
|
|
98
|
+
f[group_path].create_dataset(key, data=value)
|
|
99
|
+
|
|
100
|
+
class PickleDiskBackend(CacheBackend):
|
|
101
|
+
def __init__(self, directory="cache_dir"):
|
|
102
|
+
self.directory = directory
|
|
103
|
+
os.makedirs(directory, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
def _path(self, func_name, key):
|
|
106
|
+
return os.path.join(self.directory, f"{func_name}_{key}.pkl")
|
|
107
|
+
|
|
108
|
+
def has(self, func_name, key):
|
|
109
|
+
return os.path.exists(self._path(func_name, key))
|
|
110
|
+
|
|
111
|
+
def get(self, func_name, key):
|
|
112
|
+
logger.debug(f"[Pickle] Cache hit for {func_name}")
|
|
113
|
+
with open(self._path(func_name, key), 'rb') as f:
|
|
114
|
+
return pickle.load(f)
|
|
115
|
+
|
|
116
|
+
def set(self, func_name, key, value):
|
|
117
|
+
with open(self._path(func_name, key), 'wb') as f:
|
|
118
|
+
pickle.dump(value, f)
|
|
119
|
+
|
|
120
|
+
# --- 4. The Decorator ---
|
|
121
|
+
def generate_cache_key(kwargs):
|
|
122
|
+
"""
|
|
123
|
+
Creates a stable hash of the input arguments.
|
|
124
|
+
We use pickle to serialize args -> hash to handle complex types.
|
|
125
|
+
"""
|
|
126
|
+
# Sort kwargs to ensure order doesn't matter: f(a=1, b=2) == f(b=2, a=1)
|
|
127
|
+
sorted_items = sorted(kwargs.items())
|
|
128
|
+
serialized = pickle.dumps(sorted_items)
|
|
129
|
+
return hashlib.sha256(serialized).hexdigest()
|
|
130
|
+
|
|
131
|
+
def cached(backend: CacheBackend):
|
|
132
|
+
def decorator(fn):
|
|
133
|
+
@functools.wraps(fn)
|
|
134
|
+
def wrapper(**kwargs):
|
|
135
|
+
# 1. Generate Key based on function input
|
|
136
|
+
key = generate_cache_key(kwargs)
|
|
137
|
+
|
|
138
|
+
# 2. Check Backend
|
|
139
|
+
if backend.has(fn.__name__, key):
|
|
140
|
+
return backend.get(fn.__name__, key)
|
|
141
|
+
|
|
142
|
+
# 3. Run Function
|
|
143
|
+
logger.debug(f"Cache miss for {fn.__name__}. Executing...")
|
|
144
|
+
result = fn(**kwargs)
|
|
145
|
+
|
|
146
|
+
# 4. Save Result
|
|
147
|
+
backend.set(fn.__name__, key, result)
|
|
148
|
+
return result
|
|
149
|
+
return wrapper
|
|
150
|
+
return decorator
|
smartmdao/core.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Callable, List, Literal
|
|
4
|
+
|
|
5
|
+
from .models import Step
|
|
6
|
+
from .solvers import Solver, DAGSolver
|
|
7
|
+
from .visualization import visualize_pipeline
|
|
8
|
+
|
|
9
|
+
# Initialize module-level logger
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Pipeline:
|
|
14
|
+
steps: list[Step] = field(default_factory=list)
|
|
15
|
+
solver: Solver = field(default_factory=DAGSolver)
|
|
16
|
+
|
|
17
|
+
def add(self, fn: Callable, outputs: list[str] = None):
|
|
18
|
+
"""
|
|
19
|
+
Add a step to the pipeline.
|
|
20
|
+
:param fn: The function to execute.
|
|
21
|
+
:param outputs: Optional list of variable names this function produces.
|
|
22
|
+
"""
|
|
23
|
+
step = Step(fn, outputs)
|
|
24
|
+
self.steps.append(step)
|
|
25
|
+
logger.debug(f"Added step '{step.name}' to pipeline.")
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def step(self, fn: Callable = None, *, outputs: List[str] = None):
|
|
29
|
+
"""
|
|
30
|
+
Decorator to register a step.
|
|
31
|
+
"""
|
|
32
|
+
if fn is not None and callable(fn):
|
|
33
|
+
self.add(fn, outputs=outputs)
|
|
34
|
+
return fn
|
|
35
|
+
|
|
36
|
+
def wrapper(func):
|
|
37
|
+
self.add(func, outputs=outputs)
|
|
38
|
+
return func
|
|
39
|
+
|
|
40
|
+
return wrapper
|
|
41
|
+
|
|
42
|
+
def run(self, **inputs):
|
|
43
|
+
"""
|
|
44
|
+
Delegates the execution to the configured Solver.
|
|
45
|
+
"""
|
|
46
|
+
logger.info(f"Starting pipeline execution with {len(self.steps)} steps and inputs: {list(inputs.keys())}")
|
|
47
|
+
try:
|
|
48
|
+
result = self.solver.solve(self.steps, inputs)
|
|
49
|
+
logger.info("Pipeline execution completed successfully.")
|
|
50
|
+
return result
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.error(f"Pipeline execution failed: {e}")
|
|
53
|
+
raise
|
|
54
|
+
|
|
55
|
+
def visualize(self,
|
|
56
|
+
inputs: List[str] = None,
|
|
57
|
+
output_path: str = None,
|
|
58
|
+
orientation: Literal["TB", "LR"] = "TB",
|
|
59
|
+
graph_type: Literal["flow", "bipartite"] = "flow",
|
|
60
|
+
view: bool = True):
|
|
61
|
+
"""
|
|
62
|
+
Generates a Graphviz diagram of the pipeline.
|
|
63
|
+
"""
|
|
64
|
+
input_set = set(inputs or [])
|
|
65
|
+
logger.debug(f"Generating visualization ({graph_type}) for pipeline.")
|
|
66
|
+
|
|
67
|
+
visualize_pipeline(
|
|
68
|
+
steps=self.steps,
|
|
69
|
+
inputs=input_set,
|
|
70
|
+
output_path=output_path,
|
|
71
|
+
orientation=orientation,
|
|
72
|
+
graph_type=graph_type,
|
|
73
|
+
view=view
|
|
74
|
+
)
|
smartmdao/executor.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
from dataclasses import is_dataclass, asdict
|
|
4
|
+
from typing import Dict, Any
|
|
5
|
+
from .models import Step
|
|
6
|
+
|
|
7
|
+
# Initialize module-level logger
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class StepExecutor:
|
|
11
|
+
"""
|
|
12
|
+
Static helper responsible for binding arguments from memory
|
|
13
|
+
and updating memory with results.
|
|
14
|
+
"""
|
|
15
|
+
@staticmethod
|
|
16
|
+
def run_step(step: Step, memory: Dict[str, Any]):
|
|
17
|
+
logger.debug(f"Preparing to execute step '{step.name}'")
|
|
18
|
+
|
|
19
|
+
# Use the robust unwrapped signature to find expected parameters
|
|
20
|
+
sig = step.get_signature()
|
|
21
|
+
|
|
22
|
+
# 1. Bind Arguments
|
|
23
|
+
params = {}
|
|
24
|
+
missing_required = []
|
|
25
|
+
|
|
26
|
+
for name, param in sig.parameters.items():
|
|
27
|
+
if name in memory:
|
|
28
|
+
params[name] = memory[name]
|
|
29
|
+
elif param.default == inspect.Parameter.empty:
|
|
30
|
+
missing_required.append(name)
|
|
31
|
+
|
|
32
|
+
if missing_required:
|
|
33
|
+
error_msg = (f"Step '{step.name}' cannot run. Missing inputs: {missing_required}. "
|
|
34
|
+
f"Available in memory: {list(memory.keys())}")
|
|
35
|
+
logger.error(error_msg)
|
|
36
|
+
raise KeyError(error_msg)
|
|
37
|
+
|
|
38
|
+
# 2. Execute
|
|
39
|
+
try:
|
|
40
|
+
logger.debug(f"Invoking '{step.name}' with inputs: {list(params.keys())}")
|
|
41
|
+
result = step.fn(**params)
|
|
42
|
+
except Exception as e:
|
|
43
|
+
logger.error(f"Error executing step '{step.name}': {e}", exc_info=True)
|
|
44
|
+
raise RuntimeError(f"Error executing step '{step.name}': {e}") from e
|
|
45
|
+
|
|
46
|
+
# 3. Store Result
|
|
47
|
+
StepExecutor._update_memory(step, result, memory)
|
|
48
|
+
logger.debug(f"Finished step '{step.name}'.")
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _update_memory(step: Step, result: Any, memory: Dict[str, Any]):
|
|
52
|
+
output_keys = step.resolve_output_names()
|
|
53
|
+
|
|
54
|
+
if result is None:
|
|
55
|
+
logger.debug(f"Step '{step.name}' returned None. No outputs stored.")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# Case A: Explicit Manual Outputs (e.g. outputs=['a', 'b'])
|
|
59
|
+
if step.manual_outputs:
|
|
60
|
+
if len(output_keys) == 1:
|
|
61
|
+
memory[output_keys[0]] = result
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
# Handle Dictionary Return with Manual Outputs
|
|
65
|
+
if isinstance(result, dict):
|
|
66
|
+
for k in output_keys:
|
|
67
|
+
if k not in result:
|
|
68
|
+
logger.error(f"Step '{step.name}' missing output key '{k}' in returned dict.")
|
|
69
|
+
raise KeyError(f"Step '{step.name}' expected output key '{k}' but it was missing in returned dict.")
|
|
70
|
+
memory[k] = result[k]
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# Handle Tuple/List Return with Manual Outputs
|
|
74
|
+
if not isinstance(result, (list, tuple)):
|
|
75
|
+
raise TypeError(f"Step '{step.name}' expected iterable (or dict) output for keys {output_keys}, got {type(result)}")
|
|
76
|
+
|
|
77
|
+
if len(result) != len(output_keys):
|
|
78
|
+
raise ValueError(f"Step '{step.name}' returned {len(result)} items, expected {len(output_keys)}")
|
|
79
|
+
|
|
80
|
+
for k, v in zip(output_keys, result):
|
|
81
|
+
memory[k] = v
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Case B: Dataclass Expansion (Auto-unpacking based on type hint/runtime check)
|
|
85
|
+
if is_dataclass(result):
|
|
86
|
+
memory.update(asdict(result))
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
# Case C: Single Default Output
|
|
90
|
+
memory[output_keys[0]] = result
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
def configure_logging(
|
|
5
|
+
level: int = logging.INFO,
|
|
6
|
+
log_format: str = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
|
7
|
+
date_format: str = "%H:%M:%S"
|
|
8
|
+
) -> logging.Logger:
|
|
9
|
+
"""
|
|
10
|
+
Configures the root logger for the pipeline library.
|
|
11
|
+
|
|
12
|
+
This is a helper for the end-user. Library modules should NOT call this;
|
|
13
|
+
they should simply use `logging.getLogger(__name__)`.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
>>> from pipeline import configure_logging
|
|
17
|
+
>>> configure_logging(level=logging.DEBUG)
|
|
18
|
+
"""
|
|
19
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
20
|
+
formatter = logging.Formatter(log_format, datefmt=date_format)
|
|
21
|
+
handler.setFormatter(formatter)
|
|
22
|
+
|
|
23
|
+
# Get the library root logger (assuming the package is imported as 'pipeline' or similar)
|
|
24
|
+
# We configure the root logger to catch everything, or specific loggers if preferred.
|
|
25
|
+
root_logger = logging.getLogger()
|
|
26
|
+
root_logger.setLevel(level)
|
|
27
|
+
|
|
28
|
+
# Remove existing handlers to avoid duplicates if called multiple times
|
|
29
|
+
if root_logger.hasHandlers():
|
|
30
|
+
root_logger.handlers.clear()
|
|
31
|
+
|
|
32
|
+
root_logger.addHandler(handler)
|
|
33
|
+
return root_logger
|
|
34
|
+
|
|
35
|
+
def get_logger(name: str) -> logging.Logger:
|
|
36
|
+
"""
|
|
37
|
+
Factory to ensure every module gets a properly namespaced logger.
|
|
38
|
+
Includes a NullHandler by default so the library is silent unless configured.
|
|
39
|
+
"""
|
|
40
|
+
logger = logging.getLogger(name)
|
|
41
|
+
logger.addHandler(logging.NullHandler())
|
|
42
|
+
return logger
|
smartmdao/main.py
ADDED
smartmdao/models.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from dataclasses import dataclass, is_dataclass
|
|
3
|
+
from typing import Callable, Optional, List, get_type_hints
|
|
4
|
+
|
|
5
|
+
@dataclass(eq=False)
|
|
6
|
+
class Step:
|
|
7
|
+
"""
|
|
8
|
+
Represents a single node in the computation graph.
|
|
9
|
+
eq=False ensures hashability is based on object identity.
|
|
10
|
+
"""
|
|
11
|
+
fn: Callable
|
|
12
|
+
manual_outputs: Optional[List[str]] = None
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def name(self) -> str:
|
|
16
|
+
return self.fn.__name__
|
|
17
|
+
|
|
18
|
+
def get_signature(self) -> inspect.Signature:
|
|
19
|
+
"""
|
|
20
|
+
Robustly retrieves the signature of the underlying function,
|
|
21
|
+
peeling off any decorators (like @cached) to find the real inputs.
|
|
22
|
+
"""
|
|
23
|
+
original_fn = inspect.unwrap(self.fn)
|
|
24
|
+
return inspect.signature(original_fn)
|
|
25
|
+
|
|
26
|
+
def resolve_output_names(self) -> List[str]:
|
|
27
|
+
"""Determines variable names this step produces."""
|
|
28
|
+
if self.manual_outputs:
|
|
29
|
+
return self.manual_outputs
|
|
30
|
+
|
|
31
|
+
# FIX: Use get_type_hints to correctly resolve string annotations
|
|
32
|
+
# (common with 'from __future__ import annotations' or forward refs)
|
|
33
|
+
try:
|
|
34
|
+
original_fn = inspect.unwrap(self.fn)
|
|
35
|
+
hints = get_type_hints(original_fn)
|
|
36
|
+
ann = hints.get('return')
|
|
37
|
+
except Exception:
|
|
38
|
+
# Fallback to standard inspection if get_type_hints fails
|
|
39
|
+
# (e.g., closures without global context)
|
|
40
|
+
sig = self.get_signature()
|
|
41
|
+
ann = sig.return_annotation
|
|
42
|
+
|
|
43
|
+
# If the function returns a Dataclass, use field names
|
|
44
|
+
if isinstance(ann, type) and is_dataclass(ann):
|
|
45
|
+
return list(ann.__dataclass_fields__.keys())
|
|
46
|
+
|
|
47
|
+
# Default: use function name
|
|
48
|
+
return [self.name]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import List, Dict, Any, Callable
|
|
3
|
+
|
|
4
|
+
from .core import Pipeline
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
class PipelineEvaluator:
|
|
9
|
+
"""
|
|
10
|
+
A generic, stateful bridge to interface the Pipeline with external optimizers
|
|
11
|
+
(SciPy, OpenTURNS, PyOptSparse, etc.).
|
|
12
|
+
|
|
13
|
+
It caches the last evaluation to prevent redundant pipeline runs when optimizers
|
|
14
|
+
request objectives and constraints independently for the same state.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self,
|
|
17
|
+
pipeline: Pipeline,
|
|
18
|
+
design_vars: List[str],
|
|
19
|
+
constants: Dict[str, Any] = None):
|
|
20
|
+
"""
|
|
21
|
+
:param pipeline: The instantiated smartmdao.
|
|
22
|
+
:param design_vars: Ordered list of variable names corresponding to the optimizer's input array `x`.
|
|
23
|
+
:param constants: Optional dictionary of variables that remain fixed during optimization.
|
|
24
|
+
"""
|
|
25
|
+
self.pipeline = pipeline
|
|
26
|
+
self.design_vars = design_vars
|
|
27
|
+
self.constants = constants or {}
|
|
28
|
+
|
|
29
|
+
self.last_x = None
|
|
30
|
+
self.last_results = None
|
|
31
|
+
self.eval_count = 0
|
|
32
|
+
|
|
33
|
+
def evaluate(self, x) -> Dict[str, Any]:
|
|
34
|
+
"""Runs the pipeline if the design variables have changed."""
|
|
35
|
+
# Convert to tuple for hashable comparison
|
|
36
|
+
x_tuple = tuple(x)
|
|
37
|
+
|
|
38
|
+
if self.last_x != x_tuple:
|
|
39
|
+
self.eval_count += 1
|
|
40
|
+
|
|
41
|
+
# 1. Map the numeric array 'x' back to named variables
|
|
42
|
+
inputs = dict(zip(self.design_vars, x))
|
|
43
|
+
|
|
44
|
+
# 2. Inject constants
|
|
45
|
+
inputs.update(self.constants)
|
|
46
|
+
|
|
47
|
+
# 3. Execute
|
|
48
|
+
self.last_results = self.pipeline.run(**inputs)
|
|
49
|
+
self.last_x = x_tuple
|
|
50
|
+
|
|
51
|
+
return self.last_results
|
|
52
|
+
|
|
53
|
+
def get_objective(self, output_name: str) -> Callable:
|
|
54
|
+
"""
|
|
55
|
+
Factory method that returns a callable objective function for the optimizer.
|
|
56
|
+
"""
|
|
57
|
+
def _objective(x):
|
|
58
|
+
return self.evaluate(x)[output_name]
|
|
59
|
+
return _objective
|
|
60
|
+
|
|
61
|
+
def get_constraint(self, output_name: str, multiplier: float = 1.0) -> Callable:
|
|
62
|
+
"""
|
|
63
|
+
Factory method that returns a callable constraint function.
|
|
64
|
+
:param multiplier: Useful for flipping constraint signs.
|
|
65
|
+
(e.g., SciPy expects f(x) >= 0. If pipeline outputs f(x) <= 0, use multiplier=-1.0)
|
|
66
|
+
"""
|
|
67
|
+
def _constraint(x):
|
|
68
|
+
return multiplier * self.evaluate(x)[output_name]
|
|
69
|
+
return _constraint
|
smartmdao/solvers.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections import defaultdict, deque
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import List, Dict, Any, Set, Protocol, Optional
|
|
5
|
+
|
|
6
|
+
from .models import Step
|
|
7
|
+
from .executor import StepExecutor
|
|
8
|
+
|
|
9
|
+
# Initialize module-level logger
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
class Solver(Protocol):
|
|
13
|
+
"""Interface for execution logic."""
|
|
14
|
+
def solve(self, steps: List[Step], inputs: Dict[str, Any]) -> Dict[str, Any]:
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
class DAGSolver:
|
|
18
|
+
"""
|
|
19
|
+
Standard Topological Sort Solver.
|
|
20
|
+
Ideal for linear workflows.
|
|
21
|
+
"""
|
|
22
|
+
def solve(self, steps: List[Step], inputs: Dict[str, Any]) -> Dict[str, Any]:
|
|
23
|
+
logger.info("DAGSolver started.")
|
|
24
|
+
execution_order = self._topological_sort(steps, set(inputs.keys()))
|
|
25
|
+
logger.debug(f"Topological sort order: {[s.name for s in execution_order]}")
|
|
26
|
+
|
|
27
|
+
memory = inputs.copy()
|
|
28
|
+
|
|
29
|
+
for step in execution_order:
|
|
30
|
+
StepExecutor.run_step(step, memory)
|
|
31
|
+
|
|
32
|
+
return memory
|
|
33
|
+
|
|
34
|
+
def _topological_sort(self, steps: List[Step], input_keys: Set[str]) -> List[Step]:
|
|
35
|
+
producers_map = _map_producers(steps)
|
|
36
|
+
adj_list, indegree = _build_dependency_graph(steps, input_keys, producers_map)
|
|
37
|
+
|
|
38
|
+
# Kahn's Algorithm
|
|
39
|
+
queue = deque([s for s, deg in indegree.items() if deg == 0])
|
|
40
|
+
sorted_steps = []
|
|
41
|
+
|
|
42
|
+
while queue:
|
|
43
|
+
current = queue.popleft()
|
|
44
|
+
sorted_steps.append(current)
|
|
45
|
+
|
|
46
|
+
for neighbor in adj_list[current]:
|
|
47
|
+
indegree[neighbor] -= 1
|
|
48
|
+
if indegree[neighbor] == 0:
|
|
49
|
+
queue.append(neighbor)
|
|
50
|
+
|
|
51
|
+
if len(sorted_steps) != len(steps):
|
|
52
|
+
logger.error("Cycle detected in DAGSolver.")
|
|
53
|
+
raise ValueError("Cycle detected in pipeline. Use HybridSolver or IterativeSolver.")
|
|
54
|
+
|
|
55
|
+
return sorted_steps
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class IterativeSolver:
|
|
59
|
+
"""
|
|
60
|
+
Solves systems with feedback loops.
|
|
61
|
+
"""
|
|
62
|
+
max_iterations: int = 100
|
|
63
|
+
tolerance: float = 1e-6
|
|
64
|
+
target_var: Optional[str] = None
|
|
65
|
+
execution_order: Optional[List[str]] = None
|
|
66
|
+
|
|
67
|
+
def solve(self, steps: List[Step], inputs: Dict[str, Any]) -> Dict[str, Any]:
|
|
68
|
+
memory = inputs.copy()
|
|
69
|
+
residuals = []
|
|
70
|
+
|
|
71
|
+
run_sequence = self._determine_execution_order(steps)
|
|
72
|
+
logger.info(f"IterativeSolver started. Sequence: {[s.name for s in run_sequence]}")
|
|
73
|
+
|
|
74
|
+
# Identify variables produced by these steps (for auto-convergence)
|
|
75
|
+
produced_vars = set()
|
|
76
|
+
for s in steps:
|
|
77
|
+
produced_vars.update(s.resolve_output_names())
|
|
78
|
+
|
|
79
|
+
for i in range(self.max_iterations):
|
|
80
|
+
# Snapshot state for convergence check
|
|
81
|
+
prev_state = {k: memory.get(k) for k in produced_vars if k in memory}
|
|
82
|
+
|
|
83
|
+
# Execute
|
|
84
|
+
for step in run_sequence:
|
|
85
|
+
StepExecutor.run_step(step, memory)
|
|
86
|
+
|
|
87
|
+
# Check Convergence
|
|
88
|
+
diff = self._calculate_residual(prev_state, memory, produced_vars)
|
|
89
|
+
residuals.append(diff)
|
|
90
|
+
|
|
91
|
+
# Only break if we actually calculated a numeric difference (not inf)
|
|
92
|
+
if diff != float('inf') and diff < self.tolerance:
|
|
93
|
+
logger.info(f"Converged at iteration {i+1} with residual {diff:.6e}")
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
logger.debug(f"Iteration {i+1}: residual {diff:.6e}")
|
|
97
|
+
else:
|
|
98
|
+
logger.warning(f"Reached max_iterations ({self.max_iterations}) without converging. Last residual: {residuals[-1]:.6e}")
|
|
99
|
+
|
|
100
|
+
# Store residuals (append to potentially existing history from other cycles)
|
|
101
|
+
memory.setdefault('residual_history', []).append(residuals)
|
|
102
|
+
return memory
|
|
103
|
+
|
|
104
|
+
def _calculate_residual(self, prev_state: Dict, current_memory: Dict, produced_vars: Set[str]) -> float:
|
|
105
|
+
"""
|
|
106
|
+
Calculates the maximum change in variables.
|
|
107
|
+
"""
|
|
108
|
+
if self.target_var:
|
|
109
|
+
p = prev_state.get(self.target_var)
|
|
110
|
+
c = current_memory.get(self.target_var)
|
|
111
|
+
return abs(c - p) if (isinstance(p, (int, float)) and isinstance(c, (int, float))) else float('inf')
|
|
112
|
+
|
|
113
|
+
max_diff = 0.0
|
|
114
|
+
numeric_vars_found = False
|
|
115
|
+
|
|
116
|
+
for k in produced_vars:
|
|
117
|
+
p = prev_state.get(k)
|
|
118
|
+
c = current_memory.get(k)
|
|
119
|
+
|
|
120
|
+
# Strictly require both to be numeric
|
|
121
|
+
if isinstance(p, (int, float)) and isinstance(c, (int, float)):
|
|
122
|
+
diff = abs(c - p)
|
|
123
|
+
max_diff = max(max_diff, diff)
|
|
124
|
+
numeric_vars_found = True
|
|
125
|
+
|
|
126
|
+
if numeric_vars_found:
|
|
127
|
+
return max_diff
|
|
128
|
+
|
|
129
|
+
# If no numeric variables updated, we can't judge convergence numerically.
|
|
130
|
+
return float('inf')
|
|
131
|
+
|
|
132
|
+
def _determine_execution_order(self, steps: List[Step]) -> List[Step]:
|
|
133
|
+
if not self.execution_order:
|
|
134
|
+
return steps
|
|
135
|
+
|
|
136
|
+
step_map = {s.name: s for s in steps}
|
|
137
|
+
return [step_map[name] for name in self.execution_order if name in step_map]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class HybridSolver:
|
|
141
|
+
"""
|
|
142
|
+
Advanced solver that automatically decomposes the pipeline into
|
|
143
|
+
Linear (DAG) and Iterative (Cyclic) components (Strongly Connected Components).
|
|
144
|
+
"""
|
|
145
|
+
def __init__(self, max_iterations: int = 100, tolerance: float = 1e-6):
|
|
146
|
+
self.max_iterations = max_iterations
|
|
147
|
+
self.tolerance = tolerance
|
|
148
|
+
|
|
149
|
+
def solve(self, steps: List[Step], inputs: Dict[str, Any]) -> Dict[str, Any]:
|
|
150
|
+
logger.info("HybridSolver started.")
|
|
151
|
+
input_keys = set(inputs.keys())
|
|
152
|
+
producers_map = _map_producers(steps)
|
|
153
|
+
|
|
154
|
+
# 1. Build Adjacency Graph (Producer -> Consumer)
|
|
155
|
+
adj_list, _ = _build_dependency_graph(steps, input_keys, producers_map)
|
|
156
|
+
|
|
157
|
+
# 2. Find Strongly Connected Components (SCCs)
|
|
158
|
+
sccs = self._tarjan_scc(steps, adj_list)
|
|
159
|
+
logger.debug(f"Detected {len(sccs)} execution blocks (SCCs).")
|
|
160
|
+
|
|
161
|
+
# 3. Build Condensation Graph (DAG of SCCs)
|
|
162
|
+
scc_map = {step: i for i, cluster in enumerate(sccs) for step in cluster}
|
|
163
|
+
scc_adj = defaultdict(set)
|
|
164
|
+
scc_indegree = defaultdict(int)
|
|
165
|
+
|
|
166
|
+
for u in steps:
|
|
167
|
+
u_scc = scc_map[u]
|
|
168
|
+
for v in adj_list[u]:
|
|
169
|
+
v_scc = scc_map[v]
|
|
170
|
+
if u_scc != v_scc:
|
|
171
|
+
if v_scc not in scc_adj[u_scc]:
|
|
172
|
+
scc_adj[u_scc].add(v_scc)
|
|
173
|
+
scc_indegree[v_scc] += 1
|
|
174
|
+
|
|
175
|
+
# Ensure all SCCs have an entry
|
|
176
|
+
for i in range(len(sccs)):
|
|
177
|
+
if i not in scc_indegree:
|
|
178
|
+
scc_indegree[i] = 0
|
|
179
|
+
|
|
180
|
+
# 4. Topological Sort of SCCs
|
|
181
|
+
queue = deque([i for i, deg in scc_indegree.items() if deg == 0])
|
|
182
|
+
execution_plan = []
|
|
183
|
+
|
|
184
|
+
while queue:
|
|
185
|
+
current_scc_idx = queue.popleft()
|
|
186
|
+
execution_plan.append(sccs[current_scc_idx])
|
|
187
|
+
|
|
188
|
+
for neighbor_scc in scc_adj[current_scc_idx]:
|
|
189
|
+
scc_indegree[neighbor_scc] -= 1
|
|
190
|
+
if scc_indegree[neighbor_scc] == 0:
|
|
191
|
+
queue.append(neighbor_scc)
|
|
192
|
+
|
|
193
|
+
# 5. Execute
|
|
194
|
+
memory = inputs.copy()
|
|
195
|
+
|
|
196
|
+
for group in execution_plan:
|
|
197
|
+
# Case A: Linear
|
|
198
|
+
if len(group) == 1 and group[0] not in adj_list[group[0]]:
|
|
199
|
+
step = group[0]
|
|
200
|
+
StepExecutor.run_step(step, memory)
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
# Case B: Cyclic
|
|
204
|
+
# Sort alphabetically to ensure deterministic execution order within the cycle
|
|
205
|
+
group_sorted = sorted(group, key=lambda s: s.name)
|
|
206
|
+
|
|
207
|
+
logger.info(f"Cyclic Block Detected: {[s.name for s in group_sorted]}")
|
|
208
|
+
sub_solver = IterativeSolver(
|
|
209
|
+
max_iterations=self.max_iterations,
|
|
210
|
+
tolerance=self.tolerance
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
cycle_results = sub_solver.solve(group_sorted, memory)
|
|
214
|
+
memory.update(cycle_results)
|
|
215
|
+
|
|
216
|
+
return memory
|
|
217
|
+
|
|
218
|
+
def _tarjan_scc(self, steps: List[Step], adj_list: Dict[Step, List[Step]]) -> List[List[Step]]:
|
|
219
|
+
index = 0
|
|
220
|
+
indices = {}
|
|
221
|
+
lowlinks = {}
|
|
222
|
+
stack = []
|
|
223
|
+
on_stack = set()
|
|
224
|
+
sccs = []
|
|
225
|
+
|
|
226
|
+
def strongconnect(v):
|
|
227
|
+
nonlocal index
|
|
228
|
+
indices[v] = index
|
|
229
|
+
lowlinks[v] = index
|
|
230
|
+
index += 1
|
|
231
|
+
stack.append(v)
|
|
232
|
+
on_stack.add(v)
|
|
233
|
+
|
|
234
|
+
for w in adj_list[v]:
|
|
235
|
+
if w not in indices:
|
|
236
|
+
strongconnect(w)
|
|
237
|
+
lowlinks[v] = min(lowlinks[v], lowlinks[w])
|
|
238
|
+
elif w in on_stack:
|
|
239
|
+
lowlinks[v] = min(lowlinks[v], indices[w])
|
|
240
|
+
|
|
241
|
+
if lowlinks[v] == indices[v]:
|
|
242
|
+
new_scc = []
|
|
243
|
+
while True:
|
|
244
|
+
w = stack.pop()
|
|
245
|
+
on_stack.remove(w)
|
|
246
|
+
new_scc.append(w)
|
|
247
|
+
if w == v:
|
|
248
|
+
break
|
|
249
|
+
sccs.append(new_scc)
|
|
250
|
+
|
|
251
|
+
for step in steps:
|
|
252
|
+
if step not in indices:
|
|
253
|
+
strongconnect(step)
|
|
254
|
+
|
|
255
|
+
return sccs
|
|
256
|
+
|
|
257
|
+
# --- Helpers ---
|
|
258
|
+
|
|
259
|
+
def _map_producers(steps: List[Step]) -> Dict[str, Step]:
|
|
260
|
+
mapping = {}
|
|
261
|
+
for step in steps:
|
|
262
|
+
for out in step.resolve_output_names():
|
|
263
|
+
mapping[out] = step
|
|
264
|
+
return mapping
|
|
265
|
+
|
|
266
|
+
def _build_dependency_graph(steps: List[Step], input_keys: Set[str], producers_map: Dict[str, Step]):
|
|
267
|
+
adj_list = defaultdict(list)
|
|
268
|
+
indegree = defaultdict(int)
|
|
269
|
+
|
|
270
|
+
for s in steps:
|
|
271
|
+
indegree[s] = 0
|
|
272
|
+
|
|
273
|
+
for consumer in steps:
|
|
274
|
+
# --- FIX: Use .get_signature() to see through decorators ---
|
|
275
|
+
sig = consumer.get_signature()
|
|
276
|
+
for param in sig.parameters:
|
|
277
|
+
|
|
278
|
+
# PRIORITY FIX: Check if it's an internal producer FIRST.
|
|
279
|
+
if param in producers_map:
|
|
280
|
+
producer = producers_map[param]
|
|
281
|
+
adj_list[producer].append(consumer)
|
|
282
|
+
indegree[consumer] += 1
|
|
283
|
+
|
|
284
|
+
# Only if it's NOT produced internally do we check if it's satisfied by inputs.
|
|
285
|
+
elif param in input_keys:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
return adj_list, indegree
|
smartmdao/utils.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from dataclasses import is_dataclass
|
|
3
|
+
from typing import List
|
|
4
|
+
from .models import Step
|
|
5
|
+
|
|
6
|
+
def resolve_output_names(step: Step) -> List[str]:
|
|
7
|
+
"""
|
|
8
|
+
Determines the variable names a step produces.
|
|
9
|
+
It checks manual_outputs first, then type hints, then defaults to function name.
|
|
10
|
+
"""
|
|
11
|
+
if step.manual_outputs:
|
|
12
|
+
return step.manual_outputs
|
|
13
|
+
|
|
14
|
+
sig = inspect.signature(step.fn)
|
|
15
|
+
ann = sig.return_annotation
|
|
16
|
+
|
|
17
|
+
# If the return type is a Dataclass, use its field names
|
|
18
|
+
if isinstance(ann, type) and is_dataclass(ann):
|
|
19
|
+
return list(ann.__dataclass_fields__.keys())
|
|
20
|
+
|
|
21
|
+
# Default to the function name
|
|
22
|
+
return [step.name]
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List, Set, Dict, Literal, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
from .models import Step
|
|
6
|
+
|
|
7
|
+
# Initialize module-level logger
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
# Try importing graphviz; handle absence gracefully
|
|
11
|
+
try:
|
|
12
|
+
import graphviz
|
|
13
|
+
except ImportError:
|
|
14
|
+
graphviz = None
|
|
15
|
+
logger.warning("Graphviz not found. Visualization features will be unavailable.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PipelineVisualizer:
|
|
19
|
+
"""
|
|
20
|
+
A modern, modular visualizer for the Pipeline using Graphviz.
|
|
21
|
+
Focuses on standardizing workflow visualization with clear separation of concerns.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# --- Standard Palette (Material Design Pastels) ---
|
|
25
|
+
STYLE_INPUT = {
|
|
26
|
+
"shape": "parallelogram",
|
|
27
|
+
"style": "filled",
|
|
28
|
+
"fillcolor": "#E3F2FD", # Blue 50
|
|
29
|
+
"color": "#1565C0", # Blue 800
|
|
30
|
+
"penwidth": "1.5",
|
|
31
|
+
"margin": "0.2"
|
|
32
|
+
}
|
|
33
|
+
STYLE_STEP = {
|
|
34
|
+
"shape": "component",
|
|
35
|
+
"style": "filled",
|
|
36
|
+
"fillcolor": "#FFF3E0", # Orange 50
|
|
37
|
+
"color": "#EF6C00", # Orange 800
|
|
38
|
+
"penwidth": "1.5",
|
|
39
|
+
"margin": "0.3"
|
|
40
|
+
}
|
|
41
|
+
STYLE_INTERMEDIATE = {
|
|
42
|
+
"shape": "ellipse",
|
|
43
|
+
"style": "filled",
|
|
44
|
+
"fillcolor": "#F5F5F5", # Grey 100
|
|
45
|
+
"color": "#757575", # Grey 600
|
|
46
|
+
"penwidth": "1.0",
|
|
47
|
+
"height": "0.4"
|
|
48
|
+
}
|
|
49
|
+
STYLE_FINAL = {
|
|
50
|
+
"shape": "parallelogram",
|
|
51
|
+
"style": "filled",
|
|
52
|
+
"fillcolor": "#E8F5E9", # Green 50
|
|
53
|
+
"color": "#2E7D32", # Green 800
|
|
54
|
+
"penwidth": "2.0", # Thicker border for emphasis
|
|
55
|
+
"peripheries": "2", # Double border
|
|
56
|
+
"margin": "0.2"
|
|
57
|
+
}
|
|
58
|
+
STYLE_MISSING = {
|
|
59
|
+
"shape": "hexagon",
|
|
60
|
+
"style": "filled",
|
|
61
|
+
"fillcolor": "#FFEBEE", # Red 50
|
|
62
|
+
"color": "#C62828", # Red 800
|
|
63
|
+
"penwidth": "2.0"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
steps: List[Step],
|
|
69
|
+
input_keys: Set[str],
|
|
70
|
+
orientation: Literal['TB', 'LR'] = 'TB'
|
|
71
|
+
):
|
|
72
|
+
if graphviz is None:
|
|
73
|
+
raise ImportError(
|
|
74
|
+
"The 'graphviz' library is required for visualization. "
|
|
75
|
+
"Please install it using: pip install graphviz"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
self.steps = sorted(steps, key=lambda s: s.name)
|
|
79
|
+
self.input_keys = input_keys
|
|
80
|
+
self.orientation = orientation
|
|
81
|
+
|
|
82
|
+
# Initialize the graph
|
|
83
|
+
self.dot = graphviz.Digraph(comment='Pipeline Graph')
|
|
84
|
+
self._setup_graph_attributes()
|
|
85
|
+
|
|
86
|
+
def _setup_graph_attributes(self):
|
|
87
|
+
"""Configures global graph styling for a professional look."""
|
|
88
|
+
self.dot.attr(rankdir=self.orientation)
|
|
89
|
+
self.dot.attr(compound='true') # Allow edges between clusters
|
|
90
|
+
|
|
91
|
+
# Global typography
|
|
92
|
+
self.dot.attr('node', fontname='Helvetica', fontsize='11')
|
|
93
|
+
self.dot.attr('edge', fontname='Helvetica', fontsize='9', color='#616161')
|
|
94
|
+
|
|
95
|
+
# 'ortho' provides clean, rect-linear lines suitable for technical diagrams
|
|
96
|
+
# 'splines'='polyline' is also a good option if ortho gets messy.
|
|
97
|
+
self.dot.attr(splines='ortho')
|
|
98
|
+
|
|
99
|
+
def build(self, graph_type: Literal["flow", "bipartite"] = "flow") -> "PipelineVisualizer":
|
|
100
|
+
"""
|
|
101
|
+
Builds the nodes and edges.
|
|
102
|
+
Note: The 'bipartite' (Data Flow) view is recommended for detailed analysis
|
|
103
|
+
of Inputs vs Intermediates vs Finals.
|
|
104
|
+
"""
|
|
105
|
+
if graph_type == "bipartite":
|
|
106
|
+
self._build_bipartite_standard()
|
|
107
|
+
else:
|
|
108
|
+
self._build_flow_standard()
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def render(self, output_path: Optional[str] = None, view: bool = True):
|
|
112
|
+
"""
|
|
113
|
+
Renders the graph to a file or temporary view.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
if output_path:
|
|
117
|
+
filename, ext = os.path.splitext(output_path)
|
|
118
|
+
fmt = ext.lstrip('.').lower() if ext else 'pdf'
|
|
119
|
+
out_file = self.dot.render(filename, format=fmt, cleanup=True, view=view)
|
|
120
|
+
if not view:
|
|
121
|
+
logger.info(f"Pipeline diagram saved to: {out_file}")
|
|
122
|
+
else:
|
|
123
|
+
self.dot.view(cleanup=True)
|
|
124
|
+
logger.info("Pipeline diagram opened in viewer.")
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.error(f"Graph rendered successfully, but viewer failed: {e}")
|
|
127
|
+
if output_path:
|
|
128
|
+
logger.info(f"File saved at: {output_path}")
|
|
129
|
+
|
|
130
|
+
# --- Classification Logic ---
|
|
131
|
+
|
|
132
|
+
def _analyze_variables(self) -> Tuple[Set[str], Set[str], Set[str], Set[str], Dict[str, Step]]:
|
|
133
|
+
"""
|
|
134
|
+
Categorizes all variables in the pipeline.
|
|
135
|
+
Returns: (inputs, intermediates, finals, missing, producer_map)
|
|
136
|
+
"""
|
|
137
|
+
producers_map = {}
|
|
138
|
+
consumed = set()
|
|
139
|
+
produced = set()
|
|
140
|
+
|
|
141
|
+
for step in self.steps:
|
|
142
|
+
# Outputs
|
|
143
|
+
for out in step.resolve_output_names():
|
|
144
|
+
producers_map[out] = step
|
|
145
|
+
produced.add(out)
|
|
146
|
+
|
|
147
|
+
# Inputs (Use unwrapped signature)
|
|
148
|
+
sig = step.get_signature()
|
|
149
|
+
for param in sig.parameters:
|
|
150
|
+
consumed.add(param)
|
|
151
|
+
|
|
152
|
+
# 2. Categorize
|
|
153
|
+
# Inputs: Variables consumed but NOT produced internally.
|
|
154
|
+
# (We strictly use input_keys to validate, but graph logic relies on structural dependency)
|
|
155
|
+
real_inputs = {v for v in consumed if v not in produced}
|
|
156
|
+
|
|
157
|
+
# Missing: Required inputs that are NOT in the provided input_keys
|
|
158
|
+
missing = {v for v in real_inputs if v not in self.input_keys}
|
|
159
|
+
|
|
160
|
+
# Valid Inputs: Real inputs that exist in input_keys
|
|
161
|
+
valid_inputs = real_inputs.intersection(self.input_keys)
|
|
162
|
+
|
|
163
|
+
# Intermediates: Produced AND Consumed
|
|
164
|
+
intermediates = produced.intersection(consumed)
|
|
165
|
+
|
|
166
|
+
# Finals: Produced but NEVER Consumed
|
|
167
|
+
finals = produced - consumed
|
|
168
|
+
|
|
169
|
+
return valid_inputs, intermediates, finals, missing, producers_map
|
|
170
|
+
|
|
171
|
+
# --- Bipartite (Data Flow) Strategy ---
|
|
172
|
+
|
|
173
|
+
def _build_bipartite_standard(self):
|
|
174
|
+
"""
|
|
175
|
+
Constructs a Data Flow Diagram (DFD).
|
|
176
|
+
Strictly separates: Input Nodes -> Step Nodes -> Intermediate Nodes -> Step Nodes -> Final Nodes.
|
|
177
|
+
"""
|
|
178
|
+
inputs, intermediates, finals, missing, producers = self._analyze_variables()
|
|
179
|
+
|
|
180
|
+
# 1. Draw Inputs (Rank Source to force top/left)
|
|
181
|
+
with self.dot.subgraph(name='cluster_inputs') as c:
|
|
182
|
+
c.attr(rank='source', style='invis') # Invisible container for grouping
|
|
183
|
+
for var in inputs:
|
|
184
|
+
self._add_node(c, f"Var_{var}", var, self.STYLE_INPUT)
|
|
185
|
+
for var in missing:
|
|
186
|
+
self._add_node(c, f"Missing_{var}", f"{var} (?)", self.STYLE_MISSING)
|
|
187
|
+
|
|
188
|
+
# 2. Draw Finals (Rank Sink to force bottom/right)
|
|
189
|
+
with self.dot.subgraph(name='cluster_finals') as c:
|
|
190
|
+
c.attr(rank='sink', style='invis')
|
|
191
|
+
for var in finals:
|
|
192
|
+
self._add_node(c, f"Var_{var}", var, self.STYLE_FINAL)
|
|
193
|
+
|
|
194
|
+
# 3. Draw Intermediates
|
|
195
|
+
for var in intermediates:
|
|
196
|
+
self._add_node(self.dot, f"Var_{var}", var, self.STYLE_INTERMEDIATE)
|
|
197
|
+
|
|
198
|
+
# 4. Draw Steps
|
|
199
|
+
for step in self.steps:
|
|
200
|
+
self._add_step_node(self.dot, step)
|
|
201
|
+
|
|
202
|
+
# 5. Draw Edges
|
|
203
|
+
for step in self.steps:
|
|
204
|
+
step_id = self._node_id(step)
|
|
205
|
+
sig = step.get_signature()
|
|
206
|
+
|
|
207
|
+
# Inputs to Step
|
|
208
|
+
for param in sig.parameters:
|
|
209
|
+
if param in missing:
|
|
210
|
+
self.dot.edge(f"Missing_{param}", step_id, style="dotted", color="#D32F2F")
|
|
211
|
+
else:
|
|
212
|
+
# It's either a valid input or an intermediate/produced var
|
|
213
|
+
var_id = f"Var_{param}"
|
|
214
|
+
self.dot.edge(var_id, step_id)
|
|
215
|
+
|
|
216
|
+
# Step to Outputs
|
|
217
|
+
for out in step.resolve_output_names():
|
|
218
|
+
var_id = f"Var_{out}"
|
|
219
|
+
self.dot.edge(step_id, var_id)
|
|
220
|
+
|
|
221
|
+
# --- Flow Strategy (Process Flow) ---
|
|
222
|
+
|
|
223
|
+
def _build_flow_standard(self):
|
|
224
|
+
"""
|
|
225
|
+
Constructs a Process Flow Diagram.
|
|
226
|
+
Focuses on Steps. Data is shown as explicit nodes ONLY if it is an Input or Final Output.
|
|
227
|
+
Intermediates are labels on edges.
|
|
228
|
+
"""
|
|
229
|
+
inputs, intermediates, finals, missing, producers = self._analyze_variables()
|
|
230
|
+
step_indices = {step: i for i, step in enumerate(self.steps)}
|
|
231
|
+
|
|
232
|
+
# 1. Draw Inputs
|
|
233
|
+
with self.dot.subgraph(name='cluster_inputs') as c:
|
|
234
|
+
c.attr(rank='source', style='invis')
|
|
235
|
+
for var in inputs:
|
|
236
|
+
self._add_node(c, f"Input_{var}", var, self.STYLE_INPUT)
|
|
237
|
+
for var in missing:
|
|
238
|
+
self._add_node(c, f"Missing_{var}", f"{var} (?)", self.STYLE_MISSING)
|
|
239
|
+
|
|
240
|
+
# 2. Draw Finals
|
|
241
|
+
with self.dot.subgraph(name='cluster_finals') as c:
|
|
242
|
+
c.attr(rank='sink', style='invis')
|
|
243
|
+
for var in finals:
|
|
244
|
+
# Note: In flow view, we link the step directly to this final node
|
|
245
|
+
self._add_node(c, f"Final_{var}", var, self.STYLE_FINAL)
|
|
246
|
+
|
|
247
|
+
# 3. Draw Steps
|
|
248
|
+
for step in self.steps:
|
|
249
|
+
self._add_step_node(self.dot, step)
|
|
250
|
+
|
|
251
|
+
# 4. Draw Edges
|
|
252
|
+
for step in self.steps:
|
|
253
|
+
step_id = self._node_id(step)
|
|
254
|
+
sig = step.get_signature()
|
|
255
|
+
|
|
256
|
+
for param in sig.parameters:
|
|
257
|
+
# Case A: Missing
|
|
258
|
+
if param in missing:
|
|
259
|
+
self.dot.edge(f"Missing_{param}", step_id, style="dotted", color="#D32F2F")
|
|
260
|
+
|
|
261
|
+
# Case B: External Input
|
|
262
|
+
elif param in inputs:
|
|
263
|
+
self.dot.edge(f"Input_{param}", step_id)
|
|
264
|
+
|
|
265
|
+
# Case C: Produced by another step (Intermediate)
|
|
266
|
+
elif param in producers:
|
|
267
|
+
producer = producers[param]
|
|
268
|
+
prod_id = self._node_id(producer)
|
|
269
|
+
|
|
270
|
+
# Cycle Detection
|
|
271
|
+
is_feedback = step_indices[producer] >= step_indices[step]
|
|
272
|
+
style = "dashed" if is_feedback else "solid"
|
|
273
|
+
color = "#D32F2F" if is_feedback else "#616161"
|
|
274
|
+
|
|
275
|
+
self.dot.edge(prod_id, step_id, label=param, style=style, color=color)
|
|
276
|
+
|
|
277
|
+
# 5. Link Steps to Final Outputs
|
|
278
|
+
for step in self.steps:
|
|
279
|
+
step_id = self._node_id(step)
|
|
280
|
+
for out in step.resolve_output_names():
|
|
281
|
+
if out in finals:
|
|
282
|
+
self.dot.edge(step_id, f"Final_{out}")
|
|
283
|
+
|
|
284
|
+
# --- Helpers ---
|
|
285
|
+
|
|
286
|
+
def _node_id(self, step: Step) -> str:
|
|
287
|
+
return f"Step_{id(step)}"
|
|
288
|
+
|
|
289
|
+
def _add_node(self, graph, node_id: str, label: str, style_dict: Dict[str, str]):
|
|
290
|
+
"""Generic node adder using a style dictionary."""
|
|
291
|
+
# Make a copy to avoid mutating the class constant
|
|
292
|
+
attrs = style_dict.copy()
|
|
293
|
+
attrs['label'] = label
|
|
294
|
+
graph.node(node_id, **attrs)
|
|
295
|
+
|
|
296
|
+
def _add_step_node(self, graph, step: Step):
|
|
297
|
+
"""Adds a function/step node."""
|
|
298
|
+
attrs = self.STYLE_STEP.copy()
|
|
299
|
+
# HTML label for bold text
|
|
300
|
+
attrs['label'] = f"<<b>{step.name}</b>>"
|
|
301
|
+
graph.node(self._node_id(step), **attrs)
|
|
302
|
+
|
|
303
|
+
# API adapter
|
|
304
|
+
def visualize_pipeline(
|
|
305
|
+
steps: List[Step],
|
|
306
|
+
inputs: Set[str],
|
|
307
|
+
output_path: Optional[str] = None,
|
|
308
|
+
orientation: str = "TD",
|
|
309
|
+
graph_type: Literal["flow", "bipartite"] = "flow",
|
|
310
|
+
view: bool = True
|
|
311
|
+
):
|
|
312
|
+
viz = PipelineVisualizer(steps, inputs, orientation)
|
|
313
|
+
viz.build(graph_type).render(output_path, view=view)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: SmartMDAO
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: graphviz>=0.21
|
|
8
|
+
Requires-Dist: h5py>=3.15.1
|
|
9
|
+
Requires-Dist: ipykernel>=7.1.0
|
|
10
|
+
Requires-Dist: matplotlib>=3.10.7
|
|
11
|
+
Requires-Dist: numpy>=2.3.5
|
|
12
|
+
Requires-Dist: openturns>=1.27
|
|
13
|
+
Requires-Dist: scipy>=1.17.1
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# SmartPipeline 🚀
|
|
17
|
+
|
|
18
|
+
SmartPipeline is a robust, Pythonic framework for building modular computational workflows. It moves beyond simple DAGs (Directed Acyclic Graphs) by supporting automatic dependency injection, cyclic/iterative solving, and integrated caching, all while keeping your code clean and readable using standard Python type hints.
|
|
19
|
+
|
|
20
|
+
Whether you are running linear data processing or highly coupled Multidisciplinary Design Optimization (MDO) problems, SmartPipeline dynamically maps your functions and solves them efficiently.
|
|
21
|
+
|
|
22
|
+
# 🌟 Key Features
|
|
23
|
+
|
|
24
|
+
- **Type-Hint Driven Dependency Injection**: No need to manually define edges. If `Step B` needs a variable `x` and `Step A` returns a variable named `x`, the pipeline connects them automatically.
|
|
25
|
+
|
|
26
|
+
- **We do the MDA, you do the MDO**: We handle the heavy lifting of Multidisciplinary Analysis (MDA) — automatically isolating and converging feedback loops. Because our architecture is completely optimizer-agnostic, you can plug our dynamic evaluator into SciPy, OpenTURNS, PyOptSparse, or any algorithm you prefer.
|
|
27
|
+
|
|
28
|
+
- **Hybrid Solver**: Automatically detects whether your pipeline is linear or contains feedback loops (cycles). It solves linear parts topologically and iterates over cyclic parts until convergence using Tarjan's Algorithm.
|
|
29
|
+
|
|
30
|
+
- **Modular Caching**: Built-in decorators (`@cached`) to cache step results in RAM, HDF5 (for large arrays), or Pickle (for complex objects) with zero boilerplate.
|
|
31
|
+
|
|
32
|
+
- **Visualization**: One-line generation of Graphviz diagrams (Flow charts or Data-Flow diagrams).
|
|
33
|
+
|
|
34
|
+
# 📦 Installation
|
|
35
|
+
|
|
36
|
+
We recommend using `uv` for lightning-fast installation, but standard `pip` works perfectly as well.
|
|
37
|
+
|
|
38
|
+
Using `uv` (Recommended):
|
|
39
|
+
|
|
40
|
+
``` bash
|
|
41
|
+
uv pip install git+[https://github.com/wghami/SmartMDAO.git](https://github.com/wghami/SmartMDAO.git)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Using standard `pip`:
|
|
45
|
+
|
|
46
|
+
``` bash
|
|
47
|
+
pip install git+[https://github.com/wghami/SmartMDAO.git](https://github.com/wghami/SmartMDAO.git)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
*Note*: The visualization features require the `graphviz` system binary to be installed on your OS.
|
|
51
|
+
|
|
52
|
+
# ⚡ The Agnostic MDO Approach
|
|
53
|
+
|
|
54
|
+
`SmartPipeline` is built to let you define complex physics or engineering problems using pure, readable Python, without being locked into a specific optimization suite.
|
|
55
|
+
|
|
56
|
+
Here is how easily you can solve the classic **Sellar coupled problem** (a 2-discipline feedback loop) and optimize it using SciPy.
|
|
57
|
+
|
|
58
|
+
``` python
|
|
59
|
+
import math
|
|
60
|
+
from scipy.optimize import minimize
|
|
61
|
+
from smartmdao import Pipeline, HybridSolver
|
|
62
|
+
from smartmdao.optimization import PipelineEvaluator
|
|
63
|
+
|
|
64
|
+
# 1. We handle the heavy lifting: Multidisciplinary Analysis (MDA)
|
|
65
|
+
# The HybridSolver automatically detects the cyclic dependency between y1 and y2!
|
|
66
|
+
pipe = Pipeline(solver=HybridSolver())
|
|
67
|
+
|
|
68
|
+
@pipe.step(outputs=["y1"])
|
|
69
|
+
def discipline_1(z1: float, z2: float, x1: float, y2: float) -> float:
|
|
70
|
+
return (z1 ** 2) + z2 + x1 - (0.2 * y2)
|
|
71
|
+
|
|
72
|
+
@pipe.step(outputs=["y2"])
|
|
73
|
+
def discipline_2(z1: float, z2: float, y1: float) -> float:
|
|
74
|
+
return math.sqrt(abs(y1)) + z1 + z2
|
|
75
|
+
|
|
76
|
+
@pipe.step(outputs=["objective"])
|
|
77
|
+
def compute_objective(x1: float, z2: float, y1: float, y2: float) -> float:
|
|
78
|
+
return (x1 ** 2) + z2 + (y1 ** 2) + math.exp(-y2)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# 2. You choose the optimizer: Agnostic Multidisciplinary Optimization (MDO)
|
|
82
|
+
# Map the optimizer's numeric array to our named design variables
|
|
83
|
+
evaluator = PipelineEvaluator(
|
|
84
|
+
pipeline=pipe,
|
|
85
|
+
design_vars=["z1", "z2", "x1"],
|
|
86
|
+
constants={"y2": 1.0} # Initial guess to kick off the cycle
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Pass the dynamically generated objective functions to ANY optimizer (SciPy, OpenTURNS, etc.)
|
|
90
|
+
result = minimize(
|
|
91
|
+
evaluator.get_objective("objective"),
|
|
92
|
+
x0=[1.0, 1.0, 1.0],
|
|
93
|
+
method='SLSQP',
|
|
94
|
+
bounds=[(-10.0, 10.0), (0.0, 10.0), (0.0, 10.0)]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
print(f"Optimization Success! Objective: {result.fun:.4f}")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The user retains full control over the Pythonic equations, while the `PipelineEvaluator` caches the complex cyclic MDA evaluations so the optimizer can request objectives and constraints independently without performance penalties.
|
|
101
|
+
|
|
102
|
+
# 🛠️ Architecture Overview
|
|
103
|
+
|
|
104
|
+
- `core.py`: The entry point. Manages steps and delegates execution to solvers.
|
|
105
|
+
|
|
106
|
+
- `solvers.py`: The brains.
|
|
107
|
+
|
|
108
|
+
- `DAGSolver`: For standard linear pipelines.
|
|
109
|
+
|
|
110
|
+
- `IterativeSolver`: For fixed-point iteration problems.
|
|
111
|
+
|
|
112
|
+
- `HybridSolver`: Uses Tarjan's Algorithm to decompose graphs into linear and cyclic components dynamically.
|
|
113
|
+
|
|
114
|
+
- `optimization.py`: Contains the stateful `PipelineEvaluator` bridge, providing callable factories for external optimizers.
|
|
115
|
+
|
|
116
|
+
- `executor.py:` Handles argument binding and runtime memory management.
|
|
117
|
+
|
|
118
|
+
- `visualization.py`: Generates high-quality PDF/PNG diagrams of your workflow.
|
|
119
|
+
|
|
120
|
+
# 🚀 Future Improvements & Roadmap
|
|
121
|
+
|
|
122
|
+
To make `SmartPipeline` even better, the following improvements are planned:
|
|
123
|
+
|
|
124
|
+
- **Parallel Execution**: Integrating `asyncio` or `ProcessPoolExecutor` to allow independent branches of the DAG to run in parallel.
|
|
125
|
+
|
|
126
|
+
- **Pydantic Integration**: Replace standard `dataclasses` with Pydantic models for robust runtime data validation and schema generation.
|
|
127
|
+
|
|
128
|
+
- **Checkpointing**: Allow the pipeline to pause and resume from a specific state in case of failure, serializing the entire memory dictionary.
|
|
129
|
+
|
|
130
|
+
- **Web UI**: A lightweight Flask/Streamlit dashboard to visualize pipeline progress and convergence plots in real-time.
|
|
131
|
+
|
|
132
|
+
# 🤝 Contributing
|
|
133
|
+
|
|
134
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
135
|
+
|
|
136
|
+
# 📄 License
|
|
137
|
+
|
|
138
|
+
This project is licensed under the MIT License - see the [LICENSE](https://github.com/wghami/SmartMDAO/blob/main/LICENSE) file for details.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
smartmdao/__init__.py,sha256=frBsu1Ut9aoD5A18oGiz00M_4N1zY51NMae3nyrHNWA,640
|
|
2
|
+
smartmdao/cache.py,sha256=bJTsV2umVQizv1bWfS5gMbxQgHHjWXaH35K6l2GbEfc,4793
|
|
3
|
+
smartmdao/core.py,sha256=K1aWCsaiq7MqPQ-8nLOORX0st9VW1cL4i8d5noqO4Ww,2396
|
|
4
|
+
smartmdao/executor.py,sha256=pgIZjxKBD7fS7_TcBlM5XsJkkDpJ-ptTpt_Mk0Bf4ZU,3470
|
|
5
|
+
smartmdao/logging_config.py,sha256=rdd2iJ7ThTx0OUD6gGHMo5VO-JWb9ouLMQR9FxZuir0,1486
|
|
6
|
+
smartmdao/main.py,sha256=IFo8fG_pxNYIrobt6b7RzluZcQD0VIDJ4j5DkosTvaM,148
|
|
7
|
+
smartmdao/models.py,sha256=3R73gfirf9qDb7SRyNktlaI7WL6ZxaeXsIUTCRVQyPs,1701
|
|
8
|
+
smartmdao/optimization.py,sha256=sNEk_52zT0PK0GM_MyjIHN4aGp_djqWpkFTK685HWZ4,2519
|
|
9
|
+
smartmdao/solvers.py,sha256=VqB7KBHJ5Rxd1Gj5xr6vPMG-o3q_ivdKQs8EgMKC4tk,10211
|
|
10
|
+
smartmdao/utils.py,sha256=t_uY6MyenWCqqT5Pt-etyUlN_2TEp_c2nRqTdmtnvHA,667
|
|
11
|
+
smartmdao/visualization.py,sha256=5ZkiKy4vs5GNtqSohoNdO-o1Uf60i0xjSXqiED8Daf4,11538
|
|
12
|
+
smartmdao-1.1.0.dist-info/METADATA,sha256=zOv5p6qeBFRXwa674GSviLRTyatluUsV17t55Y2MlJE,5990
|
|
13
|
+
smartmdao-1.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
smartmdao-1.1.0.dist-info/licenses/LICENSE,sha256=9rHf5D9jL0TmxixSU-vbwDFunFbpZFmdSSnwNbYo2-k,1066
|
|
15
|
+
smartmdao-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 Guilherme Cunha
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|