Cette page a été traduite automatiquement de l’anglais et peut contenir des erreurs. Original en anglais
Code source de axqua.solvers.openfoam.runtime
"""Driving the OpenFOAM tools: ``checkMesh``, ``decomposePar``, ``interFoam``.
Mirrors :class:`axqua.env.TelemacRuntime` and shares its
:class:`~axqua.env.ShellRuntime` base: axqua never imports OpenFOAM's
environment, it sources ``etc/bashrc`` in a subshell per command. That keeps
OpenFOAM's ``$PATH``, ``$LD_LIBRARY_PATH`` and MPI wrappers out of axqua's own
process, where they would collide with TELEMAC's.
The run is streamed, not captured: an interFoam run on a million cells is hours of
work, and a silent terminal gives no way to tell a healthy run from one whose time
step has collapsed. :class:`OpenFoamProgress` renders that as a bar of simulated
time with the live Courant number and time step beside it - the two numbers that say
whether the air phase is behaving.
"""
from __future__ import annotations
import logging
import re
import shlex
from pathlib import Path
from typing import Callable
from axqua.core import errors
from axqua.core.environment import SolverEnvironment
from axqua.env import ShellRuntime
from axqua.solvers.openfoam import dicts
# Layout and processor_count lived here, and are reachable here still
from axqua.solvers.openfoam.layout import (Layout, processor_count, # noqa: F401
set_aside_runs, time_folders)
log = logging.getLogger("axqua")
# "Time = 12.5" at the head of each time step
_TIME_RE = re.compile(r"^Time = ([0-9.eE+-]+)\s*$")
# "Courant Number mean: 0.012 max: 0.43"
_COURANT_RE = re.compile(r"Courant Number mean: ([0-9.eE+-]+) max: ([0-9.eE+-]+)")
_INTERFACE_RE = re.compile(r"Interface Courant Number mean: ([0-9.eE+-]+) "
r"max: ([0-9.eE+-]+)")
_DELTAT_RE = re.compile(r"^deltaT = ([0-9.eE+-]+)")
_CONTINUITY_RE = re.compile(r"time step continuity errors : .*cumulative = "
r"([0-9.eE+-]+)")
[docs]
class OpenFoamProgress:
"""Echo streamed OpenFOAM output with a simulated-time progress bar.
Shares :class:`axqua.progress.ProgressBar` with the TELEMAC renderer, so a
3D OpenFOAM run and a TELEMAC run look the same in the terminal. The extra
quantities shown - ``Co`` and ``dt`` - are the diagnostic that matters for this
solver: a healthy interFoam run holds ``dt`` roughly constant near the Courant
target, while a run whose air phase is misbehaving shows ``dt`` falling by
orders of magnitude with the simulated clock barely advancing.
"""
def __init__(self, end_time: float, *, start_time: float = 0.0, out=None,
sink=None, echo: bool = True):
from axqua.progress import ProgressBar
self.bar = ProgressBar(total=max(end_time - start_time, 0.0), out=out)
self.start_time = float(start_time)
self.end_time = float(end_time)
self.time = float(start_time)
self.courant = 0.0
self.delta_t = 0.0
self.continuity = 0.0
# The same parse feeds the terminal bar and a detached job's status.json, so
# there is exactly one place that knows what interFoam's output looks like.
self.sink = sink
self.echo = echo
[docs]
def feed(self, line: str) -> None:
advanced = False
m = _TIME_RE.match(line)
if m:
self.time = float(m.group(1))
advanced = True
m = _COURANT_RE.search(line)
if m:
self.courant = float(m.group(2))
m = _DELTAT_RE.match(line)
if m:
self.delta_t = float(m.group(1))
m = _CONTINUITY_RE.search(line)
if m:
self.continuity = float(m.group(1))
if self.echo:
self.bar.echo(line)
self.bar.update(
self.time - self.start_time,
f"t={self.time:.2f}s Co={self.courant:.2f} dt={self.delta_t:.3g}s")
if self.sink is not None:
try:
self.sink.line(line)
if advanced:
self.sink.progress(simulated_time=self.time,
duration=self.end_time or None,
courant=self.courant,
time_step=self.delta_t)
except Exception: # noqa: BLE001 - reporting never stops the solver
pass
[docs]
class OpenFoamRuntime(ShellRuntime):
"""Run OpenFOAM utilities and solvers for a case."""
def __init__(self, of_config):
if of_config.bashrc is None:
raise ValueError(
"openfoam.bashrc is not set. Point it at the etc/bashrc of your "
"OpenFOAM install (e.g. "
"/opt/openfoam9/etc/bashrc), or set AXQUA_OPENFOAM_BASHRC / "
"~/.config/axqua/solvers.yml - see axqua.core.machine."
)
super().__init__(environment=SolverEnvironment.from_config(
getattr(of_config, "environment", None), legacy_script=of_config.bashrc))
self.of = of_config
# ------------------------------------------------------------------ checks
[docs]
def check_available(self) -> str:
"""Return the OpenFOAM version string, raising if the environment is broken."""
proc = self.run('echo "$WM_PROJECT-$WM_PROJECT_VERSION"; '
'command -v ' + shlex.quote(self.of.solver), check=False)
if proc.returncode != 0 or not proc.stdout.strip():
raise RuntimeError(
f"Could not initialize the OpenFOAM environment via {self.env_script}."
f"\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
)
return proc.stdout.strip().splitlines()[0]
# checks whose failure genuinely stops a run, as opposed to the marginal-face
# reports that a mesh cut from real terrain will almost always carry
_FATAL_CHECKS = ("negative volume", "zero or negative", "open cells",
"not closed", "Cell to face addressing", "point usage",
"Upper triangular ordering", "incorrectly oriented")
[docs]
def check_mesh(self, case_dir: str | Path, *,
on_line: Callable[[str], None] | None = None
) -> tuple[bool, list[str], str]:
"""Run ``checkMesh``; return ``(usable, failed_checks, output)``.
``checkMesh`` exits 0 even when it fails checks, so the verdict is read from
its own report rather than from the exit status.
*usable* is **not** "passed every check". A mesh cut from a real DEM
routinely carries a few faces past a skewness or non-orthogonality limit
where the survey has a step or a seam - the isar reach has exactly one in
4.9 million, and it comes from the bathymetry, not from the meshing. Treating
that as a blocking failure would mean refusing to model the terrain. Only the
checks in :attr:`_FATAL_CHECKS` - a cell with no volume, an unclosed cell,
broken addressing - actually prevent a run.
"""
proc = self.run("checkMesh -constant", cwd=case_dir, check=False,
on_line=on_line)
out = proc.stdout
failed = [ln.strip() for ln in out.splitlines() if "***" in ln]
fatal = [ln for ln in failed
if any(key.lower() in ln.lower() for key in self._FATAL_CHECKS)]
if "Mesh OK" in out:
log.info("checkMesh: Mesh OK")
elif fatal:
log.error("checkMesh found problems that prevent a run:\n %s",
"\n ".join(fatal))
else:
log.warning("checkMesh flagged marginal faces (the mesh is still "
"runnable):\n %s", "\n ".join(failed))
return not fatal, failed, out
# ------------------------------------------------------------- decomposition
[docs]
def decompose(self, case_dir: str | Path, *, force: bool = True,
n_processors: int | None = None):
"""``decomposePar`` for *n_processors* ranks (the case's own count when not given). Where the case's ``system/decomposeParDict`` names another count, a dictionary of the run's own is used (:func:`dicts.decomposition_for`), so the case's file is never changed.
Where the processor folders hold results that the case itself does not (a stage run without reconstructing), nothing is split: ``decomposePar -force`` would delete them, and whether they are continued or replaced is for the next stage to decide (:meth:`run_stage`). Returns None then.
"""
layout = Layout.of(case_dir)
if layout.only_in_processor_folders:
log.info("the processor folders hold results up to t = %g s that the case itself does not; not decomposing again", layout.parallel)
return None
n = n_processors if n_processors is not None else self.of.n_processors
return self._decompose(case_dir, n, force=force)
def _decompose(self, case_dir: str | Path, n: int | None, *, force: bool = True):
flag = " -force" if force else ""
dictionary = dicts.decomposition_for(case_dir, n) if n and n > 1 else None
return self.run(f"decomposePar{flag}{_dictionary_option(case_dir, dictionary)}",
cwd=case_dir, check=True)
def _prepare(self, case_dir: Path, n: int, layout: "Layout") -> None:
"""Make the case ready for the stage whose controlDict is active, on *n* ranks. ``decomposePar`` carries over the one top-level time the active controlDict starts from: the start for a stage that starts afresh, the latest time for one that continues.
A stage that starts afresh moves what earlier runs left into ``previous-run/`` (:func:`~axqua.solvers.openfoam.layout.set_aside_runs`), because a later time at the top level or in the processor folders is where the next stage would continue from; on several ranks the case is then split anew. A stage that continues runs on results that only the processor folders hold, and stops where they were split for other ranks or where it runs on one processor, which does not read them. Where the top level holds the latest time (after ``reconstructPar``), the case is split again from it.
"""
if not layout.continues:
set_aside_runs(case_dir)
if n > 1:
log.info("splitting the case for %d ranks, from the start time", n)
self._decompose(case_dir, n)
return
if layout.only_in_processor_folders:
if n <= 1:
raise errors.SolverError(
f"the processor folders hold the result of an earlier stage at t = {layout.parallel:g} s, which a run on one processor does not read; it would start from t = {layout.top or 0:g} s",
subject=str(case_dir),
remedy=f"Run this stage on {layout.count} ranks, or put the result together with reconstructPar first.")
if layout.count != n:
raise errors.SolverError(
f"the case is split into {layout.count} domains and its processor folders hold the result of an earlier stage at t = {layout.parallel:g} s that the case itself does not; splitting it for {n} ranks would delete it",
subject=str(case_dir),
remedy=f"Run this stage on {layout.count} ranks, or put the result together with reconstructPar first.")
return # continued where the earlier stage ended
if n > 1 and (layout.count != n or (layout.top or 0) > (layout.parallel or 0)):
log.info("splitting the case for %d ranks, from its latest time (t = %g s)", n, layout.top or 0)
self._decompose(case_dir, n)
[docs]
def reconstruct(self, case_dir: str | Path, *, latest: bool = False):
flag = " -latestTime" if latest else ""
return self.run(f"reconstructPar{flag}", cwd=case_dir, check=False)
# -------------------------------------------------------------------- solve
[docs]
def to_vtk(self, case_dir: str | Path, *, latest: bool = False,
fields: list[str] | None = None):
"""Convert the reconstructed case to VTK for QGIS / ParaView.
Part of the **runner's** lifecycle, not the plugin's (plan §13): converting a
multi-gigabyte case is exactly the heavy postprocessing QGIS must never do, and
it belongs on the machine that ran the solver.
"""
flags = " -latestTime" if latest else ""
if fields:
flags += " -fields '(" + " ".join(fields) + ")'"
return self.run(f"foamToVTK{flags}", cwd=case_dir, check=False)
[docs]
def run_stage(self, case_dir: str | Path, stage: str, *, end_time: float,
start_time: float = 0.0, n_processors: int | None = None,
cfg=None, show_progress: bool = True,
sink=None, should_stop=None):
"""Activate *stage*'s dictionaries and run the solver over them.
Pass *cfg* so the dictionaries are regenerated from the configuration this
run was launched with; without it the build-time dictionaries are used and a
changed ``end_time`` would be reported but not honored (see
:func:`axqua.solvers.openfoam.dicts.activate`).
Returns the ``CompletedProcess``; a non-zero exit is returned rather than
raised, so a caller running both stages can report which one failed.
The case is made ready for the stage first (:meth:`_prepare`): a stage that starts afresh moves what earlier runs left into ``previous-run/`` and is split anew for its ranks, a stage that continues runs on what the stage before it left, also where only the processor folders hold it. A parallel run gets the decomposition dictionary of its ranks (a parallel solver reads it as well and refuses one that names more domains than it has ranks).
"""
case_dir = Path(case_dir)
dicts.activate(case_dir, stage, cfg)
n = int((n_processors if n_processors is not None else self.of.n_processors) or 1)
self._prepare(case_dir, n, Layout.of(case_dir))
if n > 1:
decomposition = dicts.decomposition_for(case_dir, n)
# Through the environment, not a hard-coded "mpirun": MS-MPI on Windows
# provides `mpiexec`, and a profile may point at `srun` on a cluster. The
# launcher is a configured field, not a constant (plan §13).
command = self.environment.mpi_command(
f"{self.of.solver} -parallel{_dictionary_option(case_dir, decomposition)}", n)
else:
command = self.of.solver
progress = (OpenFoamProgress(end_time, start_time=start_time,
sink=sink, echo=show_progress)
if (show_progress or sink is not None) else None)
on_line = progress.feed if progress else print
log.info("running the %s stage: %s (to t = %g s)", stage, command, end_time)
try:
return self.run(command, cwd=case_dir, check=False, on_line=on_line,
should_stop=should_stop)
finally:
if progress is not None:
progress.close()
[docs]
def latest_time(self, case_dir: str | Path) -> float:
"""Largest numeric time directory in the case (0 when only ``0/`` exists)."""
times = time_folders(case_dir)
return times[-1][0] if times else 0.0
def _dictionary_option(case_dir: str | Path, dictionary: str | Path | None) -> str:
"""`` -decomposeParDict <file>`` relative to the case, or nothing."""
if dictionary is None:
return ""
path = Path(dictionary)
try:
path = path.relative_to(Path(case_dir))
except ValueError:
pass
return f" -decomposeParDict {shlex.quote(path.as_posix())}"