Diese Seite wurde maschinell aus dem Englischen übersetzt und kann Fehler enthalten. Englisches Original

Quellcode für axqua.solvers.telemac.sections

"""Discharge across internal cross-section ("baffle") lines.

TELEMAC's own ``CONTROL SECTIONS`` keyword reports the flux across sections defined
by *node pairs* in the steering file, which means the section geometry has to be
pinned to mesh node numbers before the run. This module does the same job
**afterwards, from the result file**, so the sections can be drawn freely in GIS as
a line layer and changed without re-running the solver.

For each line the unit-discharge vector ``q = (H*U, H*V)`` is interpolated from the
mesh onto dense sample points along the line and integrated against the line normal:

.. math:: Q = \\int_L (q \\cdot n) \\, ds

The normal is the right-hand normal of the digitized line; because the digitizing
direction of a GIS layer is arbitrary, the sign is normalized so that a section
reports the discharge **positive in the direction of its own net flow** (the sign
convention is reported in ``orientation``). A braided reach can therefore be split
into its threads and each thread's share read off directly.

Accuracy notes
--------------
* Interpolation is linear within the triangle containing each sample point
  (:class:`matplotlib.tri.LinearTriInterpolator`), i.e. the same P1 basis TELEMAC
  itself uses for the depth and velocity fields - so the integral is consistent
  with the discretization rather than a nearest-node approximation.
* Sample spacing defaults to a quarter of the local mesh edge length, which keeps
  the trapezoidal integration error well below the solver's own mass imbalance.
* Coordinates come from the *geometry* SELAFIN (written in double precision) when
  available: the result file is single precision, and on UTM easting ~677 000 m the
  float32 spacing is ~0.06 m - a fifth of the 0.3 m channel cell size, which would
  visibly distort the section geometry.
"""

from __future__ import annotations

import logging
from pathlib import Path

log = logging.getLogger(__name__)

#: sample spacing along a section, as a fraction of the mean mesh edge length
SAMPLE_FRACTION = 0.25
#: never sample coarser than this (m), whatever the mesh
MAX_SAMPLE_SPACING = 0.5


def _mean_edge_length(x, y, ikle) -> float:
    import numpy as np

    t = ikle - 1 if ikle.min() >= 1 else ikle
    p = np.column_stack([x, y])
    d = [np.hypot(*(p[t[:, a]] - p[t[:, b]]).T) for a, b in ((0, 1), (1, 2), (2, 0))]
    d = np.concatenate(d)
    d = d[d > 0]
    return float(d.mean()) if d.size else 1.0


def _read_fields(results: Path, geometry: Path | None):
    """Last frame of *results* plus double-precision coordinates from *geometry*."""
    import numpy as np

    from axqua.core.selafin import read_slf

    res = read_slf(Path(results))
    vals = res["values"]
    names = {n.strip().upper(): n for n in vals}
    need = ("WATER DEPTH", "VELOCITY U", "VELOCITY V")
    missing = [q for q in need if q not in names]
    if missing:
        raise ValueError(f"{Path(results).name}: result file lacks {missing} "
                         "(the .cas graphic printout needs 'U,V,H')")
    h = np.asarray(vals[names["WATER DEPTH"]], dtype=float)
    u = np.asarray(vals[names["VELOCITY U"]], dtype=float)
    v = np.asarray(vals[names["VELOCITY V"]], dtype=float)

    x, y, ikle = res["x"], res["y"], res["ikle"]
    if geometry is not None and Path(geometry).exists():
        geo = read_slf(Path(geometry))
        if len(geo["x"]) != len(x):
            raise ValueError(
                f"{Path(geometry).name} has {len(geo['x'])} nodes but "
                f"{Path(results).name} has {len(x)}: they come from different builds. "
                "Re-run the solver so the result matches the current mesh "
                "(a stale result cannot be sampled on the new geometry)."
            )
        x, y, ikle = geo["x"], geo["y"], geo["ikle"]
    else:
        # single-precision result coordinates: on UTM eastings the float32 spacing is
        # ~0.06 m, which merges nodes of a sub-metre mesh into degenerate triangles.
        log.warning("no geometry file given - integrating on the result's "
                    "single-precision coordinates; sub-metre meshes may fail to "
                    "triangulate")
    t = ikle - 1 if ikle.min() >= 1 else ikle
    return np.asarray(x, float), np.asarray(y, float), np.asarray(t), h, u, v


[Doku] def line_discharges(results: Path, lines, *, geometry: Path | None = None, name_field: str | None = None, crs_epsg: int | None = None): """Integrate the discharge across each line of the *lines* layer. ``lines`` is a path to a line layer (any CRS - reprojected to *crs_epsg*) or an already-loaded GeoDataFrame. Returns a DataFrame with one row per line: ``name``, ``discharge`` (m3/s, positive along the section's net flow), ``wetted_width``, ``mean_depth``, ``max_depth``, ``mean_velocity``, ``max_velocity``, ``orientation``. The maxima are over the wet part of the section and exist for comparing against laboratory data reported per cross-section rather than per point (:mod:`axqua.labdata`). """ import geopandas as gpd import numpy as np import pandas as pd from matplotlib.tri import LinearTriInterpolator, Triangulation x, y, tri, h, u, v = _read_fields(results, geometry) triang = Triangulation(x, y, tri) # interpolate the unit discharge (H*U, H*V), not U and H separately: the product # of two P1 fields is not P1, and on a partly wet section interpolating H*U # keeps the flux exactly zero where the depth is zero. interp_qx = LinearTriInterpolator(triang, h * u) interp_qy = LinearTriInterpolator(triang, h * v) interp_h = LinearTriInterpolator(triang, h) # the velocity itself, for the section maxima below: |q|/h is indeterminate # at a drying sample, where both go to zero interp_u = LinearTriInterpolator(triang, u) interp_v = LinearTriInterpolator(triang, v) if isinstance(lines, (str, Path)): gdf = gpd.read_file(lines) else: gdf = lines.copy() if crs_epsg is not None and gdf.crs is not None and gdf.crs.to_epsg() != crs_epsg: gdf = gdf.to_crs(epsg=crs_epsg) if name_field is None: cand = [c for c in gdf.columns if c != gdf.geometry.name and gdf[c].dtype == object] name_field = cand[0] if cand else None ds = min(SAMPLE_FRACTION * _mean_edge_length(x, y, tri), MAX_SAMPLE_SPACING) rows = [] for i, rec in gdf.iterrows(): geom = rec.geometry if geom is None or geom.is_empty: continue name = str(rec[name_field]) if name_field else f"section-{i + 1}" n = max(3, int(np.ceil(geom.length / ds)) + 1) s = np.linspace(0.0, geom.length, n) pts = np.array([[geom.interpolate(t).x, geom.interpolate(t).y] for t in s]) # tangent by central differences -> right-hand normal (ty, -tx) tx = np.gradient(pts[:, 0], s) ty = np.gradient(pts[:, 1], s) norm = np.hypot(tx, ty) norm[norm == 0] = 1.0 nx, ny = ty / norm, -tx / norm qx = np.asarray(interp_qx(pts[:, 0], pts[:, 1]).filled(0.0), dtype=float) qy = np.asarray(interp_qy(pts[:, 0], pts[:, 1]).filled(0.0), dtype=float) hh = np.asarray(interp_h(pts[:, 0], pts[:, 1]).filled(0.0), dtype=float) qn = qx * nx + qy * ny q_signed = float(np.trapezoid(qn, s)) wet = hh > 1e-3 width = float(np.trapezoid(wet.astype(float), s)) mean_h = float(hh[wet].mean()) if wet.any() else 0.0 area = float(np.trapezoid(hh, s)) mean_u = abs(q_signed) / area if area > 1e-9 else 0.0 # Maxima along the section, for comparing against a laboratory campaign that # reports a section maximum rather than a value at a coordinate (see # axqua.labdata). Taken over the wet samples only: the speed at a dry sample is # the ratio of two vanishing numbers, and including it turns the drying front # into the section's maximum velocity. speed = np.hypot( np.asarray(interp_u(pts[:, 0], pts[:, 1]).filled(0.0), dtype=float), np.asarray(interp_v(pts[:, 0], pts[:, 1]).filled(0.0), dtype=float)) max_h = float(hh[wet].max()) if wet.any() else 0.0 max_u = float(speed[wet].max()) if wet.any() else 0.0 # normalize the sign: report the section's own net flow as positive flip = q_signed < 0 rows.append({ "name": name, "discharge": abs(q_signed), "wetted_width": width, "mean_depth": mean_h, "max_depth": max_h, "mean_velocity": mean_u, "max_velocity": max_u, "orientation": "left-hand" if flip else "right-hand", }) log.info(" section %-16s Q = %8.4f m3/s (wet %5.1f m, mean h %.3f m, " "max h %.3f m, mean |U| %.3f m/s, max |U| %.3f m/s)", name, abs(q_signed), width, mean_h, max_h, mean_u, max_u) return pd.DataFrame(rows)
[Doku] def write_line_discharges(results: Path, lines, out: Path, *, geometry: Path | None = None, name_field: str | None = None, crs_epsg: int | None = None, name_column: str = "Baffle Name", discharge_column: str = "discharge (m3/s)"): """Extract the section discharges and write the two-column CSV to *out*. The CSV carries exactly the section name and its discharge in m3/s; the full table (wetted width, mean depth, mean velocity) is returned for logging. """ df = line_discharges(results, lines, geometry=geometry, name_field=name_field, crs_epsg=crs_epsg) out = Path(out) out.parent.mkdir(parents=True, exist_ok=True) df.rename(columns={"name": name_column, "discharge": discharge_column})[ [name_column, discharge_column] ].to_csv(out, index=False, float_format="%.4f") log.info("wrote %d section discharges -> %s", len(df), out) return df