Convergence history

Convergence history recorded by the time-marching loop.

This module defines ConvergenceHistory, a 1D time series of flow monitors that ember.solver.Solver.run() fills as it marches. It holds one record per logged step, carrying the residuals, and the mass flow, stagnation enthalpy and entropy at each station. The residuals are whole-field block means; only the mass flow, enthalpy, and entropy are per-station quantities.

Throughout, record means one entry in the history, and row is reserved for a blade row of the machine.

A history is returned to you by ember.solver.Solver.run(); it is not something you build directly. ConvergenceHistory.from_grid(), ConvergenceHistory.record_convergence() exist for the solver to call, and are of no use outside it.

Reading a history

Run a simulation, then read the monitors as plain numpy arrays:

hist = ember.solver.Solver(n_step=1000).run(grid)

hist.i_step            # solver step index of each record
hist.residual[:, 4]    # energy residual, drhoe
hist.err_mdot          # mass flow conservation error
hist.zeta              # entropy rise, outlet minus inlet station

Every property returns a single-precision float32 array of length i_log + 1, one entry per record.

The residuals, enthalpy and entropy are stored non-dimensionally on the fluid reference scales as described in Reference scales.

Monitors

Whole-field, one value per record:

Through-flow stations. Each blade row contributes an upstream and a downstream face, ordered inlet to outlet as [row0_up, row0_dn, row1_up, row1_dn], so station 0 is the inlet and station -1 the outlet. All three are non-dimensional, hence the _nd suffix:

Throttle state, driven by ember.patch.OutletPatch.set_throttle() and reading zero on a run whose outlets all hold a plain prescribed pressure. Unlike the stations above, they are dimensional:

Bookkeeping:

Storage

ConvergenceHistory.from_grid() allocates n_log records up front and fills them with NaN, one per log step; the solver passes n_log = ceil(n_step / n_step_log) for n_step marched steps logged every n_step_log. At each log step, ember.solver.Solver.run() calls ConvergenceHistory.record_convergence() to fill one record. A march that runs to completion fills every record; one that diverges breaks from the loop early before logging an invalid value, sets ConvergenceHistory.diverged, and trims the history to drop the preallocated NaN for steps that were never reached. This means that we can always assume that history arrays are finite and valid for plotting or reduction, without having to mask out NaN values.

Reading from a file

A history is read back from disk with ConvergenceHistory.read_cnv(), which unpickles a history written by ConvergenceHistory.write_cnv().

class convergence_history.ConvergenceHistory(shape=(), n_row=1)[source]

Allocate the data array, sized for n_row blade rows.

Station ordering and reference scales are documented in the module docstring. The station columns (mdot_st<i>, ho_st<i>, s_st<i>) are sized to 2 * n_row each, one inlet/exit pair per row, rather than a fixed width – so the column-key list is built here, per instance, before delegating to the base initialiser, which reads it to size the backing array.

Parameters:
  • shape (tuple) – Shape of a single property array.

  • n_row (int, optional) – Number of blade rows this history tracks (default 1, i.e. 2 stations: inlet and exit).

classmethod from_grid(n_log, grid)[source]

Initialize an empty convergence history sized for a solver run.

Parameters:
  • n_log (int) – Number of records to allocate; one is filled per log step. The solver derives this as ceil(n_step / n_step_log) – floor division would under-allocate when n_step is not a multiple of n_step_log.

  • grid (Grid) – Grid object containing blocks with patches

Returns:

Configured instance ready to record data

Return type:

ConvergenceHistory

classmethod read_cnv(filename)[source]

Read convergence history from CNV binary format file.

Automatically detects gzip-compressed files.

Parameters:

filename (str) – Input CNV file to read

Return type:

ConvergenceHistory

check_convergence(decay=0.0, slope=0.0, cfl=1.0)[source]

True when every enabled convergence criterion is met.

Three independent signals reduce the history to a single verdict, and the result is their logical AND. Each criterion is disabled by passing its no-op threshold, so a bare check_convergence() checks divergence alone.

  • Divergence reads diverged only; it never touches the residual. A diverged march is never converged.

  • Decay and slope read the energy residual drhoe (column 4 of residual), the strictest conserved-variable residual and the one that lags in a stalled march, over the i_log + 1 written records.

Parameters:
  • decay (float, optional) – Required fall of the residual from its peak over the whole march, in decades: converged needs log10(r.max() / r[-1]) >= decay. The default 0 disables the check (0 decades of fall is always met).

  • slope (float, optional) – Maximum allowed magnitude of the residual slope, in decades of residual per unit pseudo-time, where pseudo-time is i_step * cfl. Fitted over the last 20% of records so it reflects the recent tail rather than the startup transient. Converged needs abs(d log10(r) / d(i_step * cfl)) <= slope. The default 0 disables the check.

  • cfl (float, optional) – CFL number used to march, scaling the pseudo-time step so the slope is comparable across runs with different step sizes. Only affects the slope criterion.

Return type:

bool

find_settling_record(tol=0.01)[source]

Record index at which the entropy rise zeta has settled.

Complements check_convergence(): where that reduces the residual history to a converged/not verdict, this locates when the solution output stopped moving. It returns the record index at which zeta has come within a fraction tol of its total change and stays there for the rest of the march.

The asymptote is estimated as the mean of zeta over the final fifth of records (the same last-20% window check_convergence() fits its slope over), and the band is tol of the total swing abs(zeta[0] - target) about it. The settling record is the one after the last record still outside that band – keying on the last exit rather than the first entry makes it robust to any overshoot that dips through the band and back out. Because the asymptote is the tail mean, the result is only physically meaningful once the march has actually levelled out (e.g. a run check_convergence() accepts).

The return is a record index into the history arrays, not a solver step number, so both the step it settled at and the wall-clock time to get there are one indexing away:

idx  = hist.find_settling_record()
step = int(hist.i_step[idx])                  # solver step
wall = float(hist.time[idx] - hist.time[0])   # ms to settle

(hist.time[0] is the one-iteration startup offset, not zero, so subtract it for elapsed march time.)

Parameters:

tol (float, optional) – Settling band half-width, as a fraction of the total zeta swing. Default 0.01 (1%).

Returns:

Record index of the settling point. Falls back to 0 when zeta is within the band from the start, or is flat (zero swing).

Return type:

int

format_message(n_step=None)[source]

Format convergence message for current log step.

Parameters:

n_step (int, optional) – Total steps in this march. When given, a timing line (tpnps, elapsed, estimated remaining) is inserted after the step header.

Returns:

Formatted convergence status message

Return type:

str

format_timing(i_step, n_step)[source]

Format timing line: tpnps, elapsed, and estimated remaining.

Parameters:
  • i_step (int) – Current step index within this march.

  • n_step (int) – Total steps in this march.

record_convergence(i_step, conv, time=None)[source]

Append one fully populated record, holding solver step i_step.

Advances i_log onto the next allocated record and writes every column of it: the step index, the time, and the monitors carried by conv. A record is never left half-written, so the only NaN a reader can meet is an untouched record past i_log.

Parameters:
  • i_step (int) – Index of the solver step being recorded.

  • conv (ember.grid.ConvergenceStep) – One step’s monitors, from ember.grid.Grid.get_convergence(). The mdot, ho and s station vectors are unpacked into one scalar column per station; the history must have been constructed with a matching n_row to have enough station columns allocated.

  • time (float, optional) – Elapsed time for this record, in seconds. Defaults to the wall-clock time since the history was created (the live-solver behaviour); a reader replaying a log passes the log’s own time instead.

to_json(directory='.')[source]

Write convergence history to three JSON files in directory.

Writes err_mdot.json, work.json, and convergence_loss.json, each containing a list of {“x”: i_step, “y”: value} objects.

Parameters:

directory (str or path-like, optional) – Output directory (default current directory).

trim()[source]

Copy of the records actually written, dropping the unfilled ones.

from_grid() allocates enough records for the requested step count and leaves them NaN until record_convergence() fills them, so a march that broke early on divergence leaves a NaN tail past i_log. The result holds i_log + 1 records, the only length at which i_log stays consistent with the number of records, and it can be plotted or reduced without masking the tail out first.

The copy is independent of the original, which is also what makes the result safe to keep once the full allocation is dropped. It has no spare records, so record_convergence() cannot be called on it: trim once the march is over.

Returns:

A new history containing only the logged steps.

Return type:

ConvergenceHistory

write_cnv(filename, compress=False)[source]

Write convergence history to CNV binary format file.

Parameters:
  • filename (str) – Output filename

  • compress (bool, optional) – If True, compress using gzip (default False)

property diverged

True if the run that produced this history blew up, scalar.

Set by ember.solver.Solver.run() when ember.grid.Grid.check_nan() raises ember.grid.DivergenceError, in which case the step loop broke early and only i_log + 1 records were written.

property dP_D

Derivative term of the throttle correction [Pa], record array.

The three terms dP_P, dP_I and dP_D sum to the total correction \(\Delta p_\mathrm{throttle}\) the outlet adds to its prescribed static pressure. This one is always zero: the throttle is a PI controller, and the column survives so the on-disk layout reads in both directions. See ember.patch.OutletPatch.set_throttle().

property dP_I

Integral term of the throttle correction [Pa], record array.

property dP_P

Proportional term of the throttle correction [Pa], record array.

property err_mdot

Mass flow conservation error \((\dot m_\mathrm{out} - \dot m_\mathrm{in}) / \bar{\dot m}\) [-], record array.

Taken between the first and last station, so it spans the whole machine. Zero for a perfectly converged march; the sign says whether the outlet passes more or less than the inlet.

property err_mdot_row

Mass flow conservation error per blade row [-], shape (n_log, n_row).

As err_mdot, but taken across each blade row separately: err[i, r] = (mdot_dn_r - mdot_up_r) / mdot_avg_r for record i and row r.

Returns an empty NaN array if the n_row metadata is absent, as it is for histories written before that key existed.

property ho_nd

Stagnation enthalpy \(h_0/u_\mathrm{ref}\) [-] at each station.

Record array of shape (n_log, 2*n_row); station 0 is the inlet and -1 the outlet. Carries an offset dependent on the arbitrary datum where \(u = s = 0\) at \((p_\mathrm{dtm}, T_\mathrm{dtm})\); only changes are physically meaningful, which is why psi is a difference between stations. See Datum state.

property i_log

Index of the last written record; -1 before any is written, scalar.

A trimmed history has i_log + 1 records, so i_log is also its last valid index.

property i_step

Solver step index \(i_\mathrm{step}\) [-], record array.

The step the march had reached when each record was written, so entries advance by n_step_log, not by one.

property mdot_nd

Mass flow \(\dot m\) [-] at each station, non-dimensional.

Record array of shape (n_log, 2*n_row); station 0 is the inlet and -1 the outlet. Scaled by the fluid mass-flux scale, and summed over the whole annulus. Not to be confused with mdot_target and mdot_throttle, which are dimensional [kg/s] and count one passage, so the two differ by the blade count as well as by the scaling.

property mdot_target

Throttle mass flow setpoint \(\dot m_\mathrm{target}\) [kg/s], record array.

Zero when no outlet is running a throttle.

property mdot_throttle

Mass flow measured at the throttled outlet \(\dot m\) [kg/s], record array.

property n_node

Node count of the grid that produced this history [-], scalar.

property now

View of the record at i_log, a single-record history.

During a march this is the record being written. Once ember.solver.Solver.run() has trimmed the history it is the last one.

property psi

Stagnation enthalpy rise \(\psi\) [-] across the machine, record array.

Outlet station minus inlet station. Both terms are already non-dimensional (scaled by u_ref), so this is the inlet-to-outlet stagnation enthalpy change on the fluid reference scale – no separate kinetic-energy normalisation.

property residual

Residual of each conserved variable [-], shape (n_log, 5).

Ordered (drho, drhoVx, drhoVr, drhorVt, drhoe). Each entry is a block mean of \(|\mathtt{residual\_nd}|\) for one conserved variable (see ember.grid.Grid.get_convergence()), so the values are non-negative by construction.

In a real march they are also strictly positive, and callers may plot them on a log axis without masking. A mean of non-negative floats is zero only if every cell residual is exactly zero, since the sum is never smaller than its largest term and so cannot underflow. Float32 round-off in the flux balance and boundary conditions keeps a converged residual at a floor many orders of magnitude above the smallest normal float, rather than descending to zero: a uniform inviscid duct marched to a standstill still reports ~2.6e-6.

The exception is a history rebuilt from an external solver log (e.g. by the ember-cfd-ts parser), which may recover only the density residual and leave the other four NaN.

property s_nd

Specific entropy \(s/R_\mathrm{ref}\) [-] at each station.

Record array of shape (n_log, 2*n_row); station 0 is the inlet and -1 the outlet. Carries the same arbitrary datum offset as ho_nd, so only changes between stations are meaningful. See Datum state.

property throttle

Throttle state (mdot_target, mdot_throttle, dP_throttle), shape (n_log, 3).

The first two are mass flows [kg/s], through one passage rather than the whole annulus; the third is the total pressure correction \(\Delta p_\mathrm{throttle}\) [Pa], the sum of dP_P, dP_I and dP_D.

property time

Elapsed wall-clock time since the march began [ms], record array.

property tpnps

Wall-clock time per node per step [\(\mu\mathrm{s}\)], scalar.

Measured over the interval between the last two records, so it is NaN until a second record exists.

property zeta

Entropy rise \(\zeta\) [-] across the machine, record array.

Outlet station minus inlet station. Both terms are already non-dimensional (scaled by Rgas_ref), so this is the inlet-to-outlet entropy generation on the fluid reference scale. It remains positive for an irreversible process (Gouy-Stodola), but is no longer normalised by a reference kinetic energy.