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 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
@@ -0,0 +1,8 @@
1
+ import sys
2
+
3
+ def main():
4
+ print("Hello from smartmdao !")
5
+ print(f"Running with Python: {sys.version}")
6
+
7
+ if __name__ == "__main__":
8
+ main()
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.