Skip to content
Open
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
19 changes: 19 additions & 0 deletions docs/api/geometry.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ reference-line output.

- `add_reference_lines(geom_file, lines, storage_area)` - Insert manually
supplied reference lines into a `.g##` file
- `replace_reference_lines(geom_file, lines, storage_area, expected_existing_names=...)` - Atomically replace or remove one 2D area's complete reference-line collection while preserving other areas, with an optional ordered concurrency guard
- `generate_reference_lines_from_longitudinal_line(...)` - Generate
transverse reference-line dictionaries at regular station intervals along a
named longitudinal line
Expand Down Expand Up @@ -199,10 +200,17 @@ back to normal-to-line orientation unless `orientation_fallback="raise"` is set.
Headless 2D mesh generation helpers and compiled geometry HDF refinement-region
utilities.

### Domain and Mesh Methods

- `audit_domain_containment(geom_number, mesh_name=..., cell_size=..., ras_object=...)` - Fail closed unless every breakline, refinement region, and structure associated with the selected 2D area is wholly covered by the exact compiled perimeter buffered **inward** by one base mesh-cell spacing. BC lines are intentionally excluded because they are authored on the perimeter and require a separate association/overlap audit.
- `generate(geom_number, mesh_name=..., ras_object=...)` - Regenerate the mesh and automatically run the same inward one-cell containment gate before loading native RAS Mapper dependencies.
- `compute_property_tables(geom_number, mesh_name=..., ras_object=...)` - Compute face profiles, Manning's n assignments, face hydraulic tables, and cell properties against the restored geometry associations.

### Refinement Region Methods

- `add_refinement_region(geom_number, polygon, spacing_dx, ...)` - Add one refinement polygon to an existing compiled geometry HDF.
- `add_flowline_refinement_regions(geom_number, flowlines, buffer_width, ...)` - Buffer GeoDataFrame or LineString channel flowlines into refinement-region polygons, optionally simplify/trim them, write them through `add_refinement_region()`, and return FID/name/spacing mappings.
- `replace_refinement_regions(geom_number, regions, expected_existing_names=..., ...)` - Atomically replace or remove the complete HDF refinement-region collection, with an optional optimistic-concurrency guard.
- `get_refinement_regions(geom_number)` - Read refinement-region FID, name, and spacing values from a compiled geometry HDF.
- `set_refinement_region_spacing(geom_number, spacing_dx, ...)` - Update spacing for one or more existing refinement regions.
- `set_refinement_region_name(geom_number, new_name, ...)` - Rename an existing refinement region.
Expand Down Expand Up @@ -359,6 +367,17 @@ Storage area and 2D flow area geometry parsing and writing.
- `get_2d_flow_area_settings(geom_file)` - Read 2D flow area computation settings
- `set_2d_flow_area_settings(geom_file, area_name, **settings)` - Write 2D flow area settings (subgrid sampling, composite classification)
- `write_2d_flow_area_perimeter(geom_file, area_name, coordinates, ...)` - Write 2D flow area perimeter
- `replace_breaklines(geom_file, flow_area_name, breaklines, expected_existing_names=..., ...)` - Atomically replace the geometry-global breakline collection while preserving supplied near/far spacing, near-repeat, and protection-radius values.

## MeshRegenerationWorkflow

Exact RAS Mapper geometry import and legacy mesh-regeneration GUI workflows.

### Methods

- `refresh_geometry_hdf_from_text(geom_number=..., geometry_name=..., flow_area_name=..., ras_object=..., ...)` - Transactionally displace one exact geometry HDF, let the explicitly initialized HEC-RAS version rebuild it from task-local `.g##` text, validate the exact 2D perimeter and sibling-HDF isolation, and roll back on failure. This imports geometry features but does not create computation cells.
- `regenerate_mesh(geom_number=..., geometry_name=..., flow_area_name=..., ras_object=..., ...)` - Open/save and validate an already-current exact geometry and compiled mesh.
- `regenerate_mesh_iterative(...)` - Legacy retry workflow; exact geometry selectors are supported and no first-registration fallback is used.

## GeomLevee

Expand Down
126 changes: 126 additions & 0 deletions docs/user-guide/geometry-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,132 @@ regional = RasGeo.get_regional_mannings("01", "2D Flow Area")
RasGeo.set_base_mannings_table("01", updated_table)
```

## Rebuilding a copied 2D geometry from text

For a task-local breakout model, initialize the copied project with the HEC-RAS
version that will perform the work. An existing geometry HDF remains
authoritative in RAS Mapper, so opening and saving it does **not** import an
externally edited `.g##` perimeter or breakline collection. Use the exact,
transactional import workflow instead:

```python
from pathlib import Path
from ras_commander import (
GeomMesh,
GeomReferenceFeatures,
GeomStorage,
init_ras_project,
)
from ras_commander.gui.workflows import MeshRegenerationWorkflow

ras = init_ras_project(
Path(r"C:\tasks\breakout\Model.prj"),
"6.6",
ras_object="new",
load_results_summary=False,
)

# The caller has already edited only the task-local cloned g03 text.
GeomStorage.set_2d_flow_area_perimeter(
ras.project_folder / "Model.g03",
"Breakout Area",
reduced_domain_polygon,
)
GeomStorage.replace_breaklines(
ras.project_folder / "Model.g03",
"Breakout Area",
retained_and_clipped_breaklines,
expected_existing_names=source_breakline_names,
)

# Remove parent reference lines outside the reduced domain and replace the
# retained collection in one guarded text mutation.
GeomReferenceFeatures.replace_reference_lines(
ras.project_folder / "Model.g03",
retained_reference_lines,
storage_area="Breakout Area",
expected_existing_names=source_reference_line_names,
)

refresh = MeshRegenerationWorkflow.refresh_geometry_hdf_from_text(
geom_number="03",
geometry_name="Breakout Geometry",
flow_area_name="Breakout Area",
ras_object=ras,
)
if not refresh.success:
raise refresh.error

# HDF-only collections are replaced after RAS Mapper imports the text.
GeomMesh.replace_refinement_regions(
"03",
retained_refinement_regions,
expected_existing_names=source_refinement_names,
ras_object=ras,
)

# The admissible feature envelope is smaller than the new domain: the exact
# compiled perimeter buffered inward by one full base-cell spacing.
containment = GeomMesh.audit_domain_containment(
"03",
mesh_name="Breakout Area",
ras_object=ras,
)
if not containment.ok:
raise ValueError(containment.violations)

mesh = GeomMesh.generate(
"03",
mesh_name="Breakout Area",
ras_object=ras,
hecras_dir=Path(ras.ras_exe_path).parent,
)
if not mesh.ok:
raise RuntimeError(mesh.error_message)

GeomMesh.compute_property_tables(
"03",
mesh_name="Breakout Area",
ras_object=ras,
)
```

The import defaults to the geometry referenced by the sole current plan when
`geom_number` is omitted. If both number and name are supplied, they must
identify the same unique RAS Mapper layer. Only that HDF is displaced; failure
restores it, non-target geometry HDFs must retain their size and modification
time, and the post-save perimeter must match the text geometrically. The GUI
process tree is supervised from the owned `Ras.exe` PID and no global process
cleanup is performed. Terrain, land-cover, infiltration, and sediment
associations are captured before the import and restored and validated on the
replacement HDF. A missing associated artifact or failed restoration rolls the
transaction back.

The containment buffer is **inward**, not outward. With a 200-foot base mesh,
the eligible feature envelope is the new perimeter eroded by 200 feet.
Breaklines, refinement regions, and structures must be wholly inside that
envelope before meshing begins. Do not apply this gate to BC lines: inflow and
outflow lines belong on the perimeter and instead need exact 2D-area
association, external-face coverage, endpoint clearance, and mutual-overlap
checks. Reference lines are result-extraction features rather than meshing
constraints; remove any parent line that no longer intersects the reduced
domain so HEC-RAS cannot fail during results processing.

`HdfBndry.get_breaklines()` exposes `cell_spacing_near`,
`cell_spacing_far`, `near_repeats`, and `protection_radius` so a clipped
collection can preserve the parent BLE meshing controls. Multipart intersections
must be emitted as uniquely named single-part breaklines because the text format
stores one polyline per breakline block.

Treat the resulting compiled geometry and property tables as reusable run
inputs. A compute may still perform ordinary plan preparation, but do not clear
geometry-preprocessor artifacts for every hydrograph or rating-curve ordinate.
On a real 200,226-cell HEC-RAS 6.6 qualification model, a two-hour Diffusion
Wave solve used about 9 seconds while complete plan preparation and results
processing used about 44 minutes. The operational optimization is therefore to
prepare and certify each immutable reduced geometry once, then reuse it across
the flow series unless geometry-owned inputs change.

## Hydraulic Tables (HTAB)

Extract property tables from preprocessed geometry HDF:
Expand Down
145 changes: 140 additions & 5 deletions ras_commander/RasCmdr.py
Original file line number Diff line number Diff line change
Expand Up @@ -2145,13 +2145,40 @@ def compute_plan_linux(
f"See examples/510_linux_execution.ipynb for the complete workflow."
)

# Set num_cores if specified
effective_num_cores = None
# Set num_cores in both the text plan and the compiled execution HDF.
# Native RasUnsteady reads the latter; changing only .p## leaves the
# Phase-1 core count in force.
if num_cores is not None:
try:
RasPlan.set_num_cores(plan_path, num_cores=num_cores, ras_object=ras_obj)
logger.info(f"Set number of cores to {num_cores} for plan: {plan_num_str}")
effective_num_cores = RasCmdr._effective_linux_core_count(num_cores)
RasPlan.set_num_cores(
plan_path,
num_cores=effective_num_cores,
ras_object=ras_obj,
)
hdf_core_evidence = None
if not layout["needs_c_file"]:
hdf_core_evidence = RasCmdr._set_linux_hdf_num_cores(
tmp_hdf,
effective_num_cores,
)
logger.info(
"Configured %d native solver core(s) for plan %s%s",
effective_num_cores,
plan_num_str,
(
f" in {len(hdf_core_evidence['updated_attributes'])} "
"compiled-HDF attribute(s)"
if hdf_core_evidence is not None
else ""
),
)
except Exception as e:
logger.error(f"Error setting number of cores: {e}")
raise RuntimeError(
f"Could not configure native solver cores for plan "
f"{plan_num_str}: {e}"
) from e

if run_via_wsl:
return RasCmdr._compute_plan_linux_via_wsl(
Expand Down Expand Up @@ -2224,6 +2251,9 @@ def _create_io_link(source: Path, io_name: str):

env = os.environ.copy()
env["LD_LIBRARY_PATH"] = ld_path
if effective_num_cores is not None:
env["OMP_NUM_THREADS"] = str(effective_num_cores)
env["MKL_NUM_THREADS"] = str(effective_num_cores)

log_path = project_dir / f"compute_linux_{plan_num_str}.log"
success = False
Expand Down Expand Up @@ -2384,6 +2414,87 @@ def _resolve_linux_layout(ras_exe_dir: Path) -> dict:
"label": "canonical",
}

@staticmethod
def _effective_linux_core_count(requested_cores: int) -> int:
"""Cap a requested solver core count to the process affinity envelope."""
if (
isinstance(requested_cores, bool)
or not isinstance(requested_cores, Number)
or int(requested_cores) != requested_cores
or int(requested_cores) < 1
):
raise ValueError("num_cores must be a positive integer")
requested = int(requested_cores)

available = None
affinity_reader = getattr(os, "sched_getaffinity", None)
if callable(affinity_reader):
try:
available = len(affinity_reader(0))
except (OSError, TypeError):
available = None
if not available:
cpu_reader = getattr(os, "cpu_count", None)
if callable(cpu_reader):
available = cpu_reader()
if not available:
return requested

effective = min(requested, int(available))
if effective < requested:
logger.warning(
"Capped requested native solver cores from %d to %d based on "
"the process affinity envelope",
requested,
effective,
)
return effective

@staticmethod
def _set_linux_hdf_num_cores(tmp_hdf: Path, num_cores: int) -> dict:
"""Write the effective core count into a canonical plan ``*.tmp.hdf``."""
import h5py
import numpy as np

tmp_hdf = Path(tmp_hdf)
if not tmp_hdf.name.casefold().endswith(".tmp.hdf"):
raise ValueError("Core control target must be a '*.tmp.hdf' file")
if not tmp_hdf.is_file():
raise FileNotFoundError(f"Compiled plan HDF not found: {tmp_hdf}")

updated_attributes = []
with h5py.File(tmp_hdf, "r+") as hdf_file:
parameters = hdf_file.get("Plan Data/Plan Parameters")
if parameters is None:
raise ValueError("Compiled plan HDF lacks /Plan Data/Plan Parameters")
for attribute_name in ("1D Cores", "2D Cores (per mesh)"):
if attribute_name not in parameters.attrs:
continue
prior = np.asarray(parameters.attrs[attribute_name])
replacement = np.full(prior.shape, num_cores, dtype=prior.dtype)
if prior.shape == ():
replacement = replacement[()]
parameters.attrs.modify(attribute_name, replacement)
updated_attributes.append(
{
"attribute": attribute_name,
"before": prior.tolist(),
"after": np.asarray(
parameters.attrs[attribute_name]
).tolist(),
}
)
if not updated_attributes:
raise ValueError(
"Compiled plan HDF contains no supported solver-core attributes"
)
hdf_file.flush()
return {
"path": str(tmp_hdf),
"effective_cores": num_cores,
"updated_attributes": updated_attributes,
}

@staticmethod
def _build_linux_ld_path(ras_exe_dir: Path, layout: dict) -> str:
"""Build LD_LIBRARY_PATH for a Linux RasUnsteady run, per layout (CLB-886)."""
Expand Down Expand Up @@ -2439,14 +2550,38 @@ def _validate_linux_solve(log_path, result_hdf, plan_num_str: str):
for marker in error_markers:
if marker in low:
return False, f"solver log reports failure ('{marker}')"
import re

explicit_error = re.search(
r"(?im)^\s*(?:error\s*:|hdf_error\b)",
log_text,
)
if explicit_error:
return False, (
"solver log reports failure "
f"('{explicit_error.group(0).strip()}')"
)
if "finished unsteady flow simulation" not in low:
return False, "solver log missing 'Finished Unsteady Flow Simulation' banner"
try:
import h5py
with h5py.File(str(result_hdf), "r") as hf:
results = hf.get("Results")
if results is None:
return False, "result HDF missing /Results group"
if results.get("Unsteady") is None:
unsteady = results.get("Unsteady")
if unsteady is None:
return False, "result HDF missing /Results/Unsteady group"
populated_datasets = 0

def _count_populated(_name, item):
nonlocal populated_datasets
if isinstance(item, h5py.Dataset) and item.size > 0:
populated_datasets += 1

unsteady.visititems(_count_populated)
if populated_datasets == 0:
return False, "result HDF has no populated /Results/Unsteady datasets"
except Exception as e:
return False, f"result HDF unreadable or invalid: {e}"
return True, "ok"
Expand Down
Loading
Loading