rizer.cantera_ext
C++ Cantera 1-D plasma extension (custom Domain1D models and solvers)
Loading...
Searching...
No Matches
HydroStep.cpp
Go to the documentation of this file.
2
3#include "numeric/EulerFlux.h"
4#include "numeric/Format.h"
5
6#include <algorithm>
7#include <cmath>
8#include <cstddef>
9#include <limits>
10#include <stdexcept>
11#include <string>
12#include <vector>
13
14namespace rizer
15{
16
17 namespace
18 {
19
20 // -------------------------------------------------------------------------- //
21 // File-local helpers //
22 // -------------------------------------------------------------------------- //
23
27 struct Workspace
28 {
29 std::size_t n_cells = 0; // N + 2
30 std::size_t n_species = 0;
31 std::vector<double> rho, u_r, Tg, Te, Y;
32 std::vector<double> p_heavy, p_electron, p_total, sound_speed;
33 std::vector<double> U_rho, U_mom, U_Y, U_total_energy, U_electron_energy;
34
35 void resize(std::size_t cells, std::size_t species)
36 {
37 n_cells = cells;
38 n_species = species;
39 rho.assign(cells, 0.0);
40 u_r.assign(cells, 0.0);
41 Tg.assign(cells, 0.0);
42 Te.assign(cells, 0.0);
43 Y.assign(cells * species, 0.0);
44 p_heavy.assign(cells, 0.0);
45 p_electron.assign(cells, 0.0);
46 p_total.assign(cells, 0.0);
47 sound_speed.assign(cells, 0.0);
48 U_rho.assign(cells, 0.0);
49 U_mom.assign(cells, 0.0);
50 U_Y.assign(cells * species, 0.0);
51 U_total_energy.assign(cells, 0.0);
52 U_electron_energy.assign(cells, 0.0);
53 }
54 };
55
56 void requireSize(const std::vector<double> &v, std::size_t expected,
57 const char *name)
58 {
59 if (v.size() != expected)
60 {
61 throw std::invalid_argument(
62 std::string("hydroStep: ") + name + " has length " +
63 std::to_string(v.size()) + ", expected " +
64 std::to_string(expected) + ".");
65 }
66 }
67
75 void requireFinite(const std::vector<double> &v, const char *name)
76 {
77 for (std::size_t i = 0; i < v.size(); i++)
78 {
79 if (!std::isfinite(v[i]))
80 {
81 throw std::invalid_argument(
82 std::string("hydroStep: ") + name + "[" + std::to_string(i) +
83 "] is " + std::to_string(v[i]) + ", which is not finite.");
84 }
85 }
86 }
87
92 void requireFinite(double value, const char *name)
93 {
94 if (!std::isfinite(value))
95 {
96 throw std::invalid_argument(
97 std::string("hydroStep: ") + name + " is " +
98 std::to_string(value) + ", which is not finite.");
99 }
100 }
101
115 void requireUnitSum(const std::vector<double> &Y, const char *name)
116 {
117 double sum = 0.0;
118 for (const double Y_k : Y)
119 sum += Y_k;
120 // Loose enough to pass a normalized vector summed in any order, tight
121 // enough that nothing a caller would consider "close" slips through.
122 constexpr double tolerance = 1.0e-10;
123 if (!(std::abs(sum - 1.0) <= tolerance))
124 {
125 throw std::invalid_argument(
126 std::string("hydroStep: ") + name +
127 " sums to " + std::to_string(sum) +
128 ", which is not 1 to within 1e-10.");
129 }
130 }
131
132 } // namespace
133
134 // ------------------------------------------------------------------------------ //
135 // Free functions //
136 // ------------------------------------------------------------------------------ //
137
139 {
140 // Four steps, the third sub-cycled:
141 // 1. validate every input, before touching the state
142 // 2. widen the interior into ghost-extended working arrays
143 // 3. sub-cycle to the CFL limit, each pass being the seven stages
144 // banner-marked below (ghosts, conservative, step size, boundary
145 // regime, faces, forward Euler, back to primitive)
146 // 4. narrow the working arrays back onto the caller's state
147
148 // ---- 1. validate the inputs -----------------------------------------
149 // Check the inputs before doing any work, so a caller cannot get a half-done
150 // state and have to guess which of the many possible preconditions failed.
151 const std::size_t n_points = state.rho.size();
152 const std::size_t n_species = state.n_species;
153 if (n_points < 2)
154 {
155 throw std::invalid_argument(
156 "hydroStep: needs at least 2 radial nodes, got " +
157 std::to_string(n_points) +
158 "; a single node has no face to carry a flux, and no node "
159 "spacing to place the ghost cells one beyond each end.");
160 }
161 if (n_species == 0)
162 {
163 throw std::invalid_argument("hydroStep: the state has no species.");
164 }
165 requireSize(state.u_r, n_points, "state.u_r");
166 requireSize(state.Tg, n_points, "state.Tg");
167 requireSize(state.Te, n_points, "state.Te");
168 requireSize(state.Y, n_points * n_species, "state.Y");
169 requireSize(cfg.r_node, n_points, "cfg.r_node");
170 requireSize(cfg.r_face, n_points + 1, "cfg.r_face");
171 requireSize(cfg.V_cell, n_points, "cfg.V_cell");
172 requireFinite(state.rho, "state.rho");
173 requireFinite(state.u_r, "state.u_r");
174 requireFinite(state.Tg, "state.Tg");
175 requireFinite(state.Te, "state.Te");
176 requireFinite(state.Y, "state.Y");
177 requireFinite(cfg.r_node, "cfg.r_node");
178 requireFinite(cfg.r_face, "cfg.r_face");
179 requireFinite(cfg.V_cell, "cfg.V_cell");
180 requireFinite(cfg.Y_amb, "cfg.Y_amb");
181 requireFinite(cfg.p_amb, "cfg.p_amb");
182 requireFinite(cfg.T_amb, "cfg.T_amb");
183 requireFinite(cfg.t0, "cfg.t0");
184 requireFinite(cfg.dt_total, "cfg.dt_total");
185 requireFinite(cfg.cfl, "cfg.cfl");
186 if (cfg.thermo == nullptr)
187 {
188 throw std::invalid_argument("hydroStep: cfg.thermo is null.");
189 }
190 if (cfg.dt_total < 0.0)
191 {
192 throw std::invalid_argument("hydroStep: dt_total must be >= 0.");
193 }
194 if (!(cfg.cfl > 0.0 && cfg.cfl <= 1.0))
195 {
196 throw std::invalid_argument(
197 "hydroStep: cfl must be in (0, 1], got " + std::to_string(cfg.cfl));
198 }
199 if (cfg.electron_index < -1 || cfg.electron_index >= static_cast<int>(n_species))
200 {
201 throw std::invalid_argument(
202 "hydroStep: electron_index must be -1 or a valid species index.");
203 }
204 for (std::size_t i = 0; i < n_points; i++)
205 {
206 if (!(cfg.V_cell[i] > 0.0))
207 {
208 throw std::invalid_argument(
209 "hydroStep: V_cell[" + std::to_string(i) + "] must be > 0.");
210 }
211 if (cfg.r_face[i + 1] < cfg.r_face[i])
212 {
213 throw std::invalid_argument(
214 "hydroStep: r_face must be non-decreasing, but r_face[" +
215 std::to_string(i + 1) + "] < r_face[" + std::to_string(i) +
216 "]. Equal face radii are the planar entry point, where every "
217 "face carries unit area.");
218 }
219 if (!(cfg.r_face[i] >= 0.0))
220 {
221 throw std::invalid_argument(
222 "hydroStep: r_face[" + std::to_string(i) + "] must be >= 0.");
223 }
224 }
225 const bool inner_face_has_area = cfg.r_face[0] > 0.0;
226 if (inner_face_has_area && !cfg.ghost_state)
227 {
228 throw std::invalid_argument(
229 "hydroStep: r_face[0] > 0, so the inner face has real area and needs "
230 "a boundary state, but no ghost_state callback was given. "
231 "outer_bc_mode governs the outer face only.");
232 }
233 const bool needs_ambient = !cfg.ghost_state;
234 if (needs_ambient)
235 {
236 // Both outer modes close the ghost density from p_amb, so both need it
237 // and T_amb; AmbientGhost needs the composition as well.
238 if (!(cfg.p_amb > 0.0) || !(cfg.T_amb > 0.0))
239 {
240 throw std::invalid_argument(
241 "hydroStep: outer_bc_mode needs p_amb > 0 and T_amb > 0, got "
242 "p_amb = " +
243 std::to_string(cfg.p_amb) + " Pa, T_amb = " +
244 std::to_string(cfg.T_amb) +
245 " K. A zero closes the ghost "
246 "density at zero, which is a vacuum wall rather than an error.");
247 }
249 {
250 requireSize(cfg.Y_amb, n_species, "cfg.Y_amb");
251 requireUnitSum(cfg.Y_amb, "cfg.Y_amb");
252 }
253 }
254
255 const bool has_electron = cfg.electron_index >= 0;
256 const std::size_t electron =
257 has_electron ? static_cast<std::size_t>(cfg.electron_index) : 0;
258 const double cv_electron = has_electron ? cfg.thermo->electronCv() : 0.0;
259
260 // Two different length scales, and they are not interchangeable.
261 //
262 // `face_span` is r_{i+1/2} - r_{i-1/2}, which is what appendix A.1's
263 // geometric-source integral needs: A.3's well-balancedness cancels it
264 // against the flux difference p*(r_east - r_west) EXACTLY, so it has to be
265 // the same subtraction, not an algebraically equal rearrangement.
266 //
267 // `cell_width` is the physical extent the CFL bound is measured over,
268 // V_i divided by the mean face area. In cylindrical mode that reduces to
269 // the same r_{i+1/2} - r_{i-1/2}; in the planar entry point, where every
270 // face radius is 1 and the face span is zero, it correctly returns V_i,
271 // which is the cell width there. Using the face span for the CFL would
272 // hand the planar mode a zero step.
273 std::vector<double> face_span(n_points, 0.0);
274 std::vector<double> cell_width(n_points, 0.0);
275 for (std::size_t i = 0; i < n_points; i++)
276 {
277 face_span[i] = cfg.r_face[i + 1] - cfg.r_face[i];
278 const double mean_face_area =
279 0.5 * (cfg.r_face[i] + cfg.r_face[i + 1]);
280 if (!(mean_face_area > 0.0))
281 {
282 throw std::invalid_argument(
283 "hydroStep: cell " + std::to_string(i) +
284 " has zero mean face area, so no CFL length can be derived.");
285 }
286 cell_width[i] = cfg.V_cell[i] / mean_face_area;
287 }
288
289 // ---- 2. widen into the ghost-extended arrays ------------------------
290 // Create the working arrays, which hold the interior cells plus one ghost on
291 // each side. The ghosts are in the same arrays so the face loop below has no
292 // special case at either end.
293 Workspace work;
294 work.resize(n_points + 2, n_species);
295 const std::size_t n_faces = n_points + 1;
296 std::vector<double> flux_rho(n_faces, 0.0);
297 std::vector<double> flux_mom(n_faces, 0.0);
298 std::vector<double> flux_Y(n_faces * n_species, 0.0);
299 std::vector<double> flux_total_energy(n_faces, 0.0);
300 std::vector<double> flux_electron_energy(n_faces, 0.0);
301 std::vector<double> velocity_flux(n_faces, 0.0);
302
303 // Initialize the result.
304 HydroStepResult result;
305 result.boundary_species_mass_out.assign(n_species, 0.0);
306 result.rho_min = std::numeric_limits<double>::infinity();
307 result.p_min = std::numeric_limits<double>::infinity();
308
309 // Copy the interior state into the working arrays, leaving the ghosts to be
310 // filled in below. The caller's state is the interior only, so the ghosts
311 // are not copied back to it at the end.
312 for (std::size_t i = 0; i < n_points; i++)
313 {
314 const std::size_t c = i + 1;
315 work.rho[c] = state.rho[i];
316 work.u_r[c] = state.u_r[i];
317 work.Tg[c] = state.Tg[i];
318 work.Te[c] = state.Te[i];
319 for (std::size_t k = 0; k < n_species; k++)
320 {
321 work.Y[c * n_species + k] = state.Y[i * n_species + k];
322 }
323 }
324
325 // Radius of each extended cell, handed to the thermo accessor and to the
326 // ghost-state callback: the NODE radius inside (not the cell midpoint,
327 // which differs from it on a stretched grid and at the axis cell), and
328 // for a ghost the adjacent interior node reflected across the boundary.
329 //
330 // A ghost given the FACE radius instead would carry its state -- and
331 // under a prescribed equation of state its pressure -- half a cell
332 // inside where the ghost cell is, which is an O(1) error in the boundary
333 // cell's momentum divergence that refinement does not remove.
334 //
335 // The reflection is built from the node array rather than as
336 // 2*r_face - r_node: in the planar entry point of NUMERICS.md section
337 // 11.5 every face radius is 1, an area factor and not a position at all.
338 // On a cylindrical grid the two agree, because r_face is mirrored half a
339 // spacing beyond the end node.
340 const double inner_spacing = cfg.r_node[1] - cfg.r_node[0];
341 const double outer_spacing =
342 cfg.r_node[n_points - 1] - cfg.r_node[n_points - 2];
343 // The axis ghost mirrors the axis CELL onto itself: that cell's node sits
344 // on the axis, so reflecting it across r = 0 leaves it there. Only a face
345 // with real area has a ghost a full spacing beyond it.
346 const double r_ghost_inner =
347 inner_face_has_area ? cfg.r_node[0] - inner_spacing : cfg.r_node[0];
348 const double r_ghost_outer = cfg.r_node[n_points - 1] + outer_spacing;
349
350 // An annulus whose inner radius is smaller than its node spacing
351 // reflects its first node past the axis. r_face is checked for sign
352 // above, but this radius is derived from r_node, which is not, and it
353 // is handed to the ghost_state callback and to the thermo accessor as
354 // the POSITION to evaluate a prescribed field at. A field going as
355 // 1/r, log r or sqrt(r) returns a sign-flipped or non-finite state
356 // there and the step carries on, so the condition is refused here
357 // rather than left to surface as a wrong boundary cell.
358 //
359 // Cylindrical geometry only. In the planar entry point of NUMERICS.md
360 // section "Full system, planar - Sod shock tube" the coordinate is a position on a line and r_face is an
361 // area factor of 1 everywhere, so the inner face always "has area",
362 // the first node sits at x = 0, and a ghost at x = -dx is exactly
363 // where it belongs rather than an error.
364 if (cfg.cylindrical_source && inner_face_has_area && r_ghost_inner < 0.0)
365 {
366 throw std::invalid_argument(
367 "hydroStep: the inner ghost cell lands at r = " +
368 std::to_string(r_ghost_inner) +
369 " m, past the axis. The annulus inner radius (" +
370 std::to_string(cfg.r_node[0]) +
371 " m) is smaller than its first node spacing (" +
372 std::to_string(inner_spacing) +
373 " m), so there is no radius a ghost one spacing inside the "
374 "first node could occupy; use more nodes or a larger r_min.");
375 }
376
377 auto cellRadius = [&](std::size_t c)
378 {
379 if (c == 0)
380 return r_ghost_inner;
381 if (c == n_points + 1)
382 return r_ghost_outer;
383 return cfg.r_node[c - 1];
384 };
385
386 // No sum check here: this writes both CALLER-supplied ghosts and ghosts
387 // the sub-step derives from its own interior cells. The sum belongs on
388 // the first kind only -- see requireUnitSum's own comment, and the
389 // explicit calls at the ghost_state call sites below. An interior-derived
390 // ghost carries exactly what the interior carries, including the
391 // round-off the scheme accumulates in sum_s Y_s over many sub-cycles, and
392 // the interior itself is never held to an input tolerance. Checking the
393 // copy but not the original turns the solver's own round-off into a hard
394 // abort on fine grids, where the smaller CFL step means more sub-cycles
395 // to accumulate it.
396 auto writeGhost = [&](std::size_t c, const NodeState &ghost)
397 {
398 if (ghost.Y.size() != n_species)
399 {
400 throw std::invalid_argument(
401 "hydroStep: a ghost state carries " +
402 std::to_string(ghost.Y.size()) +
403 " mass fractions, expected " + std::to_string(n_species) + ".");
404 }
405 work.rho[c] = ghost.rho;
406 work.u_r[c] = ghost.u_r;
407 work.Tg[c] = ghost.Tg;
408 work.Te[c] = ghost.Te;
409 for (std::size_t k = 0; k < n_species; k++)
410 {
411 work.Y[c * n_species + k] = ghost.Y[k];
412 }
413 };
414
415 auto cellAsNodeState = [&](std::size_t c)
416 {
417 NodeState node;
418 node.rho = work.rho[c];
419 node.u_r = work.u_r[c];
420 node.Tg = work.Tg[c];
421 node.Te = work.Te[c];
422 const auto begin = work.Y.begin() + static_cast<std::ptrdiff_t>(c * n_species);
423 node.Y.assign(begin, begin + static_cast<std::ptrdiff_t>(n_species));
424 return node;
425 };
426
427 // ---- 3. sub-cycle to the CFL limit ----------------------------------
428 // The caller's time is the start of the sub-step, and the total interval is
429 // the Strang half-step. The sub-cycles below advance t in place, so
430 // time_left is the remaining interval to reach the end of the sub-step.
431 // The ambient ghost: the far field really is this state, so imposing all
432 // of it is accurate rather than over-specified, and it anchors the domain
433 // to ambient as the flow settles. It is the same state on every
434 // sub-cycle, so it is built once.
435 NodeState ambient_ghost;
437 {
438 ambient_ghost.u_r = 0.0;
439 ambient_ghost.Tg = cfg.T_amb;
440 ambient_ghost.Te = cfg.T_amb;
441 ambient_ghost.Y = cfg.Y_amb;
442 ambient_ghost.rho = cfg.thermo->densityFromPressure(
443 cfg.p_amb, cfg.T_amb, cfg.T_amb, ambient_ghost.Y.data());
444 }
445
446 double t = cfg.t0;
447 double time_left = cfg.dt_total;
448 const std::size_t outer = n_points + 1;
449
450 // One pass per sub-cycle, plus a pass that stops after the step-size
451 // stage when dt_total is zero: that probe then reports the dt_cfl the
452 // first sub-cycle would apply, from the same ghosts and face speeds,
453 // rather than from a second copy of the bound.
454 for (;;)
455 {
456 // ---- boundary ghosts -------------------------------------------
457 if (cfg.ghost_state)
458 {
459 // Caller-supplied, so the sum is held to the input tolerance here.
460 auto checkGhost = [&](const NodeState &ghost)
461 {
462 requireSize(ghost.Y, n_species, "a ghost_state callback's Y");
463 requireUnitSum(ghost.Y, "a ghost_state callback's Y");
464 // The sum above already rejects a NaN in Y, since a NaN sum
465 // equals nothing. The other four slots have no such check and
466 // are the one place arbitrary caller code reaches the inner
467 // loop, so they get an explicit one.
468 requireFinite(ghost.rho, "a ghost_state callback's rho");
469 requireFinite(ghost.u_r, "a ghost_state callback's u_r");
470 requireFinite(ghost.Tg, "a ghost_state callback's Tg");
471 requireFinite(ghost.Te, "a ghost_state callback's Te");
472 };
473 if (inner_face_has_area)
474 {
475 // For the inner face, the caller's callback is the only source of a
476 // ghost state. Only used for a uniform grid with r_face[0] > 0,
477 // which is a planar entry point with a real boundary, so the caller
478 // has to supply a state. The sub-step does not know what the
479 // physics is there, so it cannot derive a ghost from the interior.
480 const NodeState inner =
481 cfg.ghost_state(r_ghost_inner, t, true, cellAsNodeState(1));
482 checkGhost(inner);
483 writeGhost(0, inner);
484 }
485 const NodeState outer_ghost = cfg.ghost_state(
486 r_ghost_outer, t, false, cellAsNodeState(n_points));
487 checkGhost(outer_ghost);
488 writeGhost(outer, outer_ghost);
489 }
490 else if (cfg.outer_bc_mode == OuterBc::AmbientGhost)
491 {
492 writeGhost(outer, ambient_ghost);
493 }
494 else
495 {
496 // PressureOutlet: exactly ONE quantity imposed, p = p_amb;
497 // u_r, Tg, Te and Y_s extrapolated with zero gradient from the
498 // adjacent interior cell; rho closed from the equation of state at
499 // that p_amb. Extrapolating rho as well would already fix p at the
500 // face and make p = p_amb one condition too many. Getting that
501 // count right is still not enough to stop reflections: pinning p
502 // is a pressure-release surface, so this mode reflects far more
503 // than AmbientGhost does (NUMERICS.md section "Outgoing acoustic pulse, the boundary test", case 21).
504 NodeState ghost = cellAsNodeState(n_points);
505 ghost.rho = cfg.thermo->densityFromPressure(
506 cfg.p_amb, ghost.Tg, ghost.Te, ghost.Y.data());
507 writeGhost(outer, ghost);
508 }
509 if (!inner_face_has_area)
510 {
511 // The axis face has zero area, so nothing is ever read through it.
512 // The mirror only keeps the flux expression finite.
513 NodeState axis = cellAsNodeState(1);
514 axis.u_r = -axis.u_r;
515 writeGhost(0, axis);
516 }
517
518 // ---- primitive -> conservative, plus the face-blend inputs -------
519 for (std::size_t c = 0; c < work.n_cells; c++)
520 {
521 const double *Y_c = &work.Y[c * n_species];
522 const double r_c = cellRadius(c);
523 // One call for all four state functions, so an accessor with a
524 // costly state-setting (CanteraThermo) sets it once per cell.
525 const StateProperties properties = cfg.thermo->stateProperties(
526 r_c, t, work.rho[c], work.Tg[c], work.Te[c], Y_c);
527 work.p_heavy[c] = properties.p_heavy;
528 work.p_electron[c] = properties.p_electron;
529 work.p_total[c] = work.p_heavy[c] + work.p_electron[c];
530 // Each later pass starts from the state the previous sub-cycle
531 // produced, so its interior pressures are that sub-cycle's
532 // minimum candidates; the final state is scanned after the loop.
533 if (result.n_subcycles_used > 0 && c >= 1 && c <= n_points)
534 {
535 result.p_min = std::min(result.p_min, work.p_total[c]);
536 }
537 work.sound_speed[c] = properties.sound_speed;
538
539 // Construct U=[ρ, ρu, ρY, ρE, ρe_e] from the primitive state.
540 work.U_rho[c] = work.rho[c];
541 work.U_mom[c] = work.rho[c] * work.u_r[c];
542 for (std::size_t k = 0; k < n_species; k++)
543 {
544 work.U_Y[c * n_species + k] = work.rho[c] * Y_c[k];
545 }
546 // One conservative TOTAL energy row and one sourced ELECTRON
547 // internal-energy row; the heavy pool has no row of its own and
548 // is recovered below as total minus kinetic minus electron, which
549 // hands it -p_H div(u) exactly (NUMERICS.md section 5.4).
550 // Splitting the shared kinetic term BETWEEN two conservative pool
551 // rows instead leaves each one a spurious
552 // Y_pool u.grad(p) - u.grad(p_pool); no weight removes it, because
553 // the one that would is grad(p_pool)/grad(p) and varies in space.
554 const double Y_electron = has_electron ? Y_c[electron] : 0.0;
555 const double rho_electron = work.rho[c] * Y_electron;
556 work.U_electron_energy[c] = rho_electron * cv_electron * work.Te[c];
557 work.U_total_energy[c] =
558 work.rho[c] * properties.heavy_energy +
559 work.U_electron_energy[c] +
560 0.5 * work.rho[c] * work.u_r[c] * work.u_r[c];
561 }
562
563 // ---- step size ---------------------------------------------------
564 // Section 3.3 requires the local CFL to be computed every step, so the
565 // bound is re-derived here rather than fixed once from the initial
566 // state: a rarefaction accelerates the flow within a single half-step.
567 //
568 // The speed that bounds a cell is the one its two FACES apply, which
569 // is maxWaveSpeed over the cell and each neighbour -- the ghosts
570 // included, since the boundary faces blend against them. Scanning the
571 // interior cells alone would derive the bound from a speed smaller
572 // than the dissipation actually uses at those faces whenever a ghost
573 // is faster than every interior cell, which an ambient hotter than the
574 // channel or a ghost_state callback imposing an inflow both produce.
575 double dt_cfl = std::numeric_limits<double>::infinity();
576 for (std::size_t i = 0; i < n_points; i++)
577 {
578 const std::size_t c = i + 1;
579 const double speed = std::max(
580 numeric::maxWaveSpeed(work.u_r[c - 1], work.sound_speed[c - 1],
581 work.u_r[c], work.sound_speed[c]),
582 numeric::maxWaveSpeed(work.u_r[c], work.sound_speed[c],
583 work.u_r[c + 1], work.sound_speed[c + 1]));
584 if (speed > 0.0)
585 {
586 dt_cfl = std::min(dt_cfl, cell_width[i] / speed);
587 }
588 }
589 if (result.n_subcycles_used == 0)
590 {
591 result.dt_cfl = dt_cfl;
592 }
593 if (!(cfg.dt_total > 0.0))
594 {
595 // Zero-length probe: nothing to advance, but the caller still
596 // gets the positivity minima of the state it handed in.
597 for (std::size_t c = 1; c <= n_points; c++)
598 {
599 result.rho_min = std::min(result.rho_min, work.rho[c]);
600 result.p_min = std::min(result.p_min, work.p_total[c]);
601 }
602 break;
603 }
604
605 double dt = 0.0;
606 if (cfg.n_subcycles > 0)
607 {
608 // Equal fixed sub-steps, a test-only control for the cases where a
609 // known step size matters more than the stability margin.
610 dt = cfg.dt_total / static_cast<double>(cfg.n_subcycles);
611 if (cfg.refuse_above_cfl && dt > dt_cfl)
612 {
613 throw std::invalid_argument(
614 "hydroStep: the pinned sub-step " + significant(dt) +
615 " s exceeds the CFL bound " + significant(dt_cfl) +
616 " s at this state, where the explicit update is unstable. "
617 "Pin a smaller hydro_substep.");
618 }
619 }
620 else
621 {
622 if (!std::isfinite(dt_cfl))
623 {
624 throw std::invalid_argument(
625 "hydroStep: every wave speed is zero, so no CFL step can be "
626 "derived; pass n_subcycles explicitly.");
627 }
628 dt = cfg.cfl * dt_cfl;
629 }
630 if (dt > time_left)
631 dt = time_left;
632 if (!(dt > 0.0))
633 {
634 throw std::invalid_argument(
635 "hydroStep: the derived sub-step is not positive.");
636 }
637
638 // ---- the boundary regime the characteristic condition assumes -----
640 {
641 const double u_wall = work.u_r[n_points];
642 const double c_wall = work.sound_speed[n_points];
643 // The guard is for a RESOLVED regime change, so the inflow half is
644 // thresholded on a Mach number rather than on the sign. A boundary
645 // cell at rest produces a u_r of order 1e-16 m/s from round-off
646 // alone, and the wake behind an outgoing acoustic wave leaves a few
647 // times 1e-5 of the sound speed pointing inward; neither is the
648 // subsonic-inflow regime, which needs almost the whole state
649 // supplied rather than a single imposed pressure. Both supersonic
650 // regimes are rejected outright, with no tolerance.
651 constexpr double inflow_mach_threshold = 1.0e-3;
652 if (u_wall < -inflow_mach_threshold * c_wall || u_wall >= c_wall)
653 {
654 throw std::invalid_argument(
655 "hydroStep: outer_bc_mode=pressure_outlet assumes SUBSONIC "
656 "OUTFLOW (0 <= u_r < c) at R_max, but u_r = " +
657 std::to_string(u_wall) + " m/s against c = " +
658 std::to_string(c_wall) +
659 " m/s. Inflow, supersonic outflow and supersonic inflow "
660 "each change the characteristic count in a different "
661 "direction and need their own treatment.");
662 }
663 }
664
665 // ---- faces --------------------------------------------------------
666 for (std::size_t f = 0; f < n_faces; f++)
667 {
668 const std::size_t left = f;
669 const std::size_t right = f + 1;
670 const double r_f = cfg.r_face[f];
671 const double wave = numeric::maxWaveSpeed(
672 work.u_r[left], work.sound_speed[left], work.u_r[right],
673 work.sound_speed[right]);
674
675 // Construct F=[., ., ., ., .] using the Rusanov fluxes, which are the
676 // same for every pool and every species.
677 flux_rho[f] = numeric::rusanovScalarFlux(
678 r_f, work.U_rho[left], work.U_rho[right], work.u_r[left],
679 work.u_r[right], wave);
680 flux_mom[f] = numeric::rusanovMomentumFlux(
681 r_f, work.rho[left], work.u_r[left], work.p_total[left],
682 work.rho[right], work.u_r[right], work.p_total[right], wave);
683 for (std::size_t k = 0; k < n_species; k++)
684 {
685 flux_Y[f * n_species + k] = numeric::rusanovScalarFlux(
686 r_f, work.U_Y[left * n_species + k],
687 work.U_Y[right * n_species + k], work.u_r[left],
688 work.u_r[right], wave);
689 }
690 flux_total_energy[f] = numeric::rusanovEnergyFlux(
691 r_f, work.U_total_energy[left], work.p_total[left],
692 work.u_r[left], work.U_total_energy[right],
693 work.p_total[right], work.u_r[right], wave);
694 flux_electron_energy[f] = numeric::rusanovScalarFlux(
695 r_f, work.U_electron_energy[left],
696 work.U_electron_energy[right], work.u_r[left],
697 work.u_r[right], wave);
698 velocity_flux[f] =
699 numeric::faceVelocityFlux(r_f, work.u_r[left], work.u_r[right]);
700 }
701
702 // ---- forward Euler, NUMERICS.md (10), (11), (13), (14), (15) --------
703 // Once the fluxes are computed, the interior cells are updated with a single
704 // forward-Euler step. The ghosts are never updated, so the fluxes at the
705 // domain boundaries are always computed from the same ghost state.
706 for (std::size_t i = 0; i < n_points; i++)
707 {
708 const std::size_t c = i + 1;
709 const double inv_volume = 1.0 / cfg.V_cell[i];
710
711 work.U_rho[c] -= dt * (flux_rho[i + 1] - flux_rho[i]) * inv_volume;
712
713 double momentum_rate = -(flux_mom[i + 1] - flux_mom[i]) * inv_volume;
714 if (cfg.cylindrical_source)
715 {
716 momentum_rate +=
718 face_span[i]) *
719 inv_volume;
720 }
721 work.U_mom[c] += dt * momentum_rate;
722
723 for (std::size_t k = 0; k < n_species; k++)
724 {
725 work.U_Y[c * n_species + k] -=
726 dt * (flux_Y[(i + 1) * n_species + k] - flux_Y[i * n_species + k]) * inv_volume;
727 }
728
729 // The total row is a pure flux difference: the compression work
730 // rides inside the enthalpy rusanovEnergyFlux() transports, so
731 // every interior face appears twice with opposite signs and
732 // cancels, and total energy changes only by what crosses the two
733 // domain boundaries.
734 work.U_total_energy[c] -=
735 dt * (flux_total_energy[i + 1] - flux_total_energy[i]) * inv_volume;
736
737 // The electron row keeps its physical -p_e div(u) source and is
738 // NOT conservative, deliberately: only the total carries a jump
739 // condition, while how a shock partitions energy between the pools
740 // is a modelling statement rather than a Rankine-Hugoniot one. The
741 // divergence is differenced over the VELOCITY flux, never the mass
742 // flux (appendix A.2).
743 const double divergence =
744 (velocity_flux[i + 1] - velocity_flux[i]) * inv_volume;
745 work.U_electron_energy[c] +=
746 dt * (-(flux_electron_energy[i + 1] - flux_electron_energy[i]) * inv_volume -
747 work.p_electron[c] * divergence);
748 result.pressure_work_electron +=
749 dt * work.p_electron[c] * divergence * cfg.V_cell[i];
750 }
751
752 // Mass that crossed the two domain-boundary faces, so section 10's
753 // identity can be checked as "domain mass change equals accumulated
754 // boundary flux" rather than the weaker "mass constant".
755 result.boundary_mass_out += dt * (flux_rho[n_points] - flux_rho[0]);
756 for (std::size_t k = 0; k < n_species; k++)
757 {
758 result.boundary_species_mass_out[k] +=
759 dt * (flux_Y[n_points * n_species + k] - flux_Y[k]);
760 }
762 dt * (flux_total_energy[n_points] - flux_total_energy[0]);
764 dt * (flux_electron_energy[n_points] - flux_electron_energy[0]);
765
766 // ---- conservative -> primitive ------------------------------------
767 for (std::size_t i = 0; i < n_points; i++)
768 {
769 const std::size_t c = i + 1;
770 // The density the scheme produced, and the only one: a
771 // non-positive value is not repaired, because the kinetic energy
772 // the total-energy row carries is 0.5*(rho u)^2/rho, which a
773 // non-positive mass row leaves undefined.
774 const double rho = work.U_rho[c];
775 if (!(rho > 0.0))
776 {
777 throw std::runtime_error(
778 "hydroStep: density went non-positive at node " +
779 std::to_string(i) +
780 " (rho = " +
781 std::to_string(rho) +
782 " kg/m^3). Lower cfl or refine the grid.");
783 }
784 // The kinetic energy the total-energy row actually carries. A
785 // collapsed cell drives internal_heavy negative and
786 // heavyTemperature() raises, which is the honest outcome.
787 const double carried_kinetic = 0.5 * work.U_mom[c] * work.U_mom[c] / rho;
788 result.rho_min = std::min(result.rho_min, rho);
789
790 double *Y_c = &work.Y[c * n_species];
791 for (std::size_t k = 0; k < n_species; k++)
792 {
793 // No renormalization. The species fluxes sum to the mass flux
794 // exactly, so sum_s Y_s stays 1 to round-off on its own;
795 // rescaling here would hide a broken flux rather than reveal it.
796 Y_c[k] = work.U_Y[c * n_species + k] / rho;
797 }
798
799 // The electron row holds internal energy directly, so it inverts
800 // with no kinetic bookkeeping of its own.
801 const double Y_electron = has_electron ? Y_c[electron] : 0.0;
802 const double rho_electron = rho * Y_electron;
803 if (has_electron && !(rho_electron > 0.0))
804 {
805 // Te is 0/0 here (or, for rho_e < 0, has no meaning), and no
806 // value carried forward would mean anything: electrons
807 // chemistry later creates in this cell would start at it. In
808 // the native solver a cell only gets here when it and its
809 // neighbours hold no electrons, which a seeded start rules
810 // out, or when a reaction sub-step hands in rho_e < 0: neither
811 // CVODE here nor the Python split's scipy vode enforces
812 // Y >= 0.
813 throw std::runtime_error(
814 "hydroStep: node " + std::to_string(i) +
815 " has a non-positive electron density (rho_e = " +
816 std::to_string(rho_electron) +
817 " kg/m^3), so its electron temperature is undefined. "
818 "Either the node started without electrons (seed a positive "
819 "electron fraction at every node) or the reaction sub-step "
820 "returned a negative electron mass fraction.");
821 }
822 if (has_electron)
823 {
824 // Divide by rho_e, NOT rho: e_e and c_v,e are both per kg of
825 // ELECTRONS. Dividing by rho would undershoot Te by a factor
826 // Y_e, around 1e-10 in a weakly ionized channel. Both rows
827 // scale with rho_e -- the only source on the electron row is
828 // the compression work, proportional to p_e -- so no
829 // threshold is applied (NUMERICS.md section 9).
830 work.Te[c] =
831 work.U_electron_energy[c] / (rho_electron * cv_electron);
832 }
833 // The heavy pool has no row of its own: it is what the total holds
834 // once the kinetic energy and the electron pool are taken out,
835 // which gives it -p_H div(u) exactly. The momentum row's O(dr^2)
836 // geometric-source quadrature lands here too, as it does in any
837 // conservative Euler scheme recovering e = E - K.
838 const double internal_heavy =
839 work.U_total_energy[c] - carried_kinetic -
840 rho_electron * cv_electron * work.Te[c];
841
842 work.Tg[c] = cfg.thermo->heavyTemperature(
843 rho, Y_c, internal_heavy / rho, work.Tg[c]);
844
845 work.rho[c] = rho;
846 work.u_r[c] = work.U_mom[c] / rho;
847
848 // The state is primitive again, which is where a NaN born inside
849 // the step first becomes nameable: this is the only point that has
850 // the node and the sub-cycle index to report it with. Y is left
851 // out deliberately -- it is the expensive half at fifty-odd
852 // species, and a NaN in one mass fraction reaches the thermo call
853 // above within this same iteration, so it surfaces through Tg.
854 if (!std::isfinite(rho) || !std::isfinite(work.u_r[c]) ||
855 !std::isfinite(work.Tg[c]) || !std::isfinite(work.Te[c]))
856 {
857 throw std::runtime_error(
858 "hydroStep: node " + std::to_string(i) +
859 " went non-finite on sub-cycle " +
860 std::to_string(result.n_subcycles_used) + " (rho = " +
861 std::to_string(rho) + " kg/m^3, u_r = " +
862 std::to_string(work.u_r[c]) + " m/s, Tg = " +
863 std::to_string(work.Tg[c]) + " K, Te = " +
864 std::to_string(work.Te[c]) + " K).");
865 }
866 }
867
868 result.n_subcycles_used++;
869 t += dt;
870 time_left -= dt;
871 // A pinned count ends on the count, not on the accumulated time:
872 // M subtractions of dt_total/M leave a rounding residual of order
873 // M * eps * dt_total, which for a large M would otherwise exceed the
874 // cut-off below and run one extra, round-off-sized sub-cycle.
875 if (time_left < 1.0e-12 * cfg.dt_total ||
876 (cfg.n_subcycles > 0 && result.n_subcycles_used == cfg.n_subcycles))
877 {
878 time_left = 0.0;
879 }
880 if (!(time_left > 0.0))
881 {
882 break;
883 }
884 }
885
886 if (result.n_subcycles_used > 0)
887 {
888 // The final state's pressures: every earlier one was scanned at the
889 // start of the sub-cycle that advanced from it.
890 for (std::size_t i = 0; i < n_points; i++)
891 {
892 const std::size_t c = i + 1;
893 double p_heavy = 0.0;
894 double p_electron = 0.0;
895 cfg.thermo->pressures(cfg.r_node[i], t, work.rho[c], work.Tg[c],
896 work.Te[c], &work.Y[c * n_species], p_heavy,
897 p_electron);
898 result.p_min = std::min(result.p_min, p_heavy + p_electron);
899 }
900 }
901
902 // ---- 4. narrow back onto the caller's state ------------------------
903 for (std::size_t i = 0; i < n_points; i++)
904 {
905 const std::size_t c = i + 1;
906 state.rho[i] = work.rho[c];
907 state.u_r[i] = work.u_r[c];
908 state.Tg[i] = work.Tg[c];
909 state.Te[i] = work.Te[c];
910 for (std::size_t k = 0; k < n_species; k++)
911 {
912 state.Y[i * n_species + k] = work.Y[c * n_species + k];
913 }
914 }
915 return result;
916 }
917
918} // namespace rizer
Pure-arithmetic primitives for the explicit hydrodynamic sub-step of the 1D radial channel: one face'...
Number formatting for exception messages.
Standalone explicit hydrodynamic sub-stepper for the 1D radial two-temperature plasma channel: the "A...
virtual double densityFromPressure(double pressure, double Tg, double Te, const double *Y) const =0
Density reproducing a target total pressure at a given temperature pair and composition – the inverse...
virtual void pressures(double r, double t, double rho, double Tg, double Te, const double *Y, double &p_heavy, double &p_electron) const =0
Per-pool partial pressures, PHYSICS.md equation (G6).
virtual StateProperties stateProperties(double r, double t, double rho, double Tg, double Te, const double *Y) const
pressures(), soundSpeed() and heavyEnergy() at one state, in one call.
virtual double electronCv() const =0
Electron specific heat at constant volume, per kg of ELECTRONS, .
virtual double heavyTemperature(double rho, const double *Y, double heavy_internal_energy_target, double Tg_seed) const =0
Invert for at fixed density and composition (NUMERICS.md section 8).
double rusanovScalarFlux(double r_face, double phi_left, double phi_right, double u_left, double u_right, double wave_speed)
Rusanov face flux for any node-centred conserved scalar – and each .
Definition EulerFlux.h:93
double maxWaveSpeed(double u_left, double c_left, double u_right, double c_right)
Rusanov dissipation coefficient at a face: the fastest wave speed that can reach it from either side,...
Definition EulerFlux.h:67
double momentumGeometricSourceIntegral(double p, double dr)
Cell-integrated cylindrical momentum source, the second term of NUMERICS.md equation (13):
Definition EulerFlux.h:226
double faceVelocityFlux(double r_face, double u_left, double u_right)
Face velocity flux , whose difference gives the electron-pool compression source of NUMERICS....
Definition EulerFlux.h:199
double rusanovMomentumFlux(double r_face, double rho_left, double u_left, double p_left, double rho_right, double u_right, double p_right, double wave_speed)
Pressure-inclusive Rusanov face flux for the radial momentum row, NUMERICS.md equation (12):
Definition EulerFlux.h:123
double rusanovEnergyFlux(double r_face, double E_left, double p_left, double u_left, double E_right, double p_right, double u_right, double wave_speed)
Pressure-inclusive Rusanov face flux for the total energy row, NUMERICS.md equation (14a) – the face ...
Definition EulerFlux.h:170
@ AmbientGhost
Fix rho, u_r, p, T and Y_s to ambient.
@ PressureOutlet
Hold p = p_amb at the boundary face, extrapolate u_r/Tg/Te/Y_s with zero gradient,...
HydroStepResult hydroStep(const HydroStepConfig &cfg, HydroStepState &state)
Advance state over one Strang half-step of cfg.dt_total.
std::string significant(double value)
value to 6 significant digits, for exception messages.
Definition Format.h:18
Geometry, boundary treatment and stepping controls for one call.
Definition HydroStep.h:94
const ThermoAccessor * thermo
Injected equation of state; must outlive the call.
Definition HydroStep.h:159
bool refuse_above_cfl
With n_subcycles set, refuse a sub-cycle longer than dt_CFL instead of taking it.
Definition HydroStep.h:147
std::vector< double > r_node
Node radii [m], length N, ascending.
Definition HydroStep.h:110
int electron_index
Index of the electron species in Y, or -1 for none.
Definition HydroStep.h:156
std::vector< double > r_face
Face radii [m], length N+1, ascending.
Definition HydroStep.h:115
double t0
Time at the start of the call [s], passed to the thermo accessor and the ghost-state callback.
Definition HydroStep.h:152
double cfl
Courant number applied to the CFL bound [-], in (0, 1].
Definition HydroStep.h:149
double dt_total
Total interval this call covers [s] – one Strang half-step.
Definition HydroStep.h:136
double p_amb
Ambient pressure [Pa] and temperature [K].
Definition HydroStep.h:131
GhostStateFn ghost_state
Optional test-only ghost fill; overrides outer_bc_mode when set.
Definition HydroStep.h:161
std::vector< double > V_cell
Cell "volumes", cross-sectional area per unit axial length [m^2], length N.
Definition HydroStep.h:118
std::vector< double > Y_amb
Ambient composition [-], length K.
Definition HydroStep.h:133
std::size_t n_subcycles
Fixed number of equal sub-cycles of dt_total / n_subcycles; 0 means CFL-limited instead.
Definition HydroStep.h:143
bool cylindrical_source
Apply the cylindrical geometric source p_i*dr_i/V_i of equation (13).
Definition HydroStep.h:124
Diagnostics from one call, enough to assert NUMERICS.md section "Positivity under a strong rarefactio...
Definition HydroStep.h:179
std::size_t n_subcycles_used
M actually taken [-].
Definition HydroStep.h:180
double rho_min
Min rho over every node and sub-cycle [kg/m^3].
Definition HydroStep.h:184
double boundary_mass_out
Mass that left the domain across both boundary faces over the whole call, per unit axial length and p...
Definition HydroStep.h:203
double boundary_total_energy_out
TOTAL energy that left the domain across both boundary faces over the whole call, per unit axial leng...
Definition HydroStep.h:218
std::vector< double > boundary_species_mass_out
The same, per species [kg/m/rad], length K.
Definition HydroStep.h:205
double boundary_electron_energy_out
Electron-pool INTERNAL energy that left the domain across both boundary faces over the whole call,...
Definition HydroStep.h:233
double dt_cfl
CFL bound at the initial state [s].
Definition HydroStep.h:181
double p_min
Min total p over every node and sub-cycle [Pa].
Definition HydroStep.h:185
double pressure_work_electron
Compression work done BY the electron pool over the whole call, per unit axial length and per radian ...
Definition HydroStep.h:244
Per-node primitive state, advanced in place.
Definition HydroStep.h:166
std::vector< double > rho
Mass density [kg/m^3], length N.
Definition HydroStep.h:167
std::vector< double > u_r
Radial velocity [m/s], length N.
Definition HydroStep.h:168
std::size_t n_species
K [-].
Definition HydroStep.h:172
std::vector< double > Te
Electron temperature [K], length N.
Definition HydroStep.h:170
std::vector< double > Y
Mass fractions [-], row-major [N, K].
Definition HydroStep.h:171
std::vector< double > Tg
Heavy-pool temperature [K], length N.
Definition HydroStep.h:169
Primitive state at one radial node, as the ghost-state callback sees it and returns it.
double Te
Electron temperature [K].
std::vector< double > Y
Species mass fractions [-], length K.
double Tg
Heavy-pool temperature [K].
double u_r
Radial velocity [m/s].
double rho
Mass density [kg/m^3].
Every state function hydroStep() reads off one primitive state when it builds the conservative variab...
double heavy_energy
Heavy-pool energy [J/kg of mixture].
double p_heavy
Heavy-pool partial pressure [Pa].
double p_electron
Electron partial pressure [Pa].
double sound_speed
Frozen two-pool sound speed [m/s].