Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/conf_file.rst
Original file line number Diff line number Diff line change
Expand Up @@ -643,5 +643,14 @@ If Itzï is run from within the GRASS shell, this section is not necessary.

With GNU/Linux, *grass_bin* could simply be ``grass``.

The *region* and *mask* parameters are optionals and are applied only during the simulation.
After the simulation, those parameters are returned to the previous *region* and *mask* setting.
The *region* and *mask* parameters are optional and apply for the whole simulation,
including initialization and output finalization.
The selected region is applied privately, so another process changing the mapset's default region does not
change a running job.
Raster and mask contents are not protected against external edits.
After itzi ends, whether with success or failure, the original region, mask, and session settings are restored
without rewriting saved regions, the shared default region, or mask rasters.

An explicit *grassdata*, *location*, and *mapset* must be supplied together and select an existing, accessible mapset.
Omitted region and mask settings inherit the active settings when the target is unchanged,
and use the target's defaults when it differs.
10 changes: 9 additions & 1 deletion docs/yaml_file.rst
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,15 @@ An explicit session requires all three of the following options:
session (defaults to ``grass``); it cannot be used without those three options.
``region`` optionally names a saved region; ``mask`` optionally names a raster mask.
Both can also be used to override the active region or mask for the run.
When a session is already active, an explicit database, project, and mapset must match it.
When a session is already active, a different explicit context is selected temporarily using a private session configuration.
The context must already exist and be accessible.
Omitted region and mask settings inherit the active settings for an unchanged context;
for a different context, they use that context's defaults.
The selected region and mask remain active through initialization, execution, and finalization.
A private region snapshot isolates the job from changes to the shared default region,
but raster and mask contents can still be edited externally.
Context exit restores the original settings on success or failure without rewriting the shared default region,
saved regions, or user mask rasters.
All members of an ensemble share this GRASS context and domain.
Only a projected location / project is accepted, not latitude/longitude.
The region must have at least 3 rows and 3 columns, and a mask cannot exclude the entire simulation domain.
Expand Down
37 changes: 20 additions & 17 deletions src/itzi/bmi_itzi.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,14 @@ class BmiItzi(Bmi):
{arr_def.csdms_name: arr_def.var_loc for arr_def in input_output_array_definition}
)

def __init__(self):
def __init__(self) -> None:
"""Create a BmiItzi model that is ready for initialization."""
self.itzi: SimulationRunner
self.grass_session_manager: GrassSessionManager
self.grass_session_manager: GrassSessionManager | None = None

# Model control functions #

def initialize(self, config_file=None) -> None:
def initialize(self, config_file: str | None = None) -> None:
"""Initialize the Itzï model.

Parameters
Expand All @@ -83,13 +83,16 @@ def initialize(self, config_file=None) -> None:
sim_params = conf_data.sim_config
grass_params = conf_data.grass_params
self.grass_session_manager = GrassSessionManager(grass_params)
self.grass_session_manager.open()

self.itzi = SimulationRunner(
sim_params,
grass_params,
stats_file=conf_data.stats_file,
).initialize()
try:
self.grass_session_manager.open()
self.itzi = SimulationRunner(
sim_params,
stats_file=conf_data.stats_file,
).initialize()
except BaseException:
self.grass_session_manager.close()
self.grass_session_manager = None
raise

def update(self):
"""Advance model by one time step."""
Expand All @@ -106,10 +109,14 @@ def update_until(self, then):
then = timedelta(seconds=float(then))
self.itzi.sim.update_until(then)

def finalize(self):
def finalize(self) -> None:
"""Finalize model."""
self.itzi.finalize()
self.grass_session_manager.close()
manager, self.grass_session_manager = self.grass_session_manager, None
if manager is not None:
try:
self.itzi.finalize()
finally:
manager.close()

# Model information functions #

Expand Down Expand Up @@ -381,7 +388,3 @@ def get_grid_y(self, grid, y):

def get_grid_z(self, grid, z):
raise NotImplementedError("get_grid_z")

def __del__(self):
"""Close the GRASS session even if finalize() is not called"""
self.grass_session_manager.close()
19 changes: 9 additions & 10 deletions src/itzi/ensemble/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,15 @@ def resolve_ensemble(
return ()
requested_params = _requested_grass_params(simulations[0])

with GrassSessionManager(requested_params):
with GrassSessionManager(requested_params) as context:
from itzi.grass.utils import (
active_grass_params,
ensure_min_version,
read_domain,
resolve_effective_mask,
)

ensure_min_version()
actual_params = active_grass_params(requested_params)
domain = read_domain(simulations[0].grass.region)
effective_mask = resolve_effective_mask(simulations[0].grass.mask)
domain = read_domain()
effective_mask = context.effective_mask
input_cache: dict[str, tuple[str, Literal["raster", "strds"]]] = {}
swmm_cache: dict[Path, str] = {}
results: list[ResolvedSimulation | ValidationFailure] = []
Expand Down Expand Up @@ -431,17 +428,19 @@ def _reject_output_source_alias(map_id: str, sources: set[str]) -> None:
raise EnsembleError(f"output {map_id!r} aliases an ensemble input or mask")


def verify_resolved_simulation(simulation: ResolvedSimulation) -> None:
def verify_resolved_simulation(
simulation: ResolvedSimulation, context: GrassSessionManager
) -> None:
"""Re-resolve worker facts and reject a changed GRASS environment."""
from itzi.grass.utils import active_grass_params, read_domain, resolve_effective_mask
from itzi.grass.utils import active_grass_params, read_domain

actual = active_grass_params(simulation.grass_params)
if actual != simulation.grass_params:
raise EnsembleError("GRASS context changed after resolution")
domain = read_domain(simulation.grass_params.region)
domain = read_domain()
if domain != simulation.domain_data:
raise EnsembleError("GRASS domain changed after resolution")
mask = resolve_effective_mask(simulation.grass_params.mask)
mask = context.effective_mask
if mask != simulation.effective_mask:
raise EnsembleError("effective GRASS mask changed after resolution")
_verify_input_kinds(simulation.simulation_config.input_map_names, dict(simulation.input_kinds))
Expand Down
27 changes: 11 additions & 16 deletions src/itzi/ensemble/run_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from itzi.ensemble.models import EnsembleError
from itzi.ensemble.resolution import _last_record_index, verify_resolved_simulation
from itzi.grass.names import derived_record_name
from itzi.grass.session import GrassSessionManager
from itzi.manifest import validate_file_destination

if TYPE_CHECKING:
Expand All @@ -30,20 +31,16 @@

def validate_run_requirements(simulation: ResolvedSimulation) -> None:
"""Validate one resolved simulation without creating user artifacts."""
from itzi.grass.interface import GrassInterface
from itzi.grass.utils import ensure_min_version

ensure_min_version()
verify_resolved_simulation(simulation)
config = simulation.simulation_config
interface = GrassInterface(
start_time=config.start_time,
end_time=config.end_time,
dtype=np.float32,
region_id=simulation.grass_params.region,
raster_mask_id=simulation.grass_params.mask,
)
try:
with GrassSessionManager(simulation.grass_params) as context:
from itzi.grass.interface import GrassInterface

verify_resolved_simulation(simulation, context)
config = simulation.simulation_config
interface = GrassInterface(
start_time=config.start_time,
end_time=config.end_time,
dtype=np.float32,
)
_validate_inputs(simulation, interface)
_validate_grass_outputs(simulation, interface)
if simulation.artifacts.statistics_file is not None:
Expand All @@ -52,8 +49,6 @@ def validate_run_requirements(simulation: ResolvedSimulation) -> None:
overwrite=interface.overwrite,
description="statistics file",
)
finally:
interface.cleanup()


def _validate_inputs(simulation: ResolvedSimulation, interface: GrassInterface) -> None:
Expand Down
54 changes: 6 additions & 48 deletions src/itzi/grass/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
GNU General Public License for more details.
"""

from __future__ import annotations

import copy
import os
from collections.abc import Mapping
Expand All @@ -24,9 +26,7 @@
import grass.script as gscript
import grass.temporal as tgis
import numpy as np
from grass.lib import raster as libraster
from grass.pygrass import raster
from grass.pygrass.gis.region import Region
from grass.pygrass.vector import VectorTopo
from grass.pygrass.vector.geometry import Line, Point
from grass.pygrass.vector.table import Link, Table
Expand Down Expand Up @@ -121,61 +121,20 @@ def __init__(
start_time: datetime,
end_time: datetime,
dtype: type[np.generic] | np.dtype | str,
region_id: str | None,
raster_mask_id: str | None,
) -> None:
assert isinstance(start_time, datetime), "start_time not a datetime object!"
assert isinstance(end_time, datetime), "end_time not a datetime object!"
assert start_time <= end_time, "start_time > end_time!"

self.region_id = region_id
self.start_time = start_time
self.end_time = end_time
self.dtype = dtype

# LatLon is not supported
if gscript.locn_is_latlong():
msgr.fatal("latlong location is not supported. Please use a projected location")
# Set region
if self.region_id:
gscript.use_temp_region()
gscript.run_command("g.region", region=region_id)
self.region = Region()
# Reload the region without remapping GRASS's retained mask descriptor.
libraster.Rast_unset_window()
libraster.Rast_get_window(self.region.byref())
self.xr = self.region.cols
self.yr = self.region.rows
# Check if region is at least 3x3
if self.xr < 3 or self.yr < 3:
msgr.fatal("GRASS Region should be at least 3 cells by 3 cells")
self.mask_source = resolve_effective_mask(raster_mask_id).source
self._domain_data = read_domain()
self.xr = self._domain_data.cols
self.yr = self._domain_data.rows
self.mask_source = resolve_effective_mask(None).source
self.overwrite = gscript.overwrite()
self._domain_data = read_domain(None)
mask_manager = gscript.MaskManager(mask_name=self.mask_source, remove=False)
mask_manager.activate()
self._mask_manager: gscript.MaskManager | None = mask_manager

def __enter__(self):
return self

def __exit__(self, exc_type, exc_value, traceback):
self.cleanup()

def __del__(self):
self.cleanup()

def cleanup(self) -> None:
"""Restore the previous mask and remove the temporary region."""
if manager := getattr(self, "_mask_manager", None):
manager.deactivate()
self._mask_manager = None
# Refresh only the mask, preserving unrelated open raster handles.
libraster.Rast__check_for_auto_masking()
if getattr(self, "region_id", None):
msgr.debug("Remove temp region...")
gscript.del_temp_region()
self.region_id = None

def grass_dtype(self, dtype: str) -> RasterMapType:
if dtype in self.dtype_conv["DCELL"]:
Expand Down Expand Up @@ -339,7 +298,6 @@ def validate_output_stds_temporal_type(
)

def read_raster_map(self, raster_id: str) -> np.ndarray:
"""Read a raster using the mask selected for the simulation."""
name, mapset = split_identifier(raster_id)
if not mapset:
msgr.fatal(f"Raster <{raster_id}> not found")
Expand Down
Loading
Loading