"""Convenience helpers to read pressure-coefficient timeseries H5 files
into pandas DataFrames -- and from there to CSV or matplotlib.
The v2 timeseries layout stores one 1-D dataset per timestep under
``/{group}/t{T}``, with mesh metadata at ``/Triangles + /Geometry`` and
time arrays at ``/meta``. This module flattens that into the
spreadsheet-friendly **wide-form** ``pandas.DataFrame``::
index = time_normalized (float)
columns = triangle index (int) or region representative
values = scalar coefficient at (time, triangle)
For Cf and Cm, where every triangle in a region carries the same value,
``regions=True`` deduplicates by unique value to give one column per
region. For Cp (truly per-triangle), pass ``triangles=[...]`` to filter
to a tractable subset before exporting.
"""
from __future__ import annotations
__all__ = ["read_timeseries_df", "to_csv", "plot_timeseries"]
import pathlib
from collections.abc import Iterable
from typing import TYPE_CHECKING
import h5py
import numpy as np
import pandas as pd
if TYPE_CHECKING:
from matplotlib.axes import Axes
def _read_meta(h5_path: pathlib.Path) -> dict[float, float]:
"""Return a {raw_time: time_normalized} map from the file's /meta group."""
with h5py.File(h5_path, "r") as f:
if "meta" not in f:
return {}
meta = f["meta"]
return {
float(t): float(tn) for t, tn in zip(meta["time_steps"][:], meta["time_normalized"][:])
}
[docs]
def read_timeseries_df(
h5_path: pathlib.Path | str,
group: str,
*,
triangles: Iterable[int] | None = None,
regions: bool = False,
timestep_range: tuple[float, float] | None = None,
max_columns: int = 200,
) -> pd.DataFrame:
"""Read a coefficient timeseries from an XDMF+H5 into a wide-form DataFrame.
Args:
h5_path: Timeseries H5 path (e.g. ``cp.default.time_series.h5``,
``Cf.containers.pack.time_series.h5``).
group: Coefficient group inside the file. Examples:
``"cp"`` (in a Cp file), ``"cf_x"`` / ``"cf_y"`` / ``"cf_z"``
(in a Cf file), ``"cm_x"`` / ``"cm_y"`` / ``"cm_z"`` (Cm file).
triangles: Optional list/iterable of triangle indices to keep as
columns. Mutually exclusive with ``regions=True``.
regions: When True, deduplicate columns by their value pattern --
one representative triangle per unique value vector, named by
the chosen triangle's integer index. Correct for Cf/Cm files
where each region contributes a constant value to all its
triangles. **Do not** use on per-triangle data (Cp).
timestep_range: Optional ``(t_min, t_max)`` filter applied on the
raw time keys (``t{T}`` keys, not on the normalized index).
max_columns: Refuse to return a DataFrame wider than this many
columns unless ``triangles`` or ``regions`` is set; protects
callers that forget to filter on a per-triangle file
(an 80k-tri Cp would be 80k columns wide, way past
spreadsheet usability).
Returns:
DataFrame indexed by ``time_normalized`` with one column per
retained triangle / region representative.
Raises:
ValueError: If the file has no ``/{group}`` group, the timestep
filter yields no rows, both ``triangles`` and ``regions`` are
requested, or the unfiltered column count exceeds
``max_columns``.
"""
if triangles is not None and regions:
raise ValueError("pass either triangles=... or regions=True, not both")
h5_path = pathlib.Path(h5_path)
raw_to_norm = _read_meta(h5_path)
with h5py.File(h5_path, "r") as f:
if group not in f:
raise ValueError(f"{h5_path}:/{group} not found")
grp = f[group]
all_keys = sorted(grp.keys(), key=lambda k: float(k[1:]))
raw_times = np.array([float(k[1:]) for k in all_keys])
if timestep_range is not None:
t_min, t_max = timestep_range
mask = (raw_times >= t_min) & (raw_times <= t_max)
keys = [k for k, m in zip(all_keys, mask) if m]
raw_times = raw_times[mask]
else:
keys = all_keys
if not keys:
raise ValueError(f"no timesteps in {h5_path}:/{group} for range {timestep_range}")
if triangles is not None:
tri_idx = np.array(sorted(set(int(t) for t in triangles)))
data = np.stack([grp[k][:][tri_idx] for k in keys])
cols: list[int] = tri_idx.tolist()
else:
data = np.stack([grp[k][:] for k in keys])
n_tri = data.shape[1]
if regions:
# Each region's triangles share their value vector across
# time, so the first-timestep value already separates regions.
# Pick the lowest-index representative per unique value.
_, first_index = np.unique(data[0], return_index=True)
tri_idx = np.sort(first_index)
data = data[:, tri_idx]
cols = tri_idx.tolist()
else:
if n_tri > max_columns:
raise ValueError(
f"{h5_path}:/{group} has {n_tri} columns -- too wide. "
"Pass triangles=[...] to filter, regions=True to "
"deduplicate per region (Cf/Cm), or override "
"max_columns explicitly."
)
cols = list(range(n_tri))
norm_index = pd.Index(
[raw_to_norm.get(float(t), float(t)) for t in raw_times],
name="time_normalized",
)
return pd.DataFrame(data, index=norm_index, columns=cols)
[docs]
def to_csv(df: pd.DataFrame, path: pathlib.Path | str, **kwargs) -> None:
"""Save a timeseries DataFrame as CSV.
Wide-form by default: first column is ``time_normalized`` (from the
index), one column per retained triangle/region. Drops straight into
Google Sheets / Excel via *Open* / *Import*.
Extra keyword arguments are forwarded to :meth:`pandas.DataFrame.to_csv`.
"""
df.to_csv(pathlib.Path(path), index=True, **kwargs)
[docs]
def plot_timeseries(
df: pd.DataFrame,
columns: Iterable[int] | None = None,
*,
ax: "Axes | None" = None,
title: str | None = None,
ylabel: str = "value",
**plot_kwargs,
) -> "Axes":
"""One-line matplotlib plot of a timeseries DataFrame.
Args:
df: A DataFrame returned by :func:`read_timeseries_df`.
columns: Optional subset of columns (triangle indices) to plot;
defaults to all columns currently in the DataFrame.
ax: Existing :class:`matplotlib.axes.Axes` to draw into; a new
figure is created if omitted.
title: Plot title.
ylabel: Y-axis label (default ``"value"`` -- override per
coefficient, e.g. ``"Cp"`` / ``"Cf_x"``).
**plot_kwargs: Extra args passed to ``DataFrame.plot``.
Returns:
The matplotlib Axes the plot was drawn into.
"""
import matplotlib.pyplot as plt
if columns is not None:
df = df.loc[:, list(columns)]
if ax is None:
_, ax = plt.subplots()
df.plot(ax=ax, **plot_kwargs)
ax.set_xlabel("time_normalized")
ax.set_ylabel(ylabel)
if title is not None:
ax.set_title(title)
return ax