@@ -115,27 +115,68 @@ def _hack_structured_grid_dims(
115115 )
116116
117117
118+ _OC_SETTING_KEYWORDS = frozenset ({"all" , "first" , "last" , "steps" , "frequency" })
119+
120+
121+ def _is_emb_fill (val ) -> bool :
122+ """Return True if val is a fill/absent value for embedded keystring rows."""
123+ if val is None :
124+ return True
125+ try :
126+ f = float (val )
127+ return f == FILL_DNODATA or np .isnan (f )
128+ except (TypeError , ValueError ):
129+ return False
130+
131+
132+ def _accumulate_embedded_keystring (
133+ field_value : xr .DataArray ,
134+ field_meta : dict ,
135+ period_data : dict ,
136+ ) -> None :
137+ """Collect (ifno, keyword, value) rows per kper from a 2D embedded keystring field."""
138+ keyword = field_meta ["keyword" ]
139+ feature_dim = next ((d for d in field_value .dims if d != "nper" ), None )
140+ if feature_dim is None :
141+ return
142+ rows_by_kper : dict = period_data .setdefault ("__embedded_rows__" , {})
143+ for kper in range (field_value .sizes ["nper" ]):
144+ kper_slice = field_value .isel (nper = kper ).values
145+ for ifeat , val in enumerate (kper_slice ):
146+ if not _is_emb_fill (val ):
147+ rows_by_kper .setdefault (kper , []).append ((ifeat + 1 , keyword , val ))
148+
149+
150+ def _unstructure_period_keystring (name : str , value : xr .DataArray ) -> dict [str , dict [int , Any ]]:
151+ """Unstructure a keystring period field (e.g. save_head, print_budget).
152+
153+ The field name encodes the MF6 keyword: save_head → 'save head'.
154+ Values are ocsetting strings such as 'ALL', 'LAST', 'STEPS 1 3', 'FREQUENCY 2'.
155+ Empty strings act as a stop sentinel — they suppress fill-forward for that period.
156+ """
157+ fname = name .replace ("_" , " " )
158+ dat = {
159+ kper : value .values [kper ]
160+ for kper in range (value .sizes ["nper" ])
161+ if (tokens := value .values [kper ].lower ().split ()) and tokens [0 ] in _OC_SETTING_KEYWORDS
162+ }
163+ return {fname : dat }
164+
165+
166+ def _unstructure_period_bool (name : str , value : xr .DataArray ) -> dict [str , dict [int , Any ]]:
167+ """Unstructure a boolean period field (e.g. STO steady_state, transient)."""
168+ fname = name .rstrip ("_" ).replace ("_" , "-" ) # type: ignore
169+ dat = {kper : "" for kper in range (value .sizes ["nper" ]) if value .values [kper ]}
170+ return {fname : dat }
171+
172+
118173def _hack_period_non_numeric (name : str , value : xr .DataArray ) -> dict [str , dict [int , Any ]]:
119- data = {}
120174 match value .dtype :
121175 case np .bool :
122- # supports boolean dataarrays, e.g. STO steady_state and transient
123- # Strip trailing _ first (safe_name adds _ for Python builtins, e.g. all_ → all)
124- fname = name .rstrip ("_" ).replace ("_" , "-" ) # type: ignore
125- dat = {kper : "" for kper in range (value .sizes ["nper" ]) if value .values [kper ]}
126- data [fname ] = dat
176+ return _unstructure_period_bool (name , value )
127177 case np .dtypes .StringDType ():
128- # supports string dataarrays, e.g. OC save_budget, save_head
129- fname = name .replace ("_" , " " )
130- dat = {
131- kper : value .values [kper ]
132- for kper in range (value .sizes ["nper" ])
133- if (tokens := value .values [kper ].lower ().split ())
134- and tokens [0 ] in ["first" , "last" , "steps" , "all" ]
135- }
136- data [fname ] = dat
137-
138- return data
178+ return _unstructure_period_keystring (name , value )
179+ return {}
139180
140181
141182def _unstructure_block_param (
@@ -229,6 +270,11 @@ def _unstructure_block_param(
229270 structured_grid_dims = value .data .dims , # type: ignore
230271 )
231272 if "nper" in field_value .dims and block_name == "period" :
273+ arr_spec = xatspec .arrays .get (field_name )
274+ _field_meta = (arr_spec .metadata or {}) if arr_spec is not None else {}
275+ if _field_meta .get ("embedded_keystring" ):
276+ _accumulate_embedded_keystring (field_value , _field_meta , period_data )
277+ return
232278 is_tabular = (
233279 np .issubdtype (field_value .dtype , np .number )
234280 or np .issubdtype (field_value .dtype , np .str_ )
@@ -250,12 +296,14 @@ def _unstructure_block_param(
250296 else :
251297 arr_spec = xatspec .arrays .get (field_name )
252298 field_meta = (arr_spec .metadata or {}) if arr_spec is not None else {}
253- if "prefix" in field_meta or "row_keyword" in field_meta :
299+ if "prefix" in field_meta or "row_keyword" in field_meta or "cellid" in field_meta :
254300 field_value = field_value .copy ()
255301 if "prefix" in field_meta :
256302 field_value .attrs ["prefix" ] = field_meta ["prefix" ]
257303 if "row_keyword" in field_meta :
258304 field_value .attrs ["row_keyword" ] = field_meta ["row_keyword" ]
305+ if "cellid" in field_meta :
306+ field_value .attrs ["cellid" ] = True
259307 blocks [block_name ][field_name ] = field_value
260308 case _:
261309 blocks [block_name ][field_name ] = field_value
@@ -314,16 +362,29 @@ def _unstructure_array_component(value: Component) -> dict[str, Any]:
314362
315363# Block names that MF6 rejects if present but empty.
316364# These blocks should only be written when they contain data.
317- _SKIP_IF_EMPTY = frozenset ({"dimensions" , "fileinput" , "tracktimes" })
365+ _SKIP_IF_EMPTY = frozenset ({"dimensions" , "fileinput" , "tables" , "outlets" , " tracktimes" })
318366
319367# Block names whose fields are list columns (one array per column, same dim)
320368# rather than independent grid arrays. Only these blocks are auto-combined
321369# into an xr.Dataset for row-per-record output. griddata-style blocks must
322370# NOT be in this set — their fields are written individually with
323371# INTERNAL/CONSTANT/NETCDF format.
372+ # Extend when adding a new recarray block; keep in sync with __block_col_maps__
373+ # on generated Package classes.
324374# "sources" is the SSM sources block (pname/srctype/auxname per-row tabular input).
325375_LIST_BLOCK_NAMES = frozenset (
326- {"packagedata" , "packages" , "perioddata" , "sources" , "fileinput" , "table" }
376+ {
377+ "packagedata" ,
378+ "packages" ,
379+ "partitions" ,
380+ "perioddata" ,
381+ "sources" ,
382+ "fileinput" ,
383+ "table" ,
384+ "outlets" ,
385+ "connectiondata" ,
386+ "tables" ,
387+ }
327388)
328389
329390
@@ -368,7 +429,15 @@ def _unstructure_component(value: Component) -> dict[str, Any]:
368429 for kper , block in period_blocks .items ():
369430 key = f"period { kper + 1 } "
370431 for arr_name , val in block .items ():
371- if np .any (val != FILL_DNODATA ):
432+ if arr_name == "__embedded_rows__" :
433+ # Embedded keystring rows: list of (ifno, keyword, value) tuples.
434+ # Accumulated from all embedded_keystring fields; write as a list
435+ # so the Jinja 'list' macro renders each tuple as a record row.
436+ if val :
437+ if key not in blocks :
438+ blocks [key ] = {}
439+ blocks [key ]["lak_period" ] = val
440+ elif np .any (val != FILL_DNODATA ):
372441 # don't create the block (so it isn't written)
373442 # unless there is data to write
374443 if key not in blocks :
0 commit comments