|
| 1 | +# Binding Resolution Design for Component Loading |
| 2 | + |
| 3 | +## Problem Statement |
| 4 | + |
| 5 | +MF6 simulation and model namefiles use **bindings** to reference child components in tabular list blocks: |
| 6 | + |
| 7 | +``` |
| 8 | +BEGIN MODELS |
| 9 | + gwf6 model.nam modelname |
| 10 | +END MODELS |
| 11 | +
|
| 12 | +BEGIN SOLUTIONGROUP 1 |
| 13 | + ims6 model.ims modelname |
| 14 | +END SOLUTIONGROUP |
| 15 | +``` |
| 16 | + |
| 17 | +**On write**: The `Binding` class converts component instances to binding tuples: |
| 18 | +```python |
| 19 | +Binding.from_component(model) → ('gwf6', 'model.nam', 'modelname') |
| 20 | +``` |
| 21 | + |
| 22 | +**On load**: We need the inverse operation - convert binding tuples back to component instances: |
| 23 | +```python |
| 24 | +# Transformer outputs: |
| 25 | +{'models': [['gwf6', 'model.nam', 'modelname']]} |
| 26 | + |
| 27 | +# Component expects: |
| 28 | +{'models': {'modelname': Model(...)}} |
| 29 | +``` |
| 30 | + |
| 31 | +The challenge: **Binding resolution requires recursive component loading**. |
| 32 | + |
| 33 | +--- |
| 34 | + |
| 35 | +## Approach 1: Inline Resolution in Structure Hook |
| 36 | + |
| 37 | +### Description |
| 38 | +Register custom cattrs structure hooks for Simulation/Model that resolve bindings during structuring: |
| 39 | + |
| 40 | +```python |
| 41 | +def _structure_simulation_hook(data: dict, cls: type) -> Simulation: |
| 42 | + """Custom structure hook for Simulation that resolves bindings.""" |
| 43 | + |
| 44 | + # Resolve model bindings |
| 45 | + models_bindings = data.get('models', []) |
| 46 | + models = {} |
| 47 | + workspace = ... # Need to pass workspace somehow |
| 48 | + |
| 49 | + for binding_tuple in models_bindings: |
| 50 | + model_type, fname, name = binding_tuple |
| 51 | + model_cls = _resolve_component_class(model_type) |
| 52 | + model = model_cls.load(workspace / fname) |
| 53 | + models[name] = model |
| 54 | + |
| 55 | + # Similar for solutions, exchanges... |
| 56 | + |
| 57 | + return Simulation(models=models, solutions=solutions, ...) |
| 58 | + |
| 59 | +# Register the hook |
| 60 | +COMPONENT_CONVERTER.register_structure_hook(Simulation, _structure_simulation_hook) |
| 61 | +``` |
| 62 | + |
| 63 | +### Pros |
| 64 | +- ✅ Clean separation - structure hooks handle all structuring logic |
| 65 | +- ✅ Fits cattrs design pattern |
| 66 | +- ✅ Type-specific logic encapsulated in hooks |
| 67 | + |
| 68 | +### Cons |
| 69 | +- ❌ Structure hooks don't have access to `workspace` (only get `data` and `cls`) |
| 70 | +- ❌ Requires passing workspace through the data dict or via closure |
| 71 | +- ❌ Mixing structural transformation with I/O (recursive loads) |
| 72 | +- ❌ Harder to test in isolation |
| 73 | +- ❌ Need separate hooks for each component type with bindings |
| 74 | + |
| 75 | +--- |
| 76 | + |
| 77 | +## Approach 2: Post-Processing After Structure |
| 78 | + |
| 79 | +### Description |
| 80 | +Keep structuring simple, then resolve bindings as a post-processing step: |
| 81 | + |
| 82 | +```python |
| 83 | +def resolve_bindings(component: Component, workspace: Path) -> Component: |
| 84 | + """ |
| 85 | + Recursively resolve binding tuples to component instances. |
| 86 | +
|
| 87 | + Processes a component after structuring to: |
| 88 | + 1. Detect binding lists (list of tuples with type/fname/terms) |
| 89 | + 2. Recursively load referenced components |
| 90 | + 3. Replace binding lists with dicts of component instances |
| 91 | + """ |
| 92 | + |
| 93 | + if isinstance(component, Simulation): |
| 94 | + # Resolve models |
| 95 | + if isinstance(component.models, list): |
| 96 | + resolved_models = {} |
| 97 | + for binding_tuple in component.models: |
| 98 | + model = Binding.to_component(binding_tuple, workspace) |
| 99 | + resolved_models[model.name] = model |
| 100 | + component.models = resolved_models |
| 101 | + |
| 102 | + # Resolve solutions |
| 103 | + if isinstance(component.solutions, dict): |
| 104 | + for group_name, bindings in list(component.solutions.items()): |
| 105 | + if isinstance(bindings, list): |
| 106 | + resolved = {} |
| 107 | + for binding_tuple in bindings: |
| 108 | + solution = Binding.to_component(binding_tuple, workspace) |
| 109 | + resolved[solution.name] = solution |
| 110 | + component.solutions.update(resolved) |
| 111 | + |
| 112 | + # Similar for exchanges... |
| 113 | + |
| 114 | + elif isinstance(component, Model): |
| 115 | + # Resolve package bindings if model namefiles have them |
| 116 | + pass |
| 117 | + |
| 118 | + return component |
| 119 | + |
| 120 | +# Usage in loader: |
| 121 | +def _load_mf6(cls, path: Path) -> Component: |
| 122 | + with open(path, "r") as fp: |
| 123 | + component = structure(load_mf6(fp), path, component_type=cls) |
| 124 | + component = resolve_bindings(component, path.parent) |
| 125 | + return component |
| 126 | +``` |
| 127 | + |
| 128 | +### Pros |
| 129 | +- ✅ Clear separation of concerns: structure = data transformation, binding = I/O |
| 130 | +- ✅ Has access to workspace naturally |
| 131 | +- ✅ Easy to test independently |
| 132 | +- ✅ Reusable across different component types |
| 133 | +- ✅ Doesn't complicate cattrs setup |
| 134 | +- ✅ Can be optional/conditional (e.g., skip for testing) |
| 135 | + |
| 136 | +### Cons |
| 137 | +- ❌ Requires components to temporarily hold invalid state (lists instead of dicts) |
| 138 | +- ❌ Two-pass processing (structure, then resolve) |
| 139 | +- ❌ Need to handle partially-resolved states gracefully |
| 140 | + |
| 141 | +--- |
| 142 | + |
| 143 | +## Approach 3: Binding Helper Method on Binding Class |
| 144 | + |
| 145 | +### Description |
| 146 | +Add a `to_component()` classmethod to `Binding` that handles the conversion: |
| 147 | + |
| 148 | +```python |
| 149 | +# flopy4/mf6/binding.py |
| 150 | + |
| 151 | +@define |
| 152 | +class Binding: |
| 153 | + type: str |
| 154 | + fname: str |
| 155 | + terms: tuple[str, ...] | None = None |
| 156 | + |
| 157 | + @classmethod |
| 158 | + def from_component(cls, component: Component) -> "Binding": |
| 159 | + """Create binding from component (for write).""" |
| 160 | + # ... existing code ... |
| 161 | + |
| 162 | + @classmethod |
| 163 | + def to_component(cls, binding_tuple: tuple, workspace: Path) -> Component: |
| 164 | + """ |
| 165 | + Resolve binding tuple to component instance (for load). |
| 166 | +
|
| 167 | + Parameters |
| 168 | + ---------- |
| 169 | + binding_tuple : tuple |
| 170 | + Binding in form (type, fname) or (type, fname, *terms) |
| 171 | + workspace : Path |
| 172 | + Workspace directory for resolving file paths |
| 173 | +
|
| 174 | + Returns |
| 175 | + ------- |
| 176 | + Component |
| 177 | + Loaded component instance |
| 178 | + """ |
| 179 | + binding_type, fname = binding_tuple[0:2] |
| 180 | + terms = binding_tuple[2:] if len(binding_tuple) > 2 else () |
| 181 | + |
| 182 | + # Resolve component class from type |
| 183 | + component_cls = _resolve_component_class(binding_type) |
| 184 | + |
| 185 | + # Recursively load the component |
| 186 | + component_path = workspace / fname |
| 187 | + component = component_cls.load(component_path) |
| 188 | + |
| 189 | + # Apply terms (e.g., set name from binding) |
| 190 | + if terms: |
| 191 | + component.name = terms[0] |
| 192 | + |
| 193 | + return component |
| 194 | + |
| 195 | + |
| 196 | +def _resolve_component_class(binding_type: str) -> type[Component]: |
| 197 | + """Map binding type string to component class.""" |
| 198 | + type_map = { |
| 199 | + 'gwf6': 'flopy4.mf6.gwf.Model', |
| 200 | + 'gwt6': 'flopy4.mf6.gwt.Model', |
| 201 | + 'gwe6': 'flopy4.mf6.gwe.Model', |
| 202 | + 'ims6': 'flopy4.mf6.ims.Ims', |
| 203 | + 'gwf-gwf': 'flopy4.mf6.exchange.GwfGwf', |
| 204 | + # ... etc |
| 205 | + } |
| 206 | + |
| 207 | + if binding_type.lower() not in type_map: |
| 208 | + raise ValueError(f"Unknown binding type: {binding_type}") |
| 209 | + |
| 210 | + # Dynamic import |
| 211 | + module_path, class_name = type_map[binding_type.lower()].rsplit('.', 1) |
| 212 | + module = __import__(module_path, fromlist=[class_name]) |
| 213 | + return getattr(module, class_name) |
| 214 | +``` |
| 215 | + |
| 216 | +### Pros |
| 217 | +- ✅ Symmetric API: `from_component()` (write) ↔ `to_component()` (load) |
| 218 | +- ✅ Encapsulates type resolution logic in one place |
| 219 | +- ✅ Binding class owns both directions of the transformation |
| 220 | +- ✅ Composable with Approach 2 for the actual resolution loop |
| 221 | + |
| 222 | +### Cons |
| 223 | +- ❌ Couples Binding class to component loading (circular dependency risk) |
| 224 | +- ❌ Still needs a driver (Approach 1 or 2) to call it |
| 225 | +- ❌ Type map maintenance burden |
| 226 | + |
| 227 | +--- |
| 228 | + |
| 229 | +## Recommended Approach: Hybrid (2 + 3) |
| 230 | + |
| 231 | +**Use Approach 2 (post-processing) as the driver, with Approach 3 (Binding.to_component) for the conversion logic.** |
| 232 | + |
| 233 | +### Implementation Strategy |
| 234 | + |
| 235 | +1. **Add `Binding.to_component()` method** to encapsulate type resolution and loading: |
| 236 | + ```python |
| 237 | + # Symmetric to Binding.from_component() |
| 238 | + Binding.to_component(binding_tuple, workspace) → Component |
| 239 | + ``` |
| 240 | + |
| 241 | +2. **Add `resolve_bindings()` post-processor** to handle the iteration: |
| 242 | + ```python |
| 243 | + def resolve_bindings(component: Component, workspace: Path) -> Component: |
| 244 | + """Post-process component to resolve binding tuples.""" |
| 245 | + if isinstance(component, Simulation): |
| 246 | + # Use Binding.to_component() for each binding |
| 247 | + component.models = _resolve_binding_dict(component.models, workspace) |
| 248 | + component.solutions = _resolve_binding_dict(component.solutions, workspace) |
| 249 | + component.exchanges = _resolve_binding_dict(component.exchanges, workspace) |
| 250 | + return component |
| 251 | + ``` |
| 252 | + |
| 253 | +3. **Call from loaders** after structuring: |
| 254 | + ```python |
| 255 | + def _load_mf6(cls, path: Path) -> Component: |
| 256 | + component = structure(load_mf6(fp), path, component_type=cls) |
| 257 | + component = resolve_bindings(component, path.parent) |
| 258 | + return component |
| 259 | + ``` |
| 260 | + |
| 261 | +### Why This is Best |
| 262 | + |
| 263 | +1. **Separation of Concerns** |
| 264 | + - `Binding.to_component()`: Type resolution + loading (cohesive, reusable) |
| 265 | + - `resolve_bindings()`: Iteration + dict building (structural transformation) |
| 266 | + - Loaders: Orchestration (parse → structure → resolve → return) |
| 267 | + |
| 268 | +2. **Symmetric Design** |
| 269 | + - Write: `Component → Binding.from_component() → tuple` |
| 270 | + - Load: `tuple → Binding.to_component() → Component` |
| 271 | + |
| 272 | +3. **Testability** |
| 273 | + - Test `Binding.to_component()` with simple bindings |
| 274 | + - Test `resolve_bindings()` with mock components |
| 275 | + - Test loaders end-to-end with real files |
| 276 | + |
| 277 | +4. **Flexibility** |
| 278 | + - Can skip binding resolution for unit tests |
| 279 | + - Can resolve bindings for programmatically created components |
| 280 | + - Can extend to support lazy loading later |
| 281 | + |
| 282 | +5. **Compatibility with Classmethod API** |
| 283 | + - `Binding.to_component()` naturally calls `Component.load()` classmethod |
| 284 | + - Recursive loading works seamlessly |
| 285 | + - Workspace propagates correctly |
| 286 | + |
| 287 | +--- |
| 288 | + |
| 289 | +## Implementation Checklist |
| 290 | + |
| 291 | +- [ ] Add `Binding.to_component(binding_tuple, workspace)` classmethod |
| 292 | +- [ ] Add `_resolve_component_class(binding_type)` helper function |
| 293 | +- [ ] Add `resolve_bindings(component, workspace)` post-processor |
| 294 | +- [ ] Integrate `resolve_bindings()` into `_load_mf6()`, `_load_json()`, `_load_toml()` |
| 295 | +- [ ] Update transformer to preserve binding tuples in output |
| 296 | +- [ ] Write tests for `Binding.to_component()` |
| 297 | +- [ ] Write tests for `resolve_bindings()` |
| 298 | +- [ ] Write integration tests for Simulation.load() with bindings |
| 299 | + |
| 300 | +--- |
| 301 | + |
| 302 | +## Future Enhancements |
| 303 | + |
| 304 | +### Lazy Loading |
| 305 | +Instead of loading all child components immediately, could return proxy objects: |
| 306 | + |
| 307 | +```python |
| 308 | +class BindingProxy: |
| 309 | + def __init__(self, binding_tuple, workspace): |
| 310 | + self.binding_tuple = binding_tuple |
| 311 | + self.workspace = workspace |
| 312 | + self._component = None |
| 313 | + |
| 314 | + @property |
| 315 | + def component(self): |
| 316 | + if self._component is None: |
| 317 | + self._component = Binding.to_component(self.binding_tuple, self.workspace) |
| 318 | + return self._component |
| 319 | +``` |
| 320 | + |
| 321 | +### Caching |
| 322 | +Avoid loading the same component multiple times: |
| 323 | + |
| 324 | +```python |
| 325 | +def resolve_bindings(component, workspace, cache=None): |
| 326 | + cache = cache or {} |
| 327 | + # Check cache before calling Binding.to_component() |
| 328 | + # Store loaded components in cache by path |
| 329 | +``` |
| 330 | + |
| 331 | +### Validation |
| 332 | +Verify binding references exist before attempting to load: |
| 333 | + |
| 334 | +```python |
| 335 | +def validate_bindings(component, workspace): |
| 336 | + """Check that all binding files exist before loading.""" |
| 337 | + # Collect all binding tuples |
| 338 | + # Verify files exist |
| 339 | + # Raise helpful error if missing |
| 340 | +``` |
| 341 | + |
| 342 | +--- |
| 343 | + |
| 344 | +## Related Issues |
| 345 | + |
| 346 | +- Simulation structuring bug (name parameter receives entire dict) |
| 347 | + - **Fixed by**: Passing `component_type=cls` to `structure()` ✅ |
| 348 | + - Implemented in this session |
| 349 | + |
| 350 | +- Transformer output format for simulations |
| 351 | + - **Needs**: Simulation parser/transformer implementation |
| 352 | + - **Blocks**: Full simulation loading until implemented |
| 353 | + |
| 354 | +- Component dimension resolution for standalone package loading |
| 355 | + - **Future**: May need `dims` parameter for `Package.load(path, dims={...})` |
| 356 | + - Not needed for Simulation/Model loading (get dims from children) |
0 commit comments