Skip to content
19 changes: 16 additions & 3 deletions docs/components/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Output commands define where and what SWAN writes as results. Output can be writ
- **Write commands** — Specify output format and file names (BLOCK, TABLE, SPECOUT)

!!! note "Time Control for Output Components"
When using the rompy API, output write commands (BLOCK, TABLE, SPECOUT, NESTOUT) have their start time (`tbeg`) set from the `ModelRun.period.start`. However, you can override the time interval (`delt`) and formatting (`tfmt`, `dfmt`) by specifying a `times` field in the component. If no `times` field is provided, the component uses the runtime interval.
When using the rompy API, output write commands (BLOCK, TABLE, SPECOUT, and NESTOUT within a NEST) have their start time (`tbeg`) set from the `ModelRun.period.start`. However, you can override the time interval (`delt`) and formatting (`tfmt`, `dfmt`) by specifying a `times` field in the component. If no `times` field is provided, the component uses the runtime interval.

**Example:**
```python
Expand Down Expand Up @@ -37,6 +37,15 @@ Output commands define where and what SWAN writes as results. Output can be writ
::: rompy_swan.components.output.ISOLINE
::: rompy_swan.components.output.POINTS
::: rompy_swan.components.output.POINTS_FILE

## Nested grids

Use the `NEST` component to define nested grid output. It couples `NGRID` and `NESTOUT` together under a single `sname`, and can be used as a list in the `OUTPUT` group to support multiple nests.

!!! warning "Deprecated"
Defining `NGRID` and `NESTOUT` individually in the `OUTPUT` group is deprecated. Use the `NEST` component and the `nests` field instead.

::: rompy_swan.components.output.NEST
::: rompy_swan.components.output.NGRID
::: rompy_swan.components.output.NGRID_UNSTRUCTURED

Expand All @@ -52,5 +61,9 @@ Output commands define where and what SWAN writes as results. Output can be writ
::: rompy_swan.components.output.BLOCK
::: rompy_swan.components.output.TABLE
::: rompy_swan.components.output.SPECOUT
::: rompy_swan.components.output.NESTOUT
::: rompy_swan.components.output.TEST
::: rompy_swan.components.output.TEST

!!! warning "Deprecated"
`NESTOUT` should no longer be used directly. Use the `NEST` component instead.

::: rompy_swan.components.output.NESTOUT
88 changes: 70 additions & 18 deletions src/rompy_swan/components/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
FRAME,
GROUP,
ISOLINE,
NEST,
NESTOUT,
NGRID,
NGRID_UNSTRUCTURED,
Expand Down Expand Up @@ -455,6 +456,7 @@ def cmd(self):
Union[NGRID, NGRID_UNSTRUCTURED],
Field(description="Ngrid locations component", discriminator="model_type"),
]
NEST_TYPE = Annotated[NEST, Field(description="Nest component (NGRID + NESTOUT)")]


class OUTPUT(BaseGroupComponent):
Expand All @@ -468,13 +470,19 @@ class OUTPUT(BaseGroupComponent):
RAY 'rname' ...
ISOLINE 'sname' 'rname' ...
POINTS 'sname ...
NGRID 'sname' ...
NESTS (list):
- NEST 'sname':
NGRID 'sname' ...
NESTOUT 'sname' ...
- NEST 'sname':
NGRID 'sname' ...
NESTOUT 'sname' ...
QUANTITY ...
OUTPUT OPTIONS ...
BLOCK 'sname' ...
TABLE 'sname' ...
SPECOUT 'sname' ...
NESTOUT 'sname ...


This group component is used to define multiple types of output locations and
write components in a single model. Only fields that are explicitly prescribed are
Expand All @@ -489,7 +497,10 @@ class OUTPUT(BaseGroupComponent):
- The Locations `'sname'` assigned to each write component must be defined.
- The BLOCK component must be associated with either a `FRAME` or `GROUP`.
- The ISOLINE write component must be associated with a `RAY` component.
- The NGRID and NESTOUT components must be defined together.
- For nested grids, use the `nests` field which accepts a list of NEST components.
Each NEST automatically couples NGRID and NESTOUT with matching snames.
- Legacy `ngrid` and `nestout` fields are deprecated. Use `nests` instead for
single or multiple nested grid definitions.

Examples
--------
Expand Down Expand Up @@ -539,13 +550,23 @@ class OUTPUT(BaseGroupComponent):
ray: Optional[RAY_TYPE] = Field(default=None)
isoline: Optional[ISOLINE_TYPE] = Field(default=None)
points: Optional[POINTS_TYPE] = Field(default=None)
ngrid: Optional[NGRID_TYPE] = Field(default=None)
nests: Optional[list[NEST_TYPE]] = Field(
default=None,
description="List of nested grid definitions (each couples NGRID + NESTOUT)",
)
ngrid: Optional[NGRID_TYPE] = Field(
default=None,
deprecated="Use 'nests' field instead for single or multiple nested grids",
)
quantity: Optional[QUANTITY_TYPE] = Field(default=None)
output_options: Optional[OUTOPT_TYPE] = Field(default=None)
block: Optional[BLOCK_TYPE] = Field(default=None)
table: Optional[TABLE_TYPE] = Field(default=None)
specout: Optional[SPECOUT_TYPE] = Field(default=None)
nestout: Optional[NESTOUT_TYPE] = Field(default=None)
nestout: Optional[NESTOUT_TYPE] = Field(
default=None,
deprecated="Use 'nests' field instead for single or multiple nested grids",
)
test: Optional[TEST_TYPE] = Field(default=None)
_location_fields: list = ["frame", "group", "curve", "isoline", "points", "ngrid"]
_write_fields: list = ["block", "table", "specout", "nestout"]
Expand Down Expand Up @@ -618,22 +639,54 @@ def isoline_ray_defined(self) -> "OUTPUT":
return self

@model_validator(mode="after")
def ngrid_and_nestout(self) -> "OUTPUT":
"""Ensure NGRID and NESTOUT are specified together."""
def migrate_legacy_ngrid_nestout(self) -> "OUTPUT":
"""Convert deprecated ngrid/nestout fields to nests.

.. deprecated::
Use the `nests` field instead. This validator will be removed in a future
version once the legacy `ngrid` and `nestout` fields are dropped.
"""
if self.ngrid is None and self.nestout is None:
return self
if self.nests is not None:
raise ValueError(
"Cannot specify both 'nests' and legacy 'ngrid'/'nestout' fields. "
"Please use only 'nests' for new configurations."
)
if self.ngrid is not None and self.nestout is None:
raise ValueError(
"NGRID component specified but no NESTOUT component has been defined"
)
elif self.ngrid is None and self.nestout is not None:
if self.ngrid is None and self.nestout is not None:
raise ValueError(
"NESTOUT component specified but no NGRID component has been defined"
)
elif self.ngrid is not None and self.nestout is not None:
if self.ngrid.sname != self.nestout.sname:
raise ValueError(
f"NGRID sname='{self.ngrid.sname}' does not match "
f"the NESTOUT sname='{self.nestout.sname}'"
)
if self.ngrid.sname != self.nestout.sname:
raise ValueError(
f"NGRID sname='{self.ngrid.sname}' does not match "
f"the NESTOUT sname='{self.nestout.sname}'"
)
logger.warning(
"Using deprecated 'ngrid' and 'nestout' fields. "
"Please migrate to using 'nests' field for better support of multiple nests."
)
self.nests = [NEST(sname=self.ngrid.sname, ngrid=self.ngrid, nestout=self.nestout)]
self.ngrid = None
self.nestout = None
return self

@model_validator(mode="after")
def nests_snames_unique(self) -> "OUTPUT":
"""Ensure each nest has a unique sname."""
if self.nests is None:
return self
snames = [nest.sname for nest in self.nests]
duplicates = {x for x in snames if snames.count(x) > 1}
if duplicates:
raise ValueError(
f"Duplicate nest snames found: {duplicates}. "
"Each nest must have a unique sname."
)
return self

@property
Expand Down Expand Up @@ -688,8 +741,9 @@ def cmd(self) -> list:
repr += [f"{self.isoline.cmd()}"]
if self.points is not None:
repr += [f"{self.points.cmd()}"]
if self.ngrid is not None:
repr += [f"{self.ngrid.cmd()}"]
if self.nests is not None:
for nest in self.nests:
repr += nest.cmd()
if self.quantity is not None:
# Component renders a list
repr += self.quantity.cmd()
Expand All @@ -706,8 +760,6 @@ def cmd(self) -> list:
repr += [f"{self.table.cmd()}"]
if self.specout is not None:
repr += [f"{self.specout.cmd()}"]
if self.nestout is not None:
repr += [f"{self.nestout.cmd()}"]
if self.test is not None:
repr += [f"{self.test.cmd()}"]
return repr
Expand Down
112 changes: 108 additions & 4 deletions src/rompy_swan/components/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@

SPECIAL_NAMES = ["BOTTGRID", "COMPGRID", "BOUNDARY", "BOUND_"]

SNAME_TYPE = Annotated[str, Field(min_length=1, max_length=8)]


# =====================================================================================
# Locations
Expand Down Expand Up @@ -59,9 +61,8 @@ class BaseLocation(BaseComponent, ABC):
default="locations",
description="Model type discriminator",
)
sname: str = Field(
sname: SNAME_TYPE = Field(
description="Name of the set of output locations defined by this command",
max_length=8,
)

@field_validator("sname")
Expand Down Expand Up @@ -664,6 +665,13 @@ class NGRID(BaseLocation):
model_type: Literal["ngrid", "NGRID"] = Field(
default="ngrid", description="Model type discriminator"
)
sname: SNAME_TYPE = Field(
default="nest",
description=(
"Name of the NGRID output component, "
"overridden by the parent NEST component if used within one"
),
)
grid: GRIDREGULAR = Field(description="NGRID grid definition")

@field_validator("grid")
Expand Down Expand Up @@ -714,6 +722,10 @@ class NGRID_UNSTRUCTURED(BaseLocation):
model_type: Literal["ngrid_unstructured", "NGRID_UNSTRUCTURED"] = Field(
default="ngrid_unstructured", description="Model type discriminator"
)
sname: SNAME_TYPE = Field(
default="nest",
description="Name of the NGRID output component, overridden by the parent NEST component if used within one",
)
kind: Optional[Literal["triangle", "easymesh"]] = Field(
default="triangle",
description=(
Expand Down Expand Up @@ -1107,11 +1119,10 @@ class BaseWrite(BaseComponent, ABC):
default="write",
description="Model type discriminator",
)
sname: str = Field(
sname: SNAME_TYPE = Field(
description=(
"Name of the set of output locations in which the output is to be written"
),
max_length=8,
)
fname: str = Field(
description=(
Expand Down Expand Up @@ -1520,6 +1531,13 @@ class NESTOUT(BaseWrite):
model_type: Literal["nestout", "NESTOUT"] = Field(
default="nestout", description="Model type discriminator"
)
sname: SNAME_TYPE = Field(
default="nest",
description=(
"Name of the NESTOUT output component, "
"overridden by the parent NEST component if used within one"
),
)

@property
def suffix(self) -> str:
Expand All @@ -1533,6 +1551,92 @@ def cmd(self) -> str:
return repr


# =====================================================================================
# Nested grid component (couples NGRID and NESTOUT)
# =====================================================================================
class NEST(BaseComponent):
"""Coupled NGRID and NESTOUT component for nested runs.

.. code-block:: text

NGRID 'sname' ...
NESTOUT 'sname' ...

This component couples together an NGRID location definition with its corresponding
NESTOUT write command. This ensures that nested grid outputs are properly paired
and allows multiple nested grids to be defined in a single model configuration.

Note
----
The `sname` field is automatically assigned to both the NGRID and NESTOUT
components to ensure they are properly linked.

Examples
--------

.. ipython:: python
:okwarning:

from rompy_swan.components.output import NEST
nest = NEST(
sname="child1",
ngrid=dict(
model_type="ngrid",
grid=dict(xp=167.0, yp=-45.5, xlen=0.1, ylen=0.1, mx=12, my=14),
),
nestout=dict(
fname="nestout_child1.swn",
times=dict(tfmt=1, dfmt="hr"),
),
)
print(nest.render())

"""

model_type: Literal["nest", "NEST"] = Field(
default="nest", description="Model type discriminator"
)
sname: SNAME_TYPE = Field(
description="Name of the nested grid (used for both NGRID and NESTOUT)",
)
ngrid: Union[NGRID, NGRID_UNSTRUCTURED] = Field(
description="NGRID location component defining the nested grid boundary",
discriminator="model_type",
)
nestout: NESTOUT = Field(
description="NESTOUT write component for the nested grid boundary spectra"
)

@field_validator("sname")
@classmethod
def not_special_name(cls, sname: str) -> str:
"""Ensure sname is not defined as one of the special names."""
for name in SPECIAL_NAMES:
if sname.upper().startswith(name):
raise ValueError(f"sname {sname} is a special name and cannot be used")
return sname

@model_validator(mode="after")
def warn_sname_override(self) -> "NEST":
"""Warn if the user explicitly set a different sname on child components."""
if self.ngrid.sname != "nest":
logger.warning(f"NEST overriding NGRID sname: '{self.ngrid.sname}' -> '{self.sname}'")
if self.nestout.sname != "nest":
logger.warning(f"NEST overriding NESTOUT sname: '{self.nestout.sname}' -> '{self.sname}'")
return self

@model_validator(mode="after")
def set_sname(self) -> "NEST":
"""Set sname on child components from the parent NEST sname."""
self.ngrid.sname = self.sname
self.nestout.sname = self.sname
return self

def cmd(self) -> list:
"""Command file strings for this component."""
return [self.ngrid.cmd(), self.nestout.cmd()]


# =====================================================================================
# Write or plot intermediate results
# =====================================================================================
Expand Down
25 changes: 18 additions & 7 deletions src/rompy_swan/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,24 @@ def time_interface(self) -> "OutputInterface":
obj = getattr(self.group, component)
if obj is not None:
times = obj.times or TimeRangeOpen()
obj.times = TimeRangeOpen(
tbeg=self.period.start,
delt=times.delt if obj.times else self.period.interval,
tfmt=times.tfmt,
dfmt=times.dfmt,
suffix=obj.suffix,
)
obj.times = self._timerange(times, obj.suffix)

# Handle nests separately
if self.group.nests is not None:
for nest in self.group.nests:
if nest.nestout is not None:
times = nest.nestout.times or TimeRangeOpen()
nest.nestout.times = self._timerange(times, nest.nestout.suffix)

def _timerange(self, times: TimeRangeOpen, suffix: str) -> TimeRangeOpen:
"""Convert generic TimeRange into the Swan TimeRangeOpen subcomponent."""
return TimeRangeOpen(
tbeg=self.period.start,
delt=times.delt if times.delt is not None else self.period.interval,
tfmt=times.tfmt,
dfmt=times.dfmt,
suffix=suffix,
)


class LockupInterface(TimeInterface):
Expand Down
Loading
Loading