Skip to content

Commit e029b6c

Browse files
committed
binding design doc
1 parent 0b3d33b commit e029b6c

1 file changed

Lines changed: 356 additions & 0 deletions

File tree

binding-resolution-design.md

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
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

Comments
 (0)