Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c371fc4
feat: update site selection and report workflows
dreuzy Jun 6, 2026
b11b6bc
docs: refresh configuration reference
dreuzy Jun 6, 2026
5bc9aa4
examples: refresh site selection workflow configs
dreuzy Jun 6, 2026
17ea2b7
refactor: rename DEM network candidate options
dreuzy Jun 6, 2026
72d58c9
refactor: finalize DEM network naming
dreuzy Jun 6, 2026
bff78b2
refactor(site-selection): align DEM workflow terminology
dreuzy Jun 6, 2026
0737522
docs: refresh config reference diagrams
dreuzy Jun 6, 2026
0f5dc11
test: add validity frame to pytest path
dreuzy Jun 6, 2026
a8d5b1b
Clean up site selection legacy naming
dreuzy Jun 6, 2026
f395311
Document site selection HTML reporting
dreuzy Jun 6, 2026
13c64fd
Complete site selection workflow example
dreuzy Jun 7, 2026
1985c95
docs: expand site selection workflow documentation
dreuzy Jun 7, 2026
f0f586c
docs: align site selection guide example
dreuzy Jun 7, 2026
3a71830
docs: finalize HTML report workflow
dreuzy Jun 7, 2026
e151daa
fix: shorten Windows regression paths
dreuzy Jun 7, 2026
c653431
examples: align site selection testbed filters
dreuzy Jun 7, 2026
3f5e1be
docs: record HTML report validation
dreuzy Jun 7, 2026
f6ce451
chore: ignore generated example data blobs
dreuzy Jun 7, 2026
33e2829
feat: warn on site-selection DEM boundary basins
dreuzy Jun 7, 2026
d04548b
docs: add Finistere map context layer
dreuzy Jun 7, 2026
ddcc912
examples: add regenerated Bretagne testbed configs
dreuzy Jun 7, 2026
3465375
fix: expose validity frame package exports
dreuzy Jun 7, 2026
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
Prev Previous commit
Next Next commit
fix: shorten Windows regression paths
  • Loading branch information
dreuzy committed Jun 7, 2026
commit e151daa18eaf29d997e8ba5eddb178402fcd03c0
27 changes: 27 additions & 0 deletions hydromodpy/core/storage_naming.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Shared filesystem-safe naming helpers."""

from __future__ import annotations

import re
import unicodedata

_SAFE_CHAR_RE = re.compile(r"[^a-z0-9_-]+")
_COLLAPSE_UNDERSCORE_RE = re.compile(r"_+")

MAX_SEGMENT_LEN = 32
UNNAMED = "unnamed"


def sanitize_segment(value: str | None, *, max_len: int = MAX_SEGMENT_LEN) -> str:
"""Return a filesystem-safe lowercase slug from an arbitrary string."""
if not value:
return UNNAMED
folded = unicodedata.normalize("NFKD", str(value)).encode("ascii", "ignore").decode("ascii")
slug = _SAFE_CHAR_RE.sub("_", folded.strip().lower())
slug = _COLLAPSE_UNDERSCORE_RE.sub("_", slug).strip("_-")
if not slug:
return UNNAMED
return slug[:max_len].rstrip("_-") or UNNAMED


__all__ = ["MAX_SEGMENT_LEN", "UNNAMED", "sanitize_segment"]
44 changes: 36 additions & 8 deletions hydromodpy/data/data_freeze.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,28 @@ class LockMismatch:
def sha256_of(path: Path, *, chunk: int = 64 * 1024) -> str:
"""Compute the SHA-256 digest of a file on disk."""
hasher = hashlib.sha256()
with open(path, "rb") as fh:
with open(_fs_path(path), "rb") as fh:
for buf in iter(lambda: fh.read(chunk), b""):
hasher.update(buf)
return hasher.hexdigest()


def _fs_path(path: Path) -> str:
"""Return a filesystem path string, preserving long Windows paths."""
if os.name != "nt":
return str(path)
value = str(path.resolve())
if value.startswith("\\\\?\\"):
return value
if value.startswith("\\\\"):
return "\\\\?\\UNC\\" + value[2:]
return "\\\\?\\" + value


def _path_is_file(path: Path) -> bool:
return os.path.isfile(_fs_path(path))


def _now_iso() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")

Expand Down Expand Up @@ -177,18 +193,18 @@ def _resolve_artifact_path(
candidates.extend((base_dir / path, base_dir.parent / path))
candidates.append(path)
for candidate in candidates:
if candidate.is_file():
if _path_is_file(candidate):
return candidate
return candidates[0]


def _entry_to_locked(row: dict[str, Any], *, base_dir: Path | None) -> LockedArtifact | None:
path = _resolve_artifact_path(row["file_path"], base_dir, variable=row.get("variable"))
if not path.is_file():
if not _path_is_file(path):
return None
size: int | None = None
try:
size = path.stat().st_size
size = os.stat(_fs_path(path)).st_size
except OSError:
pass
return LockedArtifact(
Expand Down Expand Up @@ -458,7 +474,7 @@ def verify_frozen(
)
continue
p = _resolve_artifact_path(file_path, base_dir, variable=variable)
if not p.is_file():
if not _path_is_file(p):
mismatches.append(
LockMismatch(
kind="missing",
Expand Down Expand Up @@ -521,7 +537,7 @@ def verify_inputs_strict(
if meta is None:
continue
p = _resolve_artifact_path(file_path, base_dir, variable=variable)
if not p.is_file():
if not _path_is_file(p):
mismatches.append(
LockMismatch(
kind="missing",
Expand Down Expand Up @@ -624,7 +640,19 @@ def restore_archive(archive: Path | str, dest_dir: Path | str) -> Path:
stream, raw, mode = _open_reader(archive)
try:
with tarfile.open(fileobj=stream, mode=mode) as tar:
tar.extractall(dest_dir, filter="data")
extract_root = _fs_path(dest_dir)
for member in tar:
filtered = tarfile.data_filter(member, str(dest_dir))
if filtered is None:
continue
/ filtered.name
if not filtered.isdir():
os.makedirs(_fs_path(target.parent), exist_ok=True)
tar.extract(
filtered,
path=extract_root,
filter=lambda item, _dest: item,
)
finally:
if hasattr(stream, "close"):
stream.close()
Expand All @@ -636,7 +664,7 @@ def restore_archive(archive: Path | str, dest_dir: Path | str) -> Path:
locked = read_lockfile(lockfile_path)
for la in locked:
candidate = dest_dir / "artefacts" / la.sha256 / Path(la.file_path).name
if candidate.is_file():
if _path_is_file(candidate):
actual = sha256_of(candidate)
if actual != la.sha256:
raise RuntimeError(
Expand Down
63 changes: 52 additions & 11 deletions hydromodpy/simulation/extraction/post_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@

from __future__ import annotations

import hashlib
import inspect
import math
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Any

from hydromodpy.core.storage_naming import sanitize_segment
from hydromodpy.core.contracts.solver_registry import get_solver_registry_provider
from hydromodpy.core.logging import get_logger
from hydromodpy.simulation.planning.plan import RunContext, RunExecutionResult
Expand Down Expand Up @@ -356,9 +359,32 @@ def _auto_export(

export = config.export

label = export_label or sim_id[:8]
raw_label = export_label or sim_id[:8]
base_dir = Path(export.output_dir) if export.output_dir else store.project_path / "exports"
output_dir = base_dir / label
output_dir = base_dir / raw_label
specs = _auto_export_specs(export, output_dir)
if _auto_export_specs_need_short_label(specs, output_dir=output_dir):
output_dir = base_dir / _short_auto_export_label(raw_label)
specs = _auto_export_specs(export, output_dir)

if not specs:
return

output_dir.mkdir(parents=True, exist_ok=True)
failures: list[str] = []
for spec in specs:
try:
store.export(sim_id, spec)
except Exception as exc:
failures.append(f"{spec.fmt.value}:{spec.var}: {exc}")

if failures:
raise RuntimeError(f"Auto-export failed for sim {sim_id}: " + "; ".join(failures))


def _auto_export_specs(export: Any, output_dir: Path) -> list[Any]:
"""Build automated export specs for one output directory."""
from hydromodpy.core.config_kit.export_spec import ExportSpec

specs: list[ExportSpec] = []

Expand Down Expand Up @@ -395,17 +421,32 @@ def _auto_export(
for art in export.artifacts:
dest = art.dest if art.dest.is_absolute() else output_dir / art.dest
specs.append(art.model_copy(update={"dest": dest}))
return specs

if not specs:
return

output_dir.mkdir(parents=True, exist_ok=True)
failures: list[str] = []
def _auto_export_specs_need_short_label(
specs: list[Any],
*,
output_dir: Path,
max_path: int = 240,
) -> bool:
"""Return True when auto-export-generated paths need a shorter label."""
if os.name != "nt":
return False
for spec in specs:
dest = Path(spec.dest)
try:
store.export(sim_id, spec)
except Exception as exc:
failures.append(f"{spec.fmt.value}:{spec.var}: {exc}")
dest.relative_to(output_dir)
except ValueError:
continue
if len(str(dest.resolve())) >= max_path:
return True
return False

if failures:
raise RuntimeError(f"Auto-export failed for sim {sim_id}: " + "; ".join(failures))

def _short_auto_export_label(label: str, max_prefix_len: int = 32) -> str:
"""Return a stable short export label for long Windows auto-export paths."""
text = str(label or "").strip() or "run"
digest = hashlib.sha1(text.encode("utf-8")).hexdigest()[:8]
prefix = sanitize_segment(text, max_len=max_prefix_len)
return f"{prefix}__{digest}"
12 changes: 12 additions & 0 deletions hydromodpy/solver/modflow6/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ def mf6_safe_name(name: str, max_len: int = 16) -> str:
return f"{text[:prefix_len]}_{digest}"


def mf6_workspace_name(model_folder: str, model_name: str, max_path: int = 240) -> str:
"""Return the solver workspace folder name, shortened on long Windows paths."""
requested = str(model_name)
if os.name != "nt":
return requested
safe_name = mf6_safe_name(requested)
package_path = os.path.join(str(model_folder), requested, f"{safe_name}.tdis")
if len(os.path.abspath(package_path)) < max_path:
return requested
return safe_name


def mf6_output_name(model, extension: str = ".cbc") -> str:
"""Return an output file stem that keeps MF6 paths usable on Windows."""
requested = str(getattr(model, "model_name", "") or "model")
Expand Down
4 changes: 3 additions & 1 deletion hydromodpy/solver/modflow6/modflow6.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
apply_preprocess_options,
log_xt3d_resolution,
mf6_safe_name,
mf6_workspace_name,
resolve_flow_regime,
resolve_ims_complexity_for,
resolve_xt3d_options,
Expand Down Expand Up @@ -65,6 +66,7 @@ def __init__(

self.model_name = model_name
self.model_name_mf6 = mf6_safe_name(model_name)
self.model_workspace_name = mf6_workspace_name(model_folder, model_name)
self.model_output_name = model_name
self.geographic = geographic
self.flow = None
Expand All @@ -73,7 +75,7 @@ def __init__(
self.last_flow_solve_time_seconds: float | None = None
self.prob_cells = 0

self.full_path = os.path.join(model_folder, model_name)
self.full_path = os.path.join(model_folder, self.model_workspace_name)
self.dem_watershed_path = None
self.grid_ctx: SolverGridContext | None = None
self.routing_ctx: SolverRoutingContext | None = None
Expand Down
5 changes: 3 additions & 2 deletions hydromodpy/solver/modflow6/prt.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from hydromodpy.core.units.time import SECONDS_PER_DAY, factor_to_seconds
from hydromodpy.solver.base.protocols import DomainLike, FlowModelLike, TransportLike
from hydromodpy.solver.modflow6.build import mf6_safe_name
from hydromodpy.solver.modflow6.build import mf6_safe_name, mf6_workspace_name


def _as_float_list(values: Sequence[float] | None) -> list[float] | None:
Expand Down Expand Up @@ -79,10 +79,11 @@ def __init__(
self.model_modflow = model_modflow
self.model_folder = model_folder
self.model_name = model_name
self.model_workspace_name = mf6_workspace_name(model_folder, model_name)
self.suffix_name = suffix_name
self.model_name_prt = model_name + suffix_name
self.model_name_prt_mf6 = mf6_safe_name(self.model_name_prt)
self.full_path = os.path.join(model_folder, model_name)
self.full_path = os.path.join(model_folder, self.model_workspace_name)
self.exe = getattr(model_modflow, "exe", "mf6")

prt_params: dict[str, object] = {}
Expand Down
9 changes: 7 additions & 2 deletions hydromodpy/solver/modflow6/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
import numpy as np

from hydromodpy.solver.base.protocols import DomainLike, FlowModelLike, TransportLike
from hydromodpy.solver.modflow6.build import mf6_safe_name, optional_ims_kwargs
from hydromodpy.solver.modflow6.build import (
mf6_safe_name,
mf6_workspace_name,
optional_ims_kwargs,
)
from hydromodpy.solver.modflow6.postprocess import run_transport_post_processing
from hydromodpy.solver.modflow_common import (
ModflowPostprocessOptions,
Expand Down Expand Up @@ -45,10 +49,11 @@ def __init__(
self.model_modflow = model_modflow
self.model_folder = model_folder
self.model_name = model_name
self.model_workspace_name = mf6_workspace_name(model_folder, model_name)
self.suffix_name = suffix_name
self.model_name_mt = model_name + suffix_name
self.model_name_mt_mf6 = mf6_safe_name(self.model_name_mt)
self.full_path = os.path.join(model_folder, model_name)
self.full_path = os.path.join(model_folder, self.model_workspace_name)
self.exe = getattr(model_modflow, "exe", "mf6")

conc_params = {}
Expand Down
22 changes: 11 additions & 11 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,17 +208,17 @@ def _cleanup_stale_test_scratch_sessions(scratch_root: Path, current_token: str)
os.environ.setdefault("HMP_STATE_HOME", str(_TEST_STATE_ROOT))
if _OWNS_TEST_SCRATCH:
os.environ[_SCRATCH_OWNER_ENV] = _SCRATCH_OWNER_TOKEN
# Workers must override TMPDIR (the master already set it to the shared root).
if _WORKER_SUFFIX:
os.environ["PYTEST_DEBUG_TEMPROOT"] = str(_TEST_PYTEST_ROOT)
os.environ["TMPDIR"] = str(_TEST_TMP_ROOT)
os.environ["TMP"] = str(_TEST_TMP_ROOT)
os.environ["TEMP"] = str(_TEST_TMP_ROOT)
else:
os.environ.setdefault("PYTEST_DEBUG_TEMPROOT", str(_TEST_PYTEST_ROOT))
os.environ.setdefault("TMPDIR", str(_TEST_TMP_ROOT))
os.environ.setdefault("TMP", str(_TEST_TMP_ROOT))
os.environ.setdefault("TEMP", str(_TEST_TMP_ROOT))
# Always override process temp roots inside pytest. Regression and validation
# helpers build deterministic folders under tempfile.gettempdir(); inheriting
# the user's global TEMP lets concurrent pytest sessions delete each other's
# solver scratch directories on Windows.
os.environ["PYTEST_DEBUG_TEMPROOT"] = str(_TEST_PYTEST_ROOT)
os.environ["TMPDIR"] = str(_TEST_TMP_ROOT)
os.environ["TMP"] = str(_TEST_TMP_ROOT)
os.environ["TEMP"] = str(_TEST_TMP_ROOT)
# tempfile.gettempdir() is cached in-process; conftest already called it while
# resolving the scratch root, so reset the cache after exporting the test temp.
tempfile.tempdir = str(_TEST_TMP_ROOT)


def _ensure_test_scratch_dirs() -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/test_workflow_from_scratch.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def test_workflow_from_scratch_init_and_catalog(tmp_path: Path) -> None:
)
dem_dir = workspace / "data" / "dem"
assert dem_dir.is_dir(), "hmp data get must write into data/<variable>/"
raw_files = list(dem_dir.glob("dem_*.tif"))
raw_files = list(dem_dir.glob("dem_upstream_*.tif"))
assert raw_files, "no fetched DEM placeholder under data/dem/"
sidecar = raw_files[0].with_suffix(raw_files[0].suffix + ".json")
assert sidecar.is_file(), "sidecar JSON missing next to fetched file"
Expand Down
17 changes: 14 additions & 3 deletions tests/regression/data/test_data_freeze_determinism.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ def _normalise(text: str) -> str:
return _VOLATILE.sub("<ts>", text)


def _input_content_without_timestamps(doc) -> dict[str, dict[str, object]]:
"""Return deterministic input content, excluding volatile fetch times."""
inputs: dict[str, dict[str, object]] = {}
for key, value in doc["inputs"].items():
payload = dict(value)
payload.pop("fetched_at", None)
inputs[str(key)] = payload
return inputs


def _seed_two_inputs(tmp: Path) -> tuple[DataCatalogDuckDB, Path, Path, Path]:
"""Two catalog entries under a workspace; deterministic byte payloads."""
workspace = tmp / "workspace"
Expand Down Expand Up @@ -102,8 +112,9 @@ def test_two_writes_are_byte_identical_after_timestamp_normalisation(
# And the parsed content sections are exactly equal.
doc_a = tomlkit.parse(text_a)
doc_b = tomlkit.parse(text_b)
for section in ("schema", "binaries", "inputs"):
for section in ("schema", "binaries"):
assert dict(doc_a[section]) == dict(doc_b[section])
assert _input_content_without_timestamps(doc_a) == _input_content_without_timestamps(doc_b)


def test_input_ordering_is_stable_across_writes(tmp_path: Path) -> None:
Expand Down Expand Up @@ -282,7 +293,7 @@ def test_gzip_archive_round_trip_preserves_sha256(tmp_path: Path) -> None:
catalog.close()
assert archive.is_file()

restore_dir = tmp_path / "restored"
restore_dir = tmp_path / "r"
restore_archive(archive, restore_dir)

lock = restore_dir / LOCKFILE_NAME
Expand All @@ -304,7 +315,7 @@ def test_zstandard_archive_round_trip_preserves_sha256(tmp_path: Path) -> None:
catalog.close()
assert archive.is_file()

restore_dir = tmp_path / "restored_zst"
restore_dir = tmp_path / "rz"
restore_archive(archive, restore_dir)

assert (restore_dir / LOCKFILE_NAME).is_file()
Expand Down
Loading