flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
flow_ibm_phase_change.hpp
Go to the documentation of this file.
1
8#ifndef PECLET_FLOW_FLOW_IBM_PHASE_CHANGE_HPP
9#define PECLET_FLOW_FLOW_IBM_PHASE_CHANGE_HPP
10
11namespace peclet::flow {
12
13template <class Grid>
14void Solver<Grid>::enablePhaseChange(double rhoG, double rhoL, double hlv) {
15 if constexpr (Grid::collocated)
16 throw std::runtime_error(
17 "enable_phase_change: rungs P0/P1 are STAGGERED-ONLY (the collocated variable-density "
18 "pair has not been composed with the divergence-source deposit).");
19 enableVof();
20 if (hasSolid_)
21 throw std::runtime_error(
22 "enable_phase_change: an immersed solid is out of scope at rungs P0/P1 (the source "
23 "deposit and the regression would need the solid-clipped flux polygons of rung V5a's "
24 "follow-on). Use an all-fluid set_pressure_geometry.");
25 if (vofMomEnabled_)
26 throw std::runtime_error(
27 "enable_phase_change is not composable with enable_vof_momentum at this rung: both own "
28 "the head of the step, and momentum consistency would have to carry the interfacial mass "
29 "transfer in its own fluxes.");
30 if (!(rhoG > 0.0) || !(rhoL > 0.0))
31 throw std::runtime_error("enable_phase_change: both phase densities must be > 0");
32 if (!(hlv > 0.0))
33 throw std::runtime_error("enable_phase_change: the latent heat h_lv must be > 0");
34 pcRhoGPhys_ = rhoG;
35 pcRhoLPhys_ = rhoL;
36 pcHlvPhys_ = hlv;
38 // WO-P23: phase change and WO-R2 item 4's wisp guard are NOT compatible, and the measurement
39 // is in the findings. `enable_vof` sets `WyAdvector::wispEps = 1e-8`, which makes the advector
40 // treat a cell with `C <= 1e-8` as a PURE phase for reconstruction and flux. The phase-change
41 // driver needs the colour it reconstructs a plane from, deposits a source behind and pins a
42 // Dirichlet row in to be the colour that is actually advected; over the band
43 // `1e-12 < C < 1e-8` the two disagree, and on a CURVED interface that diverges the run — the
44 // P3 Scriven bubble at Ja = 0.5 reads `R(t)` error 48 % and trips the study's dt-collapse guard
45 // at step 34, against 2.002 % and 80 clean steps with the guard off. Sharing the tolerance
46 // (`pcEffInterfaceEps` / `pcEffPureEps`) is necessary but not sufficient: it fixes the planar
47 // gates and moves the Scriven blow-up from step 34 to step ~200 of 80-worth of physical time,
48 // and only `wispEps = 0` removes it. So phase change turns the guard off, loudly, and
49 // `set_vof_wisp_eps` after `enable_phase_change` is the deliberate override.
50 setVofWispEps(0.0);
51 pcMdot_ = addField("mdot");
52 pcSrc_ = addField("pc_source");
53 if (pcArea_.extent(0) != n_) {
54 pcArea_ = CCField("pc_area", n_);
55 pcNrm_[0] = CCField("pc_nx", n_);
56 pcNrm_[1] = CCField("pc_ny", n_);
57 pcNrm_[2] = CCField("pc_nz", n_);
58 pcDep_ = CCField("pc_dep", n_);
59 pcTgt_ = CCField("pc_tgt", n_);
60 pcCnew_ = CCField("pc_cnew", n_);
61 pcDefic_ = CCField("pc_defic", n_);
62 }
63 pcEnabled_ = true;
64}
65
66template <class Grid>
68 return pcEnabled_;
69}
70
71template <class Grid>
73 requirePhaseChange("set_mass_flux_uniform");
74 requireScalesPinned("set_mass_flux_uniform", true);
75 Kokkos::deep_copy(pcMdot_, v * u_.mdotToInt()); // `v * 1.0` in cell units
76 pcThermal_ = false;
77}
78
79template <class Grid>
80void Solver<Grid>::setMassFlux(const std::vector<double>& v) {
81 requirePhaseChange("set_mass_flux");
82 requireScalesPinned("set_mass_flux", true);
83 const double k = u_.mdotToInt();
84 if (k != 1.0) {
85 std::vector<double> vi(v.size());
86 for (std::size_t i = 0; i < v.size(); ++i)
87 vi[i] = v[i] * k;
88 scatterInner(pcMdot_, vi);
89 } else {
90 scatterInner(pcMdot_, v);
91 }
92 fillPropGhosts(pcMdot_);
93 pcThermal_ = false;
94}
95
96template <class Grid>
97void Solver<Grid>::requireScalesPinned(const char* who, bool needRho) const {
98 if (!u_.physical)
99 return;
100 if (!u_.tRefSet || (needRho && !u_.rhoRefSet))
101 throw std::runtime_error(
102 std::string(who) +
103 ": under a physical domain this writes a dimensional value into a field, converted once "
104 "with the reference scales; call set_rho and set_dt first (the first of each pins the "
105 "scale, and a field converted before that would be left in the wrong units)");
106}
107
108template <class Grid>
110 // Every factor is exactly 1.0 in cell units, so each line is `x = x_phys * 1.0` there.
111 pcRhoG_ = pcRhoGPhys_ * u_.rhoToInt();
112 pcRhoL_ = pcRhoLPhys_ * u_.rhoToInt();
113 pcHlv_ = pcHlvPhys_ * u_.latentToInt();
114 pcKg_ = pcKgPhys_ * u_.condToInt();
115 pcKl_ = pcKlPhys_ * u_.condToInt();
116 pcRint_ = pcRintPhys_ * u_.rintToInt();
117 pcRcpG_ = pcRcpGPhys_ * u_.rcpToInt();
118 pcRcpL_ = pcRcpLPhys_ * u_.rcpToInt();
119 if (pcEnergy_) { // the consumers that cached the old internal values
120 if (vofEnergy_.initialized())
121 vofEnergy_.setPhaseRcp(pcRcpG_, pcRcpL_);
123 }
124}
125
126template <class Grid>
127void Solver<Grid>::setPhaseChangeThermal(const std::string& tname, double Tsat, double kg,
128 double kl, double Rint) {
129 requirePhaseChange("set_phase_change_thermal");
130 if (!hasScalar(tname))
131 throw std::runtime_error("set_phase_change_thermal: no scalar named '" + tname +
132 "' (call add_scalar first)");
134 throw std::invalid_argument("set_phase_change_thermal: '" + tname +
135 "' is a cut-cell scalar; the phase-change energy path takes a "
136 "legacy scalar only (doc/scalar_ibm_design.md §8.1)");
137 pcTName_ = tname;
138 pcTsat_ = Tsat; // a temperature: never rescaled
139 pcKgPhys_ = kg;
140 pcKlPhys_ = kl;
141 pcRintPhys_ = Rint;
143 pcThermal_ = true;
144 scalarDirichletMask(tname); // allocate the per-cell Dirichlet mask + value fields
146}
147
148template <class Grid>
150 pcThermal_ = false;
151}
152
153template <class Grid>
155 pcPlaneDir_ = on;
156}
157
158template <class Grid>
160 return pcPlaneDir_;
161}
162
163template <class Grid>
165 pcQuadFit_ = on;
166}
167
168template <class Grid>
170 return pcQuadFit_;
171}
172
173template <class Grid>
175 if (mode < 0 || mode > 7)
176 throw std::runtime_error(
177 "set_phase_change_area: mode must be 0 (PLIC/MYC), 1 (cascade metric), 2 (cascade "
178 "normal), 3 (cascade footprint) or 4-7 (the joined marching-tetrahedra sheet: 4 colour/"
179 "centroid, 5 colour/split, 6 PLIC-distance/centroid, 7 PLIC-distance/split)");
180 pcAreaMode_ = mode;
181}
182
183template <class Grid>
185 return pcAreaMode_;
186}
187
188template <class Grid>
190 if (!vofEnabled_)
191 throw std::runtime_error("vof_interface_area: VoF is not enabled (call enable_vof first)");
193 double a = 0.0;
194 if (pcAreaMode_ == vof::kAreaPlic) {
195 const I3 e3 = I3{e3_.x, e3_.y, e3_.z};
196 const int g = kVofG;
197 const long sy = e3.x, sz = (long)e3.x * e3.y;
198 CCConst c = CCConst(vofAdv_.colour());
199 const double eps = pcEffInterfaceEps();
200 // The PHYSICAL polygon area in hRef^2 (V5.1), as the phase-change build takes it: on box cells
201 // the unit-cube `plicArea` is not an area at all. Bit-identical on cubic cells (det = s = 1).
202 const vof::VofMetric gme = u_.vofMetric();
203 Kokkos::parallel_reduce(
204 "peclet::flow::vof_area_plic",
205 MDRange3<CCExec>(CCExec(), {g, g, g}, {g + nx_, g + ny_, g + nz_}),
206 KOKKOS_LAMBDA(int x, int y, int z, double& acc) {
207 const long i = (long)x + (long)y * e3.x + (long)z * sz;
208 if (!vof::pcIsInterfacial(c(i), eps))
209 return;
210 double st[27];
211 for (int kk = -1; kk <= 1; ++kk)
212 for (int jj = -1; jj <= 1; ++jj)
213 for (int ii = -1; ii <= 1; ++ii)
214 st[vof::plicSt(ii + 1, jj + 1, kk + 1)] = c(i + ii + jj * sy + kk * sz);
215 double m[3];
216 vof::mycNormal(st, m);
217 acc += vof::plicAreaMetric(m[0], m[1], m[2], vof::plicAlpha(m[0], m[1], m[2], c(i)), gme);
218 },
219 a);
220 Kokkos::fence();
221 } else {
223 }
224#ifdef PECLET_FLOW_MPI
225 if (distributed_) {
226 double gsum = 0.0;
228 a = gsum;
229 }
230#endif
231 return a * u_.areaToPhys(); // hRef^2 -> the caller's area (exactly 1.0 in cell units)
232}
233
234template <class Grid>
236 if (pcAreaMode_ >= vof::kAreaMcColour) {
237 if (!pcAreaMc_.ready())
238 pcAreaMc_.init(nx_, ny_, nz_, kVofG);
239 pcAreaMc_.interfaceEps = pcEffInterfaceEps();
240 const auto m = pcAreaMc_.compute(vofAdv_.colour(), pcAreaMode_);
242 s.interfacial = m.cells;
243 s.hf = m.cells - m.orphanCells; // the cells the flux integral can actually use
244 s.hfMixed = 0;
245 s.pv = 0;
246 s.noEstimate = m.orphanCells;
247 s.area = m.area;
248 pcMcOrphanArea_ = m.orphanArea;
249 return s;
250 }
251 pcMcOrphanArea_ = 0.0;
252 if (!pcAreaC_.ready())
253 pcAreaC_.init(nx_, ny_, nz_, kVofG);
254 pcAreaC_.interfaceEps = pcEffInterfaceEps();
255 pcAreaC_.pureEps = vofAdv_.wispEps;
256 return pcAreaC_.compute(vofAdv_.colour(), pcAreaMode_);
257}
258
259template <class Grid>
261 return (pcAreaMode_ >= vof::kAreaMcColour) ? pcAreaMc_.area() : pcAreaC_.area();
262}
263
264template <class Grid>
266 requirePhaseChange("set_phase_change_energy");
267 if (!pcThermal_)
268 throw std::runtime_error(
269 "set_phase_change_energy: call set_phase_change_thermal first (the consistent transport "
270 "is for the energy scalar it names)");
271 if (!(rcpGas > 0.0) || !(rcpLiquid > 0.0))
272 throw std::runtime_error("set_phase_change_energy: both phase rho*c_p must be > 0");
273 pcRcpGPhys_ = rcpGas;
274 pcRcpLPhys_ = rcpLiquid;
275 pcRefreshUnits(); // pcEnergy_ is not yet set here, so this only derives the constants
276 if (pcKcell_.extent(0) != n_) {
277 pcKcell_ = CCField("pc_k", n_);
278 pcRcp_ = CCField("pc_rcp", n_);
279 }
280 ScalarField& sc = scalarField(pcTName_);
281 sc.energy = true;
282 sc.kcell = pcKcell_;
283 sc.rcp = pcRcp_;
284 if (!vofEnergy_.initialized())
285 vofEnergy_.init(vofAdv_, pcRcpG_, pcRcpL_);
286 else
287 vofEnergy_.setPhaseRcp(pcRcpG_, pcRcpL_);
288 vofEnergy_.exchange = [this](CCField f) { this->vofExchangeScalar(f); };
289 pcEnergy_ = true;
291}
292
293template <class Grid>
295 vofEnergy_.energyMuscl = on;
296}
297
298template <class Grid>
300 return vofEnergy_.energyMuscl;
301}
302
303template <class Grid>
305 pcEnergy_ = false;
306 if (!pcTName_.empty() && hasScalar(pcTName_))
307 scalarField(pcTName_).energy = false;
308}
309
310template <class Grid>
312 return pcEnergy_;
313}
314
315template <class Grid>
317 requirePhaseChange("set_phase_change_budget");
318 pcBudgetOn_ = on;
319 if (on && pcClsPrev_.extent(0) != n_) {
320 pcClsPrev_ = CCField("pc_cls_prev", n_);
321 Kokkos::deep_copy(pcClsPrev_, -1.0); // "no previous step": the first call counts no changes
322 }
323}
324
325template <class Grid>
327 return pcBudgetOn_;
328}
329
330template <class Grid>
332 requirePhaseChange("set_phase_change_carry_conserve");
333 pcCarryConserve_ = on;
334 if (on && pcCarrySrc_.extent(0) != n_) {
335 pcCarrySrc_ = CCField("pc_carry_src", n_);
336 Kokkos::deep_copy(pcCarrySrc_, 0.0);
337 }
338}
339
340template <class Grid>
342 return pcCarryConserve_;
343}
344
345template <class Grid>
347 requirePhaseChange("set_phase_change_fit_curvature");
348 pcFitKappa_ = kappa / u_.curvToPhys();
349}
350
351template <class Grid>
353 return pcFitKappa_ * u_.curvToPhys();
354}
355
356template <class Grid>
358 requirePhaseChange("set_phase_change_energy_order");
359 if (order != 1 && order != 2)
360 throw std::runtime_error("set_phase_change_energy_order: order must be 1 or 2");
361 setPhaseChangeMdotOperator(order == 2);
364 setPhaseChangeCarryConserve(order == 2);
365}
366
367template <class Grid>
369 return (pcMdotOperator_ && pcGfmOrder_ == 2 && pcCurvDist_ && pcCarryConserve_) ? 2 : 1;
370}
371
372template <class Grid>
374 requirePhaseChange("set_phase_change_mdot_operator");
375 if (on && pcRint_ != 0.0 && !pcThermal_)
376 throw std::runtime_error("set_phase_change_mdot_operator: needs the thermal mass flux");
377 pcMdotOperator_ = on;
378 if (on && pcMdotFit_.extent(0) != n_)
379 pcMdotFit_ = CCField("pc_mdot_fit", n_);
380}
381
382template <class Grid>
384 return pcMdotOperator_;
385}
386
387template <class Grid>
389 requirePhaseChange("set_phase_change_gfm_order");
390 if (order != 1 && order != 2)
391 throw std::runtime_error("set_phase_change_gfm_order: order must be 1 or 2");
392 pcGfmOrder_ = order;
393}
394
395template <class Grid>
397 return pcGfmOrder_;
398}
399
400template <class Grid>
402 requirePhaseChange("set_phase_change_curvature_distance");
403 pcCurvDist_ = on;
404 if (on && pcKappa_.extent(0) != n_)
405 pcKappa_ = CCField("pc_kappa", n_);
406 pcMaskFresh_ = false; // the row geometry has to be rebuilt with (or without) the curvature
407}
408
409template <class Grid>
411 return pcCurvDist_;
412}
413
414template <class Grid>
416 requirePhaseChange("set_phase_change_deposit_fallback");
417 pcDepositFallback_ = on;
418}
419
420template <class Grid>
422 return pcDepositFallback_;
423}
424
425template <class Grid>
427 return pcQOperator_ * u_.powerToPhys();
428}
429
430template <class Grid>
432 return pcQOrphan_ * u_.powerToPhys();
433}
434
435template <class Grid>
437 return pcCarryDeposited_ * pcLedgerEnergyToPhys();
438}
439
440template <class Grid>
442 return pcCarryLost_ * pcLedgerEnergyToPhys();
443}
444
445template <class Grid>
447 // A per-cell sum of rcp' (T - T_ref), cell volume left out: an energy with the consistent
448 // transport (internal energy unit x the cell volume in hRef^3), and with the constant-
449 // diffusivity scalar — whose ledgers weight by 1 instead of rho c_p — a temperature x volume.
450 return (pcEnergy_ ? u_.energyToPhys() : u_.volToPhys()) * u_.vol;
451}
452
453template <class Grid>
455 PhaseChangeBudget b = pcBudget_;
456 const double ke = pcLedgerEnergyToPhys();
457 // the GFM fluxes are sums of FACE fluxes k' o (dT)/theta: a power (per time: divide the
458 // energy unit by tRef), with no cell-volume factor
459 const double kq = pcEnergy_ ? u_.powerToPhys() : u_.volToPhys() * u_.divToPhys();
460 b.hOpen *= ke;
461 b.hOpenNew *= ke;
462 b.hLiquid *= ke;
463 b.hMasked *= ke;
464 b.dEoverwrite *= ke;
465 b.dEoverwriteNew *= ke;
466 b.eEnter *= ke;
467 b.eLeave *= ke;
468 b.qGfm *= kq;
469 b.qBehind *= kq;
470 return b;
471}
472
473template <class Grid>
475 const C3 e = e_;
476 const long sx = 1, sy = e_.x, sz = (long)e_.x * e_.y;
477 CCConst c = CCConst(cField_), U = CCConst(C[0].u), V = CCConst(C[1].u), W = CCConst(C[2].u);
478 CCConst ox = CCConst(ox_), oy = CCConst(oy_), oz = CCConst(oz_);
479 const double eps = pcEffInterfaceEps();
480 double m = 0.0;
481 Kokkos::parallel_reduce(
482 "peclet::flow::pc_band_div",
483 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
484 KOKKOS_LAMBDA(int x, int y, int z, double& acc) {
485 const long i = (long)x + (long)y * sy + (long)z * sz;
486 if (!vof::pcIsInterfacial(c(i), eps))
487 return;
488 const double d = (ox(i + sx) * U(i + sx) - ox(i) * U(i)) +
489 (oy(i + sy) * V(i + sy) - oy(i) * V(i)) +
490 (oz(i + sz) * W(i + sz) - oz(i) * W(i));
491 acc = Kokkos::fmax(acc, Kokkos::fabs(d));
492 },
493 Kokkos::Max<double>(m));
494 Kokkos::fence();
495 return Kokkos::fmax(m, 0.0);
496}
497
498template <class Grid>
500 if (!pcEnergy_)
501 return;
502 const C3 e = e_;
503 CCField kf = pcKcell_, rf = pcRcp_;
504 CCConst c = CCConst(cField_);
505 const double kg = pcKg_, kl = pcKl_, rg = pcRcpG_, rl = pcRcpL_;
506 Kokkos::parallel_for(
507 "peclet::flow::pc_energy_props",
508 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
509 KOKKOS_LAMBDA(int x, int y, int z) {
510 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
511 const double cl = Kokkos::fmin(Kokkos::fmax(c(i), 0.0), 1.0);
512 kf(i) = vof::pcPhaseMix(kg, kl, cl);
513 rf(i) = vof::pcPhaseMix(rg, rl, cl);
514 });
515 Kokkos::fence();
516 fillPropGhosts(pcKcell_);
517 fillPropGhosts(pcRcp_);
518}
519
520template <class Grid>
521void Solver<Grid>::setDivergenceSource(const std::vector<double>& v) {
522 requireScalesPinned("set_divergence_source", false);
523 if (pcUser_.extent(0) != n_)
524 pcUser_ = addField("div_source");
525 const double k = u_.divToInt(); // S' = S * tRef (exactly 1.0 in cell units)
526 if (k != 1.0) {
527 std::vector<double> vi(v.size());
528 for (std::size_t i = 0; i < v.size(); ++i)
529 vi[i] = v[i] * k;
530 scatterInner(pcUser_, vi);
531 } else {
532 scatterInner(pcUser_, v);
533 }
534 fillPropGhosts(pcUser_);
535 pcHasUser_ = true;
536}
537
538template <class Grid>
540 pcHasUser_ = false;
541}
542
543template <class Grid>
544void Solver<Grid>::setDivergenceSink(const std::vector<double>& w) {
545 requirePhaseChange("set_divergence_sink");
546 if (pcSink_.extent(0) != n_)
547 pcSink_ = addField("div_sink");
548 scatterInner(pcSink_, w);
549 fillPropGhosts(pcSink_);
550 const C3 e = e_;
551 CCConst wv = CCConst(pcSink_);
552 double acc = 0.0;
553 Kokkos::parallel_reduce(
554 "peclet::flow::pc_sink_weight",
555 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
556 KOKKOS_LAMBDA(int x, int y, int z, double& a) {
557 a += wv((long)x + (long)y * e.x + (long)z * (long)e.x * e.y);
558 },
559 acc);
560 Kokkos::fence();
561#ifdef PECLET_FLOW_MPI
562 if (distributed_) {
563 double g = 0;
564 MPI_Allreduce(&acc, &g, 1, MPI_DOUBLE, MPI_SUM, comm_);
565 acc = g;
566 }
567#endif
568 if (!(acc > 0.0))
569 throw std::runtime_error("set_divergence_sink: the weights must have a positive global sum");
570 pcSinkW_ = acc;
571 pcHasSink_ = true;
572}
573
574template <class Grid>
576 pcHasSink_ = false;
577}
578
579template <class Grid>
581 const double dt = dtPhysArg * u_.timeToInt();
582 requirePhaseChange("apply_phase_change");
585 pcRegress(dt);
588 // WO-P3f: the a-priori half of the energy-budget instrument. With no energy solve to bracket,
589 // `pcBudgetPost` on the CURRENT temperature and the just-rebuilt plane geometry answers the
590 // one question an exact-state probe can ask of the energy side: how much heat do the
591 // plane-anchored rows draw across this interface, against the `mdot h_lv A_Gamma` the SAME
592 // fields make the regression book? Inert unless `set_phase_change_budget(true)` ran.
593 if (pcBudgetOn_ && pcThermal_ && pcClsPrev_.extent(0) == n_) {
594 ScalarField& sc = scalarField(pcTName_);
595 if (sc.dmask.extent(0) == n_)
596 pcBudgetPost(sc);
597 }
598}
599
600template <class Grid>
602 if (!pcEnabled_)
603 return;
606 pcRegress(dt_);
607}
608
609template <class Grid>
611 requirePhaseChange("phase_change_diagnostics");
612 PhaseChangeDiagnostics d = pcDiag_;
613 // colour extrema of the CURRENT field (the regression's own boundedness read-out)
614 CCConst c = CCConst(cField_);
615 const C3 e = e_;
616 double mn = 1e300, mx = -1e300;
617 Kokkos::parallel_reduce(
618 "peclet::flow::pc_extrema",
619 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
620 KOKKOS_LAMBDA(int x, int y, int z, double& lo, double& hi) {
621 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
622 lo = Kokkos::fmin(lo, c(i));
623 hi = Kokkos::fmax(hi, c(i));
624 },
625 Kokkos::Min<double>(mn), Kokkos::Max<double>(mx));
626 Kokkos::fence();
627 d.minC = mn;
628 d.maxC = mx;
629 d.bandDiv = pcBandDivergence();
630 if (pcEnergy_ && vofEnergy_.initialized())
631 vofEnergy_.extrema(d.Tmin, d.Tmax);
632 // To the caller's units (every factor exactly 1.0 in cell units; counts, colours and
633 // temperatures are unit-free). The colour ledger is a liquid VOLUME: colour x cell volume.
634 const double km = u_.mdotToPhys(), ka = u_.areaToPhys(), kq = u_.powerToPhys(),
635 kv = u_.volToPhys() * u_.vol, kd = u_.divToPhys();
636 d.mdotMin *= km;
637 d.mdotMax *= km;
638 d.mdotMean *= km;
639 d.mdotFit *= km;
640 d.area *= ka;
641 d.areaOrphan *= ka;
642 d.qOperator *= kq;
643 d.qOrphan *= kq;
644 d.removedVolume *= kv;
645 d.redistributed *= kv;
646 d.unresolved *= kv;
647 d.sourceSum *= kd;
648 d.bandDiv *= kd;
649 return d;
650}
651
652template <class Grid>
654 // WO-P23 (a defect found in the P0/P1 code): the gradient fit reads the temperature at +-2, so
655 // the energy scalar's ghost band has to be VALID here. `set_field` and the coupling drivers
656 // write inner cells only, and `advanceScalars` fills the ghosts at its END — so the FIRST
657 // `apply_phase_change` / `step` of every run fitted its one-sided gradients against a band of
658 // zeros. Measured on the P2 sucking-interface kernel probe (an exact analytic state, no time
659 // stepping, quasi-2D so the y/z ghosts are inside the 5^3 stencil): mdot came out
660 // **8.16 against the exact 18.48**, a 56 % error that did NOT converge under refinement,
661 // because the transverse ghost samples are counted as pure liquid at T = 0 and pull the fit
662 // towards zero. One line, and it is a correctness fix, not a tolerance.
663 if (pcThermal_)
664 scalarFillGhosts(scalarField(pcTName_));
665 // WO-P3g item 1: the operator-flux `mdot` reads the very rows the energy solve uses, so the
666 // Dirichlet mask, the plane geometry and (item 3) the curvature must belong to the colour this
667 // build reconstructs from. In the COUPLED step they already do — `pcUpdateThermalMask` runs at
668 // the bottom of `step()` on exactly this colour, and nothing has touched it since — so this
669 // costs nothing there. After `set_vof` / `set_field`, i.e. in the one-shot `apply_phase_change`
670 // probe, they do not, and rebuilding here is what makes the probe measure the same operator the
671 // run does.
672 if (pcMdotOperator_ && pcThermal_ && !pcMaskFresh_) {
675 }
676 // WO-P3c: the cascade-consistent interfacial area, computed on the colour field's OWN g = 3
677 // block (the height columns reach +-3, which the G = 2 phase-change block does not carry) and
678 // copied back onto the inner region. Inert at `pcAreaMode_ == kAreaPlic`: the kernel below then
679 // takes the branch it always took, and `pcAreaCg2_` is never even allocated.
680 const bool cascadeArea = (pcAreaMode_ != vof::kAreaPlic);
681 if (cascadeArea) {
682 if (pcAreaCg2_.extent(0) != n_)
683 pcAreaCg2_ = CCField("pc_area_cascade", n_);
685 const auto as = pcAreaCascadeCompute();
686 pcDiag_.areaHf = as.hf + as.hfMixed;
687 pcDiag_.areaPv = as.pv;
688 pcDiag_.areaNone = as.noEstimate;
689 pcDiag_.areaOrphan = pcMcOrphanArea_;
690 copyInner(pcAreaCg2_, e_, G, CCConst(pcAreaCascadeField()), e3_, kVofG);
691 }
692 CCConst areaCasc = CCConst(cascadeArea ? pcAreaCg2_ : pcMdot_);
693 const C3 e = e_;
694 const long sy = e_.x, sz = (long)e_.x * e_.y;
695 CCField mdot = pcMdot_, area = pcArea_, dep = pcDep_, tgt = pcTgt_;
696 CCField nx = pcNrm_[0], ny = pcNrm_[1], nz = pcNrm_[2];
697 CCConst c = CCConst(cField_);
698 const bool thermal = pcThermal_;
699 CCConst T = thermal ? CCConst(scalarField(pcTName_).c) : CCConst(cField_);
700 const double eps = pcEffInterfaceEps(), pureEps = pcEffPureEps();
701 const bool quad = pcQuadFit_;
702 const bool depFallback = pcDepositFallback_;
703 const double Tsat = pcTsat_, kg = pcKg_, kl = pcKl_, hlv = pcHlv_, Rint = pcRint_;
704 const double kapPresc = pcFitKappa_; // WO-P3f, 0 = the shipped (tangent-plane) distance
705 const double rhoG = pcRhoG_, rhoL = pcRhoL_;
706 // WO-P3g. `curvDist` (item 3) replaces the prescribed curvature by the V3 cascade's per-cell
707 // one; `opMode` (item 1) replaces the least-squares fit by the energy operator's own flux, for
708 // which the whole Dirichlet row set has to be readable inside the kernel.
709 const bool curvDist = pcCurvDist_ && pcKappa_.extent(0) == n_;
710 CCConst kapF = CCConst(curvDist ? pcKappa_ : pcMdot_);
711 ScalarField* scT = thermal ? &scalarField(pcTName_) : nullptr;
712 const bool opMode =
713 pcMdotOperator_ && thermal && pcGphi_.extent(0) == n_ && scT->dmask.extent(0) == n_;
714 const bool opEnergy = opMode && scT->energy && scT->kcell.extent(0) == n_;
715 // UNITS. Without the consistent energy transport the operator flux below is built from the
716 // scalar's DIFFUSIVITY, i.e. it is the heat divided by an implicit rho c_p = 1 — one unit of
717 // the solver's internal system, which is a different physical heat capacity in every unit
718 // system. In cell units that is the shipped (and gated) behaviour and stays; under an armed
719 // extent it has no physical meaning, so the combination is refused rather than guessed.
720 if (opMode && !opEnergy && u_.physical)
721 throw std::runtime_error(
722 "set_phase_change_mdot_operator / set_phase_change_energy_order(2) under a physical "
723 "domain need set_phase_change_energy(True, rho_cp_gas, rho_cp_liquid): without it the "
724 "operator's interfacial heat is a diffusivity flux with an implicit rho*c_p of one "
725 "internal unit, which is not a physical heat capacity");
726 if (opMode)
728 CCConst mkF = CCConst(opMode ? scT->dmask : pcMdot_);
729 CCConst inD = CCConst(opMode ? pcInDomain_ : pcMdot_);
730 CCConst kcF = CCConst(opEnergy ? scT->kcell : pcMdot_);
731 CCConst oxF = CCConst(ox_), oyF = CCConst(oy_), ozF = CCConst(oz_);
732 const double Dconst = thermal ? scT->D : 0.0, thMin = pcGfmThMin_, thMax = pcGfmThMax_;
733 const int gfmOrder = pcGfmOrder_;
734 // Phase 3 (V5, `flow/doc/anisotropic_vof.md` §7): the phase-change layer is where lengths,
735 // areas and the cell VOLUME all enter at once. `gme` is the per-axis metric; `vCell` the cell
736 // volume in hRef^3. Both are exactly 1 on every isotropic run.
737 const vof::VofMetric gme = u_.vofMetric();
738 const double vCell = u_.vol;
739 CCField mdotFit = opMode ? pcMdotFit_ : pcMdot_;
740 long nIface = 0, nFallback = 0;
741 double sumArea = 0.0, sumMdot = 0.0, sumQ = 0.0, sumQorph = 0.0, sumMdotFitA = 0.0;
742 Kokkos::parallel_reduce(
743 "peclet::flow::pc_build", MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
744 KOKKOS_LAMBDA(int x, int y, int z, long& nif, long& nfb, double& aacc, double& macc,
745 double& qacc, double& qorph, double& mfacc) {
746 const long i = (long)x + (long)y * e.x + (long)z * sz;
747 area(i) = 0.0;
748 dep(i) = 0.0;
749 tgt(i) = 0.0;
750 nx(i) = 0.0;
751 ny(i) = 0.0;
752 nz(i) = 0.0;
753 if (!vof::pcIsInterfacial(c(i), eps)) {
754 if (thermal)
755 mdot(i) = 0.0;
756 return;
757 }
758 double st[27];
759 for (int kk = -1; kk <= 1; ++kk)
760 for (int jj = -1; jj <= 1; ++jj)
761 for (int ii = -1; ii <= 1; ++ii)
762 st[vof::plicSt(ii + 1, jj + 1, kk + 1)] = c(i + ii + jj * sy + kk * sz);
763 double m[3];
764 vof::mycNormal(st, m);
765 const double al = vof::plicAlpha(m[0], m[1], m[2], c(i));
766 // PHYSICAL area (V5.1) — `areaCasc` already carries the metric from its own driver.
767 const double A = cascadeArea ? areaCasc(i) : vof::plicAreaMetric(m[0], m[1], m[2], al, gme);
768 // The PHYSICAL unit normal and the PHYSICAL centre distance (V5.2): `s(m)` is the shape
769 // factor of §2, exactly 1.0 at equal spacings, so both are today's values there.
770 double n[3] = {1.0, 0.0, 0.0};
771 const double sMet = vof::vofPhysNormal(m, gme, n);
772 if (!(sMet > 0.0))
773 return;
774 const double phic = vof::pcCentreDistance(m[0], m[1], m[2], al) / sMet;
775 // **WO-P3g item 1 — the area is a UNIT CONVERSION, not a term in the mass balance.**
776 // The regression removes `dV = mdot A dt/rho_l` and the source deposits
777 // `S = mdot A (1/rho_g - 1/rho_l)`, and with `mdot = q/(h_lv A)` BOTH are `A`-free:
778 // `dV = q dt/(h_lv rho_l)`, `S = q (1/rho_g - 1/rho_l)/h_lv`. So a cell the AREA
779 // ESTIMATOR gave nothing still has to evaporate the heat its Dirichlet rows draw, or
780 // that heat is simply destroyed. Measured on the a-priori probe (exact sphere, R = 20,
781 // Ja 0.5, area mode 6): **27.6 % of the operator's total interfacial heat sits on
782 // interfacial cells with A = 0** -- the joined marching-tetrahedra sheet deposits each
783 // triangle to the cell holding its centroid, so a band cell can carry an interface and
784 // no area. `Aeff` is the unit conversion those cells use; the DIAGNOSTIC area
785 // (`interface_area`) stays the geometric one, so `mdot_area` is unaffected.
786 double Aeff = A;
787 double md = mdot(i);
788 if (thermal) {
789 const double Tg = vof::pcInterfaceTemperature(Tsat, md, Rint);
790 // WO-P3g item 3: the sample distance is measured to the CURVED interface, with the
791 // curvature taken per cell from the V3 cascade (`curvDist`) or, as WO-P3f shipped it,
792 // prescribed for the whole field. Bitwise unchanged when both are 0.
793 const double kapFit = curvDist ? kapF(i) : kapPresc;
794 vof::PcGradFit fg, fl;
795 for (int dz = -2; dz <= 2; ++dz)
796 for (int dy = -2; dy <= 2; ++dy)
797 for (int dx = -2; dx <= 2; ++dx) {
798 if (dx == 0 && dy == 0 && dz == 0)
799 continue;
800 // V5.2: the sample offset is PHYSICAL — `delta = H d` — because the fit models
801 // T against the physical normal distance and returns a physical dT/dn. With
802 // index offsets the fitted gradient would be off by the shape factor `s(m)`.
803 // `g.h = {1,1,1}` multiplies each component by 1.0.
804 const double di[3] = {(double)dx, (double)dy, (double)dz};
805 double dp[3];
806 gme.toPhys(di, dp);
807 const double w = vof::pcGradWeight(dp[0], dp[1], dp[2], n);
808 if (!(w > 0.0))
809 continue;
810 const long j = i + dx + dy * sy + dz * sz;
811 const double cj = c(j);
812 double phi = vof::pcOffsetDistance(phic, n, dp[0], dp[1], dp[2]);
813 // WO-P3f: bitwise unchanged at kappaFit == 0 (the shipped default)
814 if (kapFit != 0.0)
815 phi = vof::pcCurvedDistance(phi, dp[0], dp[1], dp[2], n, kapFit);
817 vof::pcGradAdd(fg, w, phi, T(j), Tg);
818 else if (cj >= 1.0 - pureEps && phi < 0.0)
819 vof::pcGradAdd(fl, w, phi, T(j), Tg);
820 }
823 // **WO-P3g item 1 — `mdot` from the ENERGY OPERATOR's own interfacial flux.**
824 //
825 // The rows the energy solve runs are `scalarMaskGfm2`'s, one per (pure cell, masked
826 // neighbour) face; this gathers the SAME rows from the masked side and asks how much
827 // heat they draw. Every ingredient is read from the state that solve used — the
828 // Dirichlet mask, the face openness, `k(C)` and the plane geometry — and the plane this
829 // kernel just reconstructed is bitwise the plane `pcUpdateThermalMask` stored (same
830 // colour, same `mycNormal`/`plicAlpha`), so `theta` is identical to the row's own.
831 //
832 // What it buys: `mdot h_lv A = q` makes the heat the energy equation loses and the mass
833 // the regression produces ONE discrete quantity (WO-P3f measured the shipped pair
834 // disagreeing by 0.95…1.04), and `A` then cancels out of both the plane shift
835 // `dV = mdot A dt/rho_l` and the divergence source — the interfacial area stops being a
836 // term in the mass balance at all.
837 if (opMode) {
838 const long stq[3] = {1, sy, sz};
839 double qsum = 0.0, csum = 0.0;
840 for (int d = 0; d < 3; ++d)
841 for (int sg = -1; sg <= 1; sg += 2) {
842 const long pc = i + (long)sg * stq[d];
843 if (mkF(pc) > 0.5)
844 continue; // an identity row: it carries no Dirichlet coupling
845 if (!(inD(pc) > 0.5))
846 continue; // outside the global domain: no row exists there at all
847 const double of = (d == 0) ? ((sg > 0) ? oxF(i + 1) : oxF(i))
848 : (d == 1) ? ((sg > 0) ? oyF(i + sy) : oyF(i))
849 : ((sg > 0) ? ozF(i + sz) : ozF(i));
850 if (!(of > 0.0))
851 continue;
852 const double ofB = (d == 0) ? ((sg > 0) ? oxF(pc + 1) : oxF(pc))
853 : (d == 1) ? ((sg > 0) ? oyF(pc + sy) : oyF(pc))
854 : ((sg > 0) ? ozF(pc + sz) : ozF(pc));
855 const long jb = pc + (long)sg * stq[d];
856 const bool behind = !(mkF(jb) > 0.5) && ofB > 0.0;
857 // the step from the PURE cell `pc` to this (masked) cell is -sg
858 // V5.3: `theta` is a distance ALONG a grid line, i.e. an axis ratio — it takes
859 // the INDEX centre distance and the INDEX normal component, both of which the
860 // metric-aware overload reconstructs from the physical pair.
861 const double th = vof::pcGfmThetaKAniso(
862 phic, n, d, (double)(-sg), curvDist ? kapF(i) : 0.0, thMin, thMax, gme);
863 const vof::PcGfmRow row = vof::pcGfmRow(th, behind, gfmOrder);
864 const double cf = (opEnergy ? kcF(pc) : Dconst) * of * row.aGamma;
865 qsum += cf * (T(pc) - Tsat);
866 csum += cf;
867 // The three-point row also RESCALES `pc`'s band toward the cell BEHIND it, and
868 // that rescaling is one-sided (cell `jb`'s own row does not mirror it), so it is
869 // part of the heat the interface removes from the solved set. Booking it here is
870 // what makes `sum_j Q_j` the EXACT total the operator transfers -- and, on a
871 // 1-D quadratic, the exact conductive flux through the interfacial face at every
872 // theta (gate (a); with the Dirichlet coupling alone it is a factor 2/(1+theta)
873 // off, which is the whole point of the second-order row).
874 if (behind && row.aBehind != 1.0) {
875 const double kb = opEnergy ? 0.5 * (kcF(pc) + kcF(jb)) : Dconst;
876 qsum += (row.aBehind - 1.0) * kb * ofB * (T(pc) - T(jb));
877 }
878 }
879 mdotFit(i) = md; // the least-squares estimator, kept as a diagnostic
880 Aeff = (A > 0.0) ? A : 1.0;
882 qacc += qsum;
883 if (!(A > 0.0))
884 qorph += qsum; // reported, but no longer dropped
885 mfacc += mdotFit(i) * A;
886 }
887 mdot(i) = md;
888 }
889 area(i) = Aeff;
890 nx(i) = n[0];
891 ny(i) = n[1];
892 nz(i) = n[2];
893 ++nif;
894 aacc += A; // the GEOMETRIC area, for the diagnostics; `Aeff` is the unit conversion
895 macc += md;
896 // the divergence source and the pure-gas cell that will carry it
897 // V5.5: `S` is a volumetric source, so the interfacial flux is divided by the cell
898 // VOLUME `V' = hp_x hp_y hp_z` (exactly 1.0 isotropic: `x / 1.0 == x`).
899 const double S = vof::pcDivSource(md, Aeff, rhoG, rhoL) / vCell;
900 if (S != 0.0) {
901 // WO-P23: the receiving PURE GAS cell is the BEST cell of the 5^3 neighbourhood on
902 // the `+n` side, scored by Malan's own collinearity weight `(d.n)^2/|d|^3` — closest
903 // and most nearly along the normal wins, so it returns the `d = round(n)` cell whenever
904 // that one is pure and degrades gracefully when it is not. The rung P0/P1 rule tried
905 // exactly two candidates (`round(k n)`, k = 1, 2) and left the source IN the
906 // interfacial cell when both were still interfacial — which is what happens on a
907 // CURVED interface: measured 48 … 262 such cells on the P3 Scriven bubble, and each of
908 // them then carries `div(open u) = S` on its OWN faces, i.e. Weymouth-Yue advects it
909 // with a field that is not the liquid velocity (that is exactly what
910 // `phase_change_diagnostics()['band_div']` reports). The search order is fixed and the
911 // comparison strict, so the choice is deterministic and decomposition-independent.
912 int tx = 0, ty = 0, tz = 0;
913 bool found = false;
914 for (int k = 1; k <= 2 && !found; ++k) { // the rung P0/P1 rule, FIRST and unchanged
915 const int ox = (int)Kokkos::round(k * n[0]);
916 const int oy = (int)Kokkos::round(k * n[1]);
917 const int oz = (int)Kokkos::round(k * n[2]);
918 if (ox == 0 && oy == 0 && oz == 0)
919 continue;
920 if (c(i + ox + oy * sy + oz * sz) <= pureEps) {
921 tx = ox;
922 ty = oy;
923 tz = oz;
924 found = true;
925 }
926 }
927 if (!found && depFallback) {
928 // Only where that rule FAILS does the deposit fall back to the best cell of the `+n`
929 // half of the 5^3 box, scored by Malan's collinearity weight. Those are the cells the
930 // P0/P1 code left the source in (48 … 262 of them on the Scriven bubble), and each
931 // one then carries `div(open u) = S` on its OWN faces, i.e. Weymouth-Yue advecting
932 // the colour with a field that is not the liquid velocity.
933 //
934 // The order matters and the measurement says so: making the search the PRIMARY rule
935 // (best-`w` over the whole box) DIVERGES the Scriven bubble — `max|uf|` runs away and
936 // the study's dt-collapse guard trips at step 316 of the Ja = 0.5 run with the
937 // Weymouth-Yue Courant number pinned at 0.38 however small dt is. The planar gates
938 // (P0a/P0b/P1/P2) never saw it, because there the two rules choose the same cell.
939 // Preferring the along-the-normal candidates keeps the validated behaviour wherever
940 // it existed and only fills the holes.
941 double best = 0.0;
942 for (int dz = -2; dz <= 2; ++dz)
943 for (int dy = -2; dy <= 2; ++dy)
944 for (int dx = -2; dx <= 2; ++dx) {
945 if (dx == 0 && dy == 0 && dz == 0)
946 continue;
947 // V5.5: the candidate is scored on its PHYSICAL offset, so on a stretched
948 // grid the search still prefers the cell that is nearest AND most along the
949 // normal. `g.h = {1,1,1}` multiplies each component by 1.0.
950 const double di[3] = {(double)dx, (double)dy, (double)dz};
951 double dp[3];
952 gme.toPhys(di, dp);
953 if (!(dp[0] * n[0] + dp[1] * n[1] + dp[2] * n[2] > 0.0))
954 continue; // the deposit goes BEHIND the interface, into the gas
955 const double w = vof::pcGradWeight(dp[0], dp[1], dp[2], n);
956 if (!(w > best))
957 continue;
958 if (c(i + dx + dy * sy + dz * sz) > pureEps)
959 continue;
960 best = w;
961 tx = dx;
962 ty = dy;
963 tz = dz;
964 }
965 if (!(best > 0.0))
966 ++nfb; // no pure gas cell in the 5^3 box on the +n side: the source stays put
967 } else if (!found) {
968 ++nfb; // the default: no pure gas cell within two cells, the source stays put
969 }
970 dep(i) = S;
971 tgt(i) = (double)((tx + 2) + 5 * (ty + 2) + 25 * (tz + 2));
972 }
973 },
975 Kokkos::fence();
976 pcQOperator_ = sumQ;
977 pcQOrphan_ = sumQorph;
978 pcMdotFitMean_ = sumArea > 0.0 ? sumMdotFitA / sumArea : 0.0;
979 pcDiag_.qOperator = sumQ;
980 pcDiag_.qOrphan = sumQorph;
981 pcDiag_.mdotFit = pcMdotFitMean_;
982 pcDiag_.interfaceCells = nIface;
983 pcDiag_.fallbackCells = nFallback;
984 pcDiag_.area = sumArea;
985 pcDiag_.mdotMean = nIface > 0 ? sumMdot / (double)nIface : 0.0;
986 // extrema of mdot over interfacial cells
987 double mn = 0.0, mx = 0.0;
988 if (nIface > 0) {
989 mn = 1e300;
990 mx = -1e300;
991 CCConst md = CCConst(pcMdot_), ar = CCConst(pcArea_);
992 Kokkos::parallel_reduce(
993 "peclet::flow::pc_mdot_extrema",
994 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
995 KOKKOS_LAMBDA(int x, int y, int z, double& lo, double& hi) {
996 const long i = (long)x + (long)y * e.x + (long)z * sz;
997 if (ar(i) > 0.0) {
998 lo = Kokkos::fmin(lo, md(i));
999 hi = Kokkos::fmax(hi, md(i));
1000 }
1001 },
1002 Kokkos::Min<double>(mn), Kokkos::Max<double>(mx));
1003 Kokkos::fence();
1004 }
1005 pcDiag_.mdotMin = mn;
1006 pcDiag_.mdotMax = mx;
1007 // The exchange that makes the two consumers decomposition-independent. `fillGhosts` is the
1008 // halo/periodic base; on a NON-periodic domain face the periodic wrap would import the far
1009 // side's interface as a phantom source/deficit donor, so those ghosts are zeroed.
1010 fillGhosts(pcMdot_);
1011 fillGhosts(pcArea_);
1012 fillGhosts(pcDep_);
1013 fillGhosts(pcTgt_);
1014 for (int d = 0; d < 3; ++d)
1015 fillGhosts(pcNrm_[d]);
1016 pcZeroDomainGhosts(pcMdot_);
1017 pcZeroDomainGhosts(pcArea_);
1018 pcZeroDomainGhosts(pcDep_);
1019 pcZeroDomainGhosts(pcTgt_);
1020 for (int d = 0; d < 3; ++d)
1021 pcZeroDomainGhosts(pcNrm_[d]);
1022}
1023
1024template <class Grid>
1026 const C3 e = e_;
1027 const long sy = e_.x, sz = (long)e_.x * e_.y;
1028 CCField src = pcSrc_;
1029 CCConst dep = CCConst(pcDep_), tgt = CCConst(pcTgt_);
1030 double sum = 0.0;
1031 long ncell = 0;
1032 Kokkos::parallel_reduce(
1033 "peclet::flow::pc_source_gather",
1034 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1035 KOKKOS_LAMBDA(int x, int y, int z, double& acc, long& nc) {
1036 const long i = (long)x + (long)y * e.x + (long)z * sz;
1037 double s = 0.0;
1038 for (int dz = -2; dz <= 2; ++dz)
1039 for (int dy = -2; dy <= 2; ++dy)
1040 for (int dx = -2; dx <= 2; ++dx) {
1041 const long j = i + dx + dy * sy + dz * sz;
1042 const double dj = dep(j);
1043 if (dj == 0.0)
1044 continue;
1045 const int code = (int)tgt(j);
1046 const int tx = code % 5 - 2, ty = (code / 5) % 5 - 2, tz = code / 25 - 2;
1047 if (tx + dx == 0 && ty + dy == 0 && tz + dz == 0)
1048 s += dj;
1049 }
1050 src(i) = s;
1051 acc += s;
1052 if (s != 0.0)
1053 ++nc;
1054 },
1055 sum, ncell);
1056 Kokkos::fence();
1057#ifdef PECLET_FLOW_MPI
1058 if (distributed_) {
1059 double g = 0;
1060 MPI_Allreduce(&sum, &g, 1, MPI_DOUBLE, MPI_SUM, comm_);
1061 sum = g;
1062 }
1063#endif
1064 // WO-P23: the auto-balanced sink. Subtract the GLOBAL deposited source, spread over the sink
1065 // weights, so a closed domain's Poisson RHS is compatible by construction.
1066 if (pcHasSink_) {
1067 const double f = sum / pcSinkW_;
1068 CCConst wv = CCConst(pcSink_);
1069 Kokkos::parallel_for(
1070 "peclet::flow::pc_sink_apply",
1071 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1072 KOKKOS_LAMBDA(int x, int y, int z) {
1073 const long i = (long)x + (long)y * e.x + (long)z * sz;
1074 src(i) -= f * wv(i);
1075 });
1076 Kokkos::fence();
1077 }
1078 pcDiag_.sourceSum = sum;
1079 pcDiag_.sourceCells = ncell;
1080 fillPropGhosts(pcSrc_);
1081}
1082
1083template <class Grid>
1085 const C3 e = e_;
1086 const long sy = e_.x, sz = (long)e_.x * e_.y;
1087 CCField Cf = cField_, cnew = pcCnew_, defic = pcDefic_;
1088 CCConst md = CCConst(pcMdot_), ar = CCConst(pcArea_);
1089 const double rhoL = pcRhoL_;
1090 // V5.5: `mdot A dt/rho_l` is a liquid VOLUME; the colour it removes is that volume divided by
1091 // the CELL volume `V' = hp_x hp_y hp_z` (exactly 1.0 on every isotropic run).
1092 const double vCell = u_.vol;
1093 double removed = 0.0;
1094 long ndef = 0, nexc = 0;
1095 double redist = 0.0;
1096 Kokkos::parallel_reduce(
1097 "peclet::flow::pc_regress_raw", MDRange3<CCExec>(CCExec(), {0, 0, 0}, {e.x, e.y, e.z}),
1098 KOKKOS_LAMBDA(int x, int y, int z, double& rem, long& nd, long& ne, double& rd) {
1099 const long i = (long)x + (long)y * e.x + (long)z * sz;
1100 const double A = ar(i);
1101 if (!(A > 0.0)) {
1102 cnew(i) = Cf(i);
1103 defic(i) = 0.0;
1104 return;
1105 }
1106 const double dV = vof::pcRegressVolume(md(i), A, dt, rhoL) / vCell;
1107 const double raw = Cf(i) - dV;
1108 const double cl = Kokkos::fmin(Kokkos::fmax(raw, 0.0), 1.0);
1109 cnew(i) = cl;
1110 defic(i) = raw - cl;
1111 const bool inner =
1112 (x >= G && x < e.x - G && y >= G && y < e.y - G && z >= G && z < e.z - G);
1113 if (inner) {
1114 rem += dV;
1115 if (raw < 0.0)
1116 ++nd;
1117 if (raw > 1.0)
1118 ++ne;
1119 rd += Kokkos::fabs(raw - cl);
1120 }
1121 },
1122 removed, ndef, nexc, redist);
1123 Kokkos::fence();
1124 CCConst cn = CCConst(pcCnew_), df = CCConst(pcDefic_);
1125 CCConst nxv = CCConst(pcNrm_[0]), nyv = CCConst(pcNrm_[1]), nzv = CCConst(pcNrm_[2]);
1126 Kokkos::parallel_for(
1127 "peclet::flow::pc_regress_apply",
1128 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1129 KOKKOS_LAMBDA(int x, int y, int z) {
1130 const long i = (long)x + (long)y * e.x + (long)z * sz;
1131 double v = cn(i);
1132 const long st[3] = {1, sy, sz};
1133 for (int d = 0; d < 3; ++d)
1134 for (int s = -1; s <= 1; s += 2) {
1135 const long j = i + (long)s * st[d]; // the neighbour that might push into i
1136 const double dj = df(j);
1137 if (dj == 0.0)
1138 continue;
1139 // Recompute j's WHOLE allocation here (not just i's share): every receiver runs the
1140 // identical arithmetic on the identical inputs, so the sum each cell forms has a
1141 // fixed order and is bitwise independent of the decomposition.
1142 const double sgn = dj < 0.0 ? -1.0 : 1.0;
1143 double n[3] = {nxv(j), nyv(j), nzv(j)};
1144 int step[3];
1145 double w[3];
1146 bool avail[3];
1147 for (int q = 0; q < 3; ++q) {
1148 const double p = sgn * n[q];
1149 const int sq = (p > 0.0) ? 1 : ((p < 0.0) ? -1 : 0);
1150 const double ct = sq == 0 ? 0.0 : cn(j + (long)sq * st[q]);
1151 avail[q] = sq != 0 && (dj < 0.0 ? (ct > 0.0) : (ct < 1.0));
1152 }
1153 vof::pcPushWeights(n, sgn, avail, step, w);
1154 // j pushes into j + step[d]*e_d; that is i iff step[d] == -s
1155 if (step[d] == -s)
1156 v += dj * w[d];
1157 }
1158 Cf(i) = v;
1159 });
1160 Kokkos::fence();
1161 pcDiag_.removedVolume = removed;
1162 pcDiag_.deficitCells = ndef;
1163 pcDiag_.excessCells = nexc;
1164 pcDiag_.redistributed = redist;
1165 // How much residue found no neighbour able to absorb it (pushed anyway, on the unrestricted
1166 // weights, so conservation holds and the colour goes slightly out of [0,1] instead).
1167 double unres = 0.0;
1168 Kokkos::parallel_reduce(
1169 "peclet::flow::pc_unresolved",
1170 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1171 KOKKOS_LAMBDA(int x, int y, int z, double& acc) {
1172 const long i = (long)x + (long)y * e.x + (long)z * sz;
1173 const double dj = df(i);
1174 if (dj == 0.0)
1175 return;
1176 const double sgn = dj < 0.0 ? -1.0 : 1.0;
1177 const long st[3] = {1, sy, sz};
1178 double n[3] = {nxv(i), nyv(i), nzv(i)};
1179 bool avail[3];
1180 for (int q = 0; q < 3; ++q) {
1181 const double p = sgn * n[q];
1182 const int sq = (p > 0.0) ? 1 : ((p < 0.0) ? -1 : 0);
1183 const double ct = sq == 0 ? 0.0 : cn(i + (long)sq * st[q]);
1184 avail[q] = sq != 0 && (dj < 0.0 ? (ct > 0.0) : (ct < 1.0));
1185 }
1186 int step[3];
1187 double w[3];
1188 if (!vof::pcPushWeights(n, sgn, avail, step, w))
1189 acc += Kokkos::fabs(dj);
1190 },
1191 unres);
1192 Kokkos::fence();
1193 pcDiag_.unresolved = unres;
1194 fillPropGhosts(cField_);
1195}
1196
1197template <class Grid>
1199 if (pcKappa_.extent(0) != n_)
1200 pcKappa_ = CCField("pc_kappa", n_);
1202 vofCurv_.compute(vofAdv_.colour());
1203 copyInner(pcKappa_, e_, G, CCConst(vofCurv_.kappa()), e3_, kVofG);
1204 // read at depth 1 (the GFM row asks for the INTERFACIAL neighbour's curvature), so exchange;
1205 // a periodic wrap on a non-periodic domain face would import a phantom curvature, so zero it.
1206 fillGhosts(pcKappa_);
1207 pcZeroDomainGhosts(pcKappa_);
1208}
1209
1210template <class Grid>
1212 if (pcInDomain_.extent(0) == n_)
1213 return;
1214 pcInDomain_ = CCField("pc_indomain", n_);
1215 Kokkos::deep_copy(pcInDomain_, 0.0);
1216 const C3 e = e_;
1217 CCField f = pcInDomain_;
1218 Kokkos::parallel_for(
1219 "peclet::flow::pc_indomain",
1220 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1221 KOKKOS_LAMBDA(int x, int y, int z) {
1222 f((long)x + (long)y * e.x + (long)z * (long)e.x * e.y) = 1.0;
1223 });
1224 Kokkos::fence();
1225 fillGhosts(pcInDomain_);
1226 pcZeroDomainGhosts(pcInDomain_);
1227}
1228
1229template <class Grid>
1231 if (!pcThermal_)
1232 return;
1233 if (pcCurvDist_)
1234 pcUpdateCurvature(); // WO-P3g item 3: kappa for the row's theta and for the carried-value fit
1235 ScalarField& sc = scalarField(pcTName_);
1236 scalarFillGhosts(sc); // the carried-value refit reads T at +-2 (see pcBuildInterface)
1237 const C3 e = e_;
1238 const long sy = e_.x, sz = (long)e_.x * e_.y;
1239 CCField mk = sc.dmask, dv = sc.dval, tg = pcTgam_;
1240 CCField gn0 = pcGn_[0], gn1 = pcGn_[1], gn2 = pcGn_[2], gph = pcGphi_;
1241 CCConst c = CCConst(cField_), md = CCConst(pcMdot_), T = CCConst(sc.c);
1242 const double eps = pcEffInterfaceEps(), pureEps = pcEffPureEps(), Tsat = pcTsat_, Rint = pcRint_;
1243 const double kapPresc = pcFitKappa_; // WO-P3f
1244 const vof::VofMetric gme = u_.vofMetric(); // Phase 3 (V5.2/V5.3)
1245 const bool curvDist = pcCurvDist_ && pcKappa_.extent(0) == n_; // WO-P3g item 3
1246 CCConst kapF = CCConst(curvDist ? pcKappa_ : pcMdot_);
1247 const bool carry = pcPlaneDir_, quad = pcQuadFit_;
1248 const long syl = sy, szl = sz;
1249 Kokkos::parallel_for(
1250 "peclet::flow::pc_thermal_mask",
1251 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1252 KOKKOS_LAMBDA(int x, int y, int z) {
1253 const long i = (long)x + (long)y * e.x + (long)z * sz;
1254 const bool on = vof::pcIsInterfacial(c(i), eps);
1255 const double Tgam = vof::pcInterfaceTemperature(Tsat, md(i), Rint);
1256 mk(i) = on ? 1.0 : 0.0;
1257 tg(i) = Tgam;
1258 dv(i) = Tgam;
1259 gn0(i) = 0.0;
1260 gn1(i) = 0.0;
1261 gn2(i) = 0.0;
1262 gph(i) = 0.0;
1263 if (!on)
1264 return;
1265 // WO-P23: the plane geometry the PLANE-ANCHORED (ghost-fluid) Dirichlet rows need — the
1266 // unit normal and the signed centre distance of THIS cell's PLIC plane, rebuilt from the
1267 // colour the energy solve is about to run with (the head-of-step values belong to C^n and
1268 // the interface has moved since).
1269 double st[27];
1270 for (int kk = -1; kk <= 1; ++kk)
1271 for (int jj = -1; jj <= 1; ++jj)
1272 for (int ii = -1; ii <= 1; ++ii)
1273 st[vof::plicSt(ii + 1, jj + 1, kk + 1)] = c(i + ii + jj * sy + kk * sz);
1274 double m[3];
1275 vof::mycNormal(st, m);
1276 // V5.2/V5.3: the stored normal and centre distance are PHYSICAL — the plane-anchored
1277 // rows and the one-sided fit both measure in physical lengths, and the axis pullback
1278 // happens in `pcGfmThetaKAniso`. Identity at the unit metric.
1279 double n[3] = {0.0, 0.0, 0.0};
1280 const double sMet = vof::vofPhysNormal(m, gme, n);
1281 if (!(sMet > 0.0))
1282 return;
1283 const double al = vof::plicAlpha(m[0], m[1], m[2], c(i));
1284 gn0(i) = n[0];
1285 gn1(i) = n[1];
1286 gn2(i) = n[2];
1287 const double phic = vof::pcCentreDistance(m[0], m[1], m[2], al) / sMet;
1288 gph(i) = phic;
1289 if (!carry)
1290 return;
1291 // The value this cell CARRIES until the interface sweeps past it and it becomes pure.
1292 // Nothing reads it across a face (the plane-anchored rows use `tg`), so it is free to be
1293 // the one-sided extrapolation of the profile on the side the cell CENTRE lies on.
1294 const bool gasSide = phic > 0.0;
1295 const double kapFit = curvDist ? kapF(i) : kapPresc; // WO-P3g item 3
1297 for (int dz = -2; dz <= 2; ++dz)
1298 for (int dy = -2; dy <= 2; ++dy)
1299 for (int dx = -2; dx <= 2; ++dx) {
1300 if (dx == 0 && dy == 0 && dz == 0)
1301 continue;
1302 const double di[3] = {(double)dx, (double)dy, (double)dz};
1303 double dp[3];
1304 gme.toPhys(di, dp); // V5.2: PHYSICAL sample offsets
1305 const double w = vof::pcGradWeight(dp[0], dp[1], dp[2], n);
1306 if (!(w > 0.0))
1307 continue;
1308 const long j = i + dx + dy * syl + dz * szl;
1309 const double cj = c(j);
1310 double phi = vof::pcOffsetDistance(phic, n, dp[0], dp[1], dp[2]);
1311 if (kapFit != 0.0) // WO-P3f, bitwise unchanged at 0
1312 phi = vof::pcCurvedDistance(phi, dp[0], dp[1], dp[2], n, kapFit);
1313 if (gasSide) {
1314 if (cj <= pureEps && phi > 0.0)
1315 vof::pcGradAdd(f, w, phi, T(j), Tgam);
1316 } else if (cj >= 1.0 - pureEps && phi < 0.0) {
1317 vof::pcGradAdd(f, w, phi, T(j), Tgam);
1318 }
1319 }
1321 });
1322 Kokkos::fence();
1323 // The GFM rows read mask / dval / n / phi of a FACE NEIGHBOUR, i.e. at depth 1: exchange, then
1324 // kill the periodic wrap on non-periodic domain faces (a wrapped "interfacial" ghost would turn
1325 // a domain-boundary face into a phantom Dirichlet).
1326 fillGhosts(sc.dmask);
1327 fillGhosts(sc.dval);
1328 fillGhosts(pcTgam_);
1329 fillGhosts(pcGn_[0]);
1330 fillGhosts(pcGn_[1]);
1331 fillGhosts(pcGn_[2]);
1332 fillGhosts(pcGphi_);
1333 pcZeroDomainGhosts(sc.dmask);
1334 pcZeroDomainGhosts(sc.dval);
1335 pcZeroDomainGhosts(pcTgam_);
1336 pcZeroDomainGhosts(pcGn_[0]);
1337 pcZeroDomainGhosts(pcGn_[1]);
1338 pcZeroDomainGhosts(pcGn_[2]);
1339 pcZeroDomainGhosts(pcGphi_);
1340 // WO-P3f: the enthalpy the overwrite is about to destroy, returned to the phase it came from.
1341 // Runs AFTER the exchanges above (it reads the donor's mask/normal/T_Gamma at depth 1 and the
1342 // donor's own neighbours at depth 2). Inert -- no kernel, no allocation -- when the option
1343 // is off.
1344 if (pcCarryConserve_ && pcCarrySrc_.extent(0) == n_)
1345 pcCarryDeposit(sc);
1346 // WO-P3g item 1: the Dirichlet rows now describe THIS colour field, which is what the
1347 // operator-flux `mdot` needs before it may read them (see the note in `pcBuildInterface`).
1348 pcMaskFresh_ = true;
1349}
1350
1351template <class Grid>
1353 for (int face = 0; face < 6; ++face) {
1354 if (bc_[face] == 0 || !touchesGlobalFace(face))
1355 continue;
1356 const int a = face / 2, side = face % 2;
1357 const int t1 = (a + 1) % 3, t2 = (a + 2) % 3;
1358 const int nt1 = (t1 == 0) ? nx_ : (t1 == 1) ? ny_ : nz_;
1359 const int nt2 = (t2 == 0) ? nx_ : (t2 == 1) ? ny_ : nz_;
1360 const int na = (a == 0) ? nx_ : (a == 1) ? ny_ : nz_;
1361 const long sx = 1, sy = e_.x, sz = (long)e_.x * e_.y;
1362 const long sa = (a == 0) ? sx : (a == 1) ? sy : sz;
1363 const long st1 = (t1 == 0) ? sx : (t1 == 1) ? sy : sz;
1364 const long st2 = (t2 == 0) ? sx : (t2 == 1) ? sy : sz;
1365 const int aInner = (side == 0) ? G : (G + na - 1);
1366 const int dir = (side == 0) ? -1 : +1;
1367 Kokkos::parallel_for(
1368 "peclet::flow::pc_zero_ghosts", MDRange2<CCExec>(CCExec(), {G, G}, {G + nt1, G + nt2}),
1369 KOKKOS_LAMBDA(int j1, int j2) {
1370 const long base = (long)aInner * sa + (long)j1 * st1 + (long)j2 * st2;
1371 for (int L = 1; L <= 2; ++L)
1372 f(base + (long)dir * L * sa) = 0.0;
1373 });
1374 }
1375 Kokkos::fence();
1376}
1377
1378template <class Grid>
1380 const C3 e = e_;
1381 const long sx = 1, sy = e_.x, sz = (long)e_.x * e_.y;
1382 CCConst c = CCConst(cField_), T = CCConst(sc.c), dv = CCConst(sc.dval), mk = CCConst(sc.dmask),
1383 tg = CCConst(pcTgam_), gn0 = CCConst(pcGn_[0]), gn1 = CCConst(pcGn_[1]),
1384 gn2 = CCConst(pcGn_[2]);
1385 CCField srcF = pcCarrySrc_;
1386 const bool useRcp = pcEnergy_;
1387 const double rcpG = pcRcpG_, rcpL = pcRcpL_;
1388 double dep = 0.0, lost = 0.0;
1389 using MD = MDRange3<CCExec>;
1390 Kokkos::parallel_reduce(
1391 "peclet::flow::pc_carry_deposit", MD(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1392 KOKKOS_LAMBDA(int x, int y, int z, double& acc) {
1393 const long st[3] = {sx, sy, sz};
1394 const long i = (long)x + (long)y * sy + (long)z * sz;
1395 if (mk(i) > 0.5)
1396 return; // masked cells are the identity rows; they receive nothing
1397 double add = 0.0;
1398 for (int d = 0; d < 3; ++d)
1399 for (int sg = -1; sg <= 1; sg += 2) {
1400 const long j = i + (long)sg * st[d];
1401 if (!(mk(j) > 0.5))
1402 continue;
1403 const double rj = useRcp ? vof::pcPhaseMix(rcpG, rcpL, c(j)) : 1.0;
1404 const double dE = rj * (T(j) - dv(j));
1405 if (!(dE != 0.0))
1406 continue;
1407 const double nn[3] = {gn0(j), gn1(j), gn2(j)};
1408 const double devj = T(j) - tg(j);
1409 int chosen[3] = {0, 0, 0};
1410 double w[3] = {0.0, 0.0, 0.0}, wsum = 0.0;
1411 for (int dd = 0; dd < 3; ++dd) {
1412 if (nn[dd] == 0.0)
1413 continue;
1414 const int sL = (nn[dd] > 0.0) ? -1 : +1; // n points into the GAS
1415 const double dl = T(j + (long)sL * st[dd]) - tg(j);
1416 const double dg = T(j - (long)sL * st[dd]) - tg(j);
1417 const int s = (dl * devj >= dg * devj) ? sL : -sL;
1418 if (mk(j + (long)s * st[dd]) > 0.5)
1419 continue; // that neighbour is itself an identity row: it cannot absorb
1420 chosen[dd] = s;
1421 w[dd] = nn[dd] * nn[dd];
1422 wsum += w[dd];
1423 }
1424 if (!(wsum > 0.0))
1425 continue; // no neighbour left in the solve; the residual is booked below
1426 if (chosen[d] != -sg)
1427 continue;
1428 add += (w[d] / wsum) * dE;
1429 }
1430 if (add == 0.0)
1431 return;
1432 const double ri = useRcp ? vof::pcPhaseMix(rcpG, rcpL, c(i)) : 1.0;
1433 srcF(i) += add / ri; // stored as a TEMPERATURE increment for cell i
1434 acc += add;
1435 },
1436 dep);
1437 // The donor total, so `lost` is the EXACT unplaced remainder rather than a census of one
1438 // failure mode: an interfacial cell whose whole `n_d^2` allocation lands on cells that are
1439 // themselves identity rows keeps its discarded enthalpy, and on a curved interface the band is
1440 // thicker than one cell, so that is not a rare event. Report it, never hide it.
1441 Kokkos::parallel_reduce(
1442 "peclet::flow::pc_carry_total", MD(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1443 KOKKOS_LAMBDA(int x, int y, int z, double& tot) {
1444 const long i = (long)x + (long)y * sy + (long)z * sz;
1445 if (!(mk(i) > 0.5))
1446 return;
1447 const double ri = useRcp ? vof::pcPhaseMix(rcpG, rcpL, c(i)) : 1.0;
1448 tot += ri * (T(i) - dv(i));
1449 },
1450 lost);
1451 Kokkos::fence();
1452 pcCarryDeposited_ = dep;
1453 pcCarryLost_ = lost - dep;
1454}
1455
1456template <class Grid>
1458 const C3 e = e_;
1459 const long sy = e_.x, sz = (long)e_.x * e_.y;
1460 CCField cOld = sc.cOld, srcF = pcCarrySrc_;
1461 CCConst mk = CCConst(sc.dmask);
1462 Kokkos::parallel_for(
1463 "peclet::flow::pc_carry_apply",
1464 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1465 KOKKOS_LAMBDA(int x, int y, int z) {
1466 const long i = (long)x + (long)y * sy + (long)z * sz;
1467 if (!(mk(i) > 0.5))
1468 cOld(i) += srcF(i);
1469 srcF(i) = 0.0;
1470 });
1471 Kokkos::fence();
1472}
1473
1474template <class Grid>
1476 const C3 e = e_;
1477 const long sy = e_.x, sz = (long)e_.x * e_.y;
1478 CCConst c = CCConst(cField_), T = CCConst(sc.cOld), dvl = CCConst(sc.dval),
1479 mk = CCConst(sc.dmask), cls = CCConst(pcClsPrev_);
1480 const bool useRcp = sc.energy && sc.rcp.extent(0) == n_;
1481 CCConst rcp = useRcp ? CCConst(sc.rcp) : CCConst(sc.c);
1482 const double Tsat = pcTsat_;
1483 double hOpen = 0, hLiq = 0, hMask = 0, dEo = 0, dEoN = 0, eEnt = 0, eLev = 0;
1484 long nEL = 0, nEG = 0, nLL = 0, nLG = 0, nM = 0;
1485 using MD = MDRange3<CCExec>;
1486 Kokkos::parallel_reduce(
1487 "peclet::flow::pc_budget_pre", MD(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1488 KOKKOS_LAMBDA(int x, int y, int z, double& a1, double& a2, double& a3, double& a4, double& a5,
1489 double& a6, double& a7) {
1490 const long i = (long)x + (long)y * sy + (long)z * sz;
1491 const double r = useRcp ? rcp(i) : 1.0;
1492 const double h = r * (T(i) - Tsat);
1493 const bool msk = mk(i) > 0.5;
1494 if (msk) {
1495 a3 += h;
1496 a4 += r * (dvl(i) - T(i));
1497 } else {
1498 a1 += h;
1499 if (c(i) >= 0.5)
1500 a2 += h;
1501 }
1502 const double cp = cls(i);
1503 if (!(cp >= 0.0))
1504 return; // the first instrumented step has no previous class
1505 const bool wasMsk = (cp > 0.5 && cp < 1.5);
1506 if (msk && !wasMsk) {
1507 a6 += h;
1508 a5 += r * (dvl(i) - T(i));
1509 } else if (!msk && wasMsk) {
1510 a7 += h;
1511 }
1512 },
1513 hOpen, hLiq, hMask, dEo, dEoN, eEnt, eLev);
1514 Kokkos::parallel_reduce(
1515 "peclet::flow::pc_budget_counts", MD(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1516 KOKKOS_LAMBDA(int x, int y, int z, long& c1, long& c2, long& c3, long& c4, long& c5) {
1517 const long i = (long)x + (long)y * sy + (long)z * sz;
1518 const bool msk = mk(i) > 0.5;
1519 if (msk)
1520 c5 += 1;
1521 const double cp = cls(i);
1522 if (!(cp >= 0.0))
1523 return;
1524 const bool wasMsk = (cp > 0.5 && cp < 1.5);
1525 if (msk && !wasMsk) {
1526 if (cp > 1.5)
1527 c1 += 1;
1528 else
1529 c2 += 1;
1530 } else if (!msk && wasMsk) {
1531 if (c(i) >= 0.5)
1532 c3 += 1;
1533 else
1534 c4 += 1;
1535 }
1536 },
1537 nEL, nEG, nLL, nLG, nM);
1538 Kokkos::fence();
1539 pcBudget_.hOpen = hOpen;
1540 pcBudget_.hLiquid = hLiq;
1541 pcBudget_.hMasked = hMask;
1542 pcBudget_.dEoverwrite = dEo;
1543 pcBudget_.dEoverwriteNew = dEoN;
1544 pcBudget_.eEnter = eEnt;
1545 pcBudget_.eLeave = eLev;
1546 pcBudget_.nEnterLiquid = nEL;
1547 pcBudget_.nEnterGas = nEG;
1548 pcBudget_.nLeaveLiquid = nLL;
1549 pcBudget_.nLeaveGas = nLG;
1550 pcBudget_.nMasked = nM;
1551}
1552
1553template <class Grid>
1555 const C3 e = e_;
1556 const long sx = 1, sy = e_.x, sz = (long)e_.x * e_.y;
1557 CCConst c = CCConst(cField_), T = CCConst(sc.c), mk = CCConst(sc.dmask), tg = CCConst(pcTgam_),
1558 gnx = CCConst(pcGn_[0]), gny = CCConst(pcGn_[1]), gnz = CCConst(pcGn_[2]),
1559 gph = CCConst(pcGphi_);
1560 CCConst ox = CCConst(ox_), oy = CCConst(oy_), oz = CCConst(oz_);
1561 const bool useRcp = sc.energy && sc.rcp.extent(0) == n_;
1562 const bool useK = sc.energy && sc.kcell.extent(0) == n_;
1563 CCConst rcp = useRcp ? CCConst(sc.rcp) : CCConst(sc.c);
1564 CCConst kc = useK ? CCConst(sc.kcell) : CCConst(sc.c);
1565 const double Tsat = pcTsat_, Dc = sc.D, thMin = pcGfmThMin_, thMax = pcGfmThMax_;
1566 const bool gfm = pcPlaneDir_ && pcGphi_.extent(0) == n_;
1567 // WO-P3g: the instrument has to mirror the ROW, or it measures a scheme nobody ran.
1568 const int gfmOrder = pcGfmOrder_;
1569 const bool curvDist = pcCurvDist_ && pcKappa_.extent(0) == n_;
1570 CCConst kapF = CCConst(curvDist ? pcKappa_ : pcGphi_);
1571 const vof::VofMetric gme = u_.vofMetric(); // Phase 3 (V5.3)
1572 CCField clsOut = pcClsPrev_;
1573 double hOpen = 0, q = 0, qb = 0;
1574 using MD = MDRange3<CCExec>;
1575 Kokkos::parallel_reduce(
1576 "peclet::flow::pc_budget_post", MD(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1577 KOKKOS_LAMBDA(int x, int y, int z, double& a1, double& a2, double& a3) {
1578 const long i = (long)x + (long)y * sy + (long)z * sz;
1579 const double r = useRcp ? rcp(i) : 1.0;
1580 const bool msk = mk(i) > 0.5;
1581 clsOut(i) = msk ? 1.0 : (c(i) >= 0.5 ? 2.0 : 0.0);
1582 if (msk)
1583 return;
1584 a1 += r * (T(i) - Tsat);
1585 if (!gfm)
1586 return;
1587 const long st[3] = {sx, sy, sz};
1588 const double kd = useK ? kc(i) : Dc;
1589 for (int d = 0; d < 3; ++d)
1590 for (int sgn = -1; sgn <= 1; sgn += 2) {
1591 const long j = i + (long)sgn * st[d];
1592 if (!(mk(j) > 0.5))
1593 continue;
1594 const double of = (d == 0) ? ((sgn < 0) ? ox(i) : ox(i + sx))
1595 : (d == 1) ? ((sgn < 0) ? oy(i) : oy(i + sy))
1596 : ((sgn < 0) ? oz(i) : oz(i + sz));
1597 const double nvec[3] = {gnx(j), gny(j), gnz(j)};
1598 const double th = vof::pcGfmThetaKAniso(gph(j), nvec, d, (double)sgn,
1599 curvDist ? kapF(j) : 0.0, thMin, thMax, gme);
1600 const long jb = i - (long)sgn * st[d];
1601 const double ofB = (d == 0) ? ((sgn < 0) ? ox(i + sx) : ox(i))
1602 : (d == 1) ? ((sgn < 0) ? oy(i + sy) : oy(i))
1603 : ((sgn < 0) ? oz(i + sz) : oz(i));
1604 const bool behind = !(mk(jb) > 0.5) && ofB > 0.0;
1605 const vof::PcGfmRow row = vof::pcGfmRow(th, behind, gfmOrder);
1606 a2 += kd * of * row.aGamma * (tg(j) - T(i));
1607 if (behind && row.aBehind != 1.0) {
1608 const double kb = useK ? 0.5 * (kc(i) + kc(jb)) : Dc;
1609 a3 += (row.aBehind - 1.0) * kb * ofB * (T(jb) - T(i));
1610 }
1611 }
1612 },
1613 hOpen, q, qb);
1614 Kokkos::fence();
1615 pcBudget_.hOpenNew = hOpen;
1616 pcBudget_.qGfm = q;
1617 pcBudget_.qBehind = qb;
1618 pcBudget_.calls += 1;
1619}
1620
1621template <class Grid>
1623 if (!pcEnabled_ && !pcHasUser_)
1624 return;
1625 const C3 e = e_;
1626 const bool hasPc = pcEnabled_, hasUser = pcHasUser_;
1627 CCConst sp = hasPc ? CCConst(pcSrc_) : CCConst(div);
1628 CCConst su = hasUser ? CCConst(pcUser_) : CCConst(div);
1629 Kokkos::parallel_for(
1630 "peclet::flow::pc_div_source",
1631 MDRange3<CCExec>(CCExec(), {G, G, G}, {e.x - G, e.y - G, e.z - G}),
1632 KOKKOS_LAMBDA(int x, int y, int z) {
1633 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
1634 double s = 0.0;
1635 if (hasPc)
1636 s += sp(i);
1637 if (hasUser)
1638 s += su(i);
1639 div(i) -= s;
1640 });
1641}
1642
1643template <class Grid>
1645 return Kokkos::fmax(pcInterfaceEps_, vofAdv_.wispEps);
1646}
1647
1648template <class Grid>
1650 return Kokkos::fmax(pcPureEps_, vofAdv_.wispEps);
1651}
1652
1653template <class Grid>
1654void Solver<Grid>::requirePhaseChange(const char* who) const {
1655 if (!pcEnabled_)
1656 throw std::runtime_error(std::string(who) +
1657 ": phase change is not enabled (call "
1658 "enable_phase_change first)");
1659}
1660
1661template <class Grid>
1662double Solver<Grid>::phaseTick() {
1663 Kokkos::fence();
1664 return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
1665}
1666
1667} // namespace peclet::flow
1668
1669#endif // PECLET_FLOW_FLOW_IBM_PHASE_CHANGE_HPP
void vofExchangeScalar(CCField f)
The colour block's ghost policy WITHOUT the colour-specific rules (no solid-band fill,...
void setPhaseChangeGfmOrder(int order)
WO-P3g item 2: the order of the one-sided (ghost-fluid) Dirichlet row.
void setPhaseChangeEnergyMuscl(bool on)
MinMod-limited donor reconstruction of the face temperature in the consistent energy flux (vof/energy...
static constexpr int kVofG
void pcZeroDomainGhosts(CCField f)
Zero the two ghost layers on every NON-periodic domain face this rank owns.
void pcCarryDeposit(ScalarField &sc)
WO-P3f — the enthalpy the per-cell Dirichlet overwrite destroys, returned to the phase it came from.
void scatterInner(CCField fld, const std::vector< double > &in)
void phaseChangeStep()
The in-step driver: everything applyPhaseChange does, at the head of step().
ScalarField & scalarField(const std::string &name)
PhaseChangeDiagnostics phaseChangeDiagnostics()
void fillGhosts(CCField f)
void setMassFluxUniform(double v)
Prescribe a UNIFORM mass flux (P0), in mass per area per time.
void setPhaseChangeFitCurvature(double kappa)
WO-P3f INSTRUMENT: prescribe the interface curvature kappa = div(n) the one-sided fits use to correct...
void fillPropGhosts(CCField f, bool edges=false)
void pcScatterSource()
(2) Deposit each interfacial cell's source into its chosen pure-gas cell, as a GATHER (each receiving...
void pcApplyDivergenceSource(CCField div)
Subtract the phase-change (and any prescribed) divergence source from div_ so the deflated pressure s...
void setPhaseChangeMdotOperator(bool on)
WO-P3g item 1: take mdot from the energy operator's own interfacial flux instead of the one-sided lea...
double phaseChangeCarryDeposited() const
The carry ledger, physical: an energy with the consistent transport on, and a temperature times a vol...
CCField addField(const std::string &name)
void pcRegress(double dt)
(3) The regression: two Jacobi passes over the exchanged per-cell data, so the clip deficit is redist...
vof::VofInterfaceArea::Stats pcAreaCascadeCompute()
Run the cascade area driver on the (already bridged) g = 3 colour block.
SField pcAreaCascadeField() const
The area field the last pcAreaCascadeCompute filled (either driver).
double pcEffPureEps() const
…and the mirror statement: a cell the ADVECTOR treats as a pure phase is a pure phase here too,...
void pcUpdateEnergyProps()
Refresh k(C) and (rho c_p)(C) on the G=2 block from the current colour.
void requireScalesPinned(const char *who, bool needRho) const
Under an armed extent, refuse a call that writes a dimensional value into a FIELD before the referenc...
double phaseChangeQOperator() const
The operator's interfacial heat (power), physical.
void copyInner(CCField dst, C3 de, int dg, CCConst src, C3 se, int sg)
void setDivergenceSink(const std::vector< double > &w)
WO-P23: an AUTO-BALANCED sink region for the phase-change divergence source.
void setPhaseChangeThermal(const std::string &tname, double Tsat, double kg, double kl, double Rint)
P1: compute mdot each step from the registered scalar tname by the one-sided pure-cell weighted least...
void pcBudgetPost(ScalarField &sc)
WO-P3f, part 2: the state AFTER the energy solve, the heat the plane-anchored rows actually delivered...
void setPhaseChangePlaneDirichlet(bool on)
The PLANE-ANCHORED (ghost-fluid) Dirichlet condition, ON by default.
double vofInterfaceArea()
The summed interfacial area over the inner region, PHYSICAL (globally reduced under MPI) — the E7 gal...
void setPhaseChangeQuadraticFit(bool on)
The QUADRATIC one-sided gradient fit (T - T_G = G phi + Q phi^2) instead of the linear one.
double pcBandDivergence()
The BAND-EXTENDED LIQUID VELOCITY of VOF_PLAN §9 item 3, as a MEASUREMENT rather than a switch.
void pcBudgetPre(ScalarField &sc)
WO-P3f, the energy-budget instrument, part 1: the state BEFORE the energy solve, and the class-change...
void applyPhaseChange(double dtPhysArg)
Kinematic entry point (the P0a/P1 driver): build mdot/A_G/n from the current colour and temperature,...
void setPhaseChangeCarryConserve(bool on)
WO-P3f: make the per-cell Dirichlet overwrite ENTHALPY-CONSERVING.
static constexpr int G
Definition flow_ibm.hpp:80
void pcBuildInterface()
(1) The interface build: for every inner interfacial cell reconstruct the PLIC plane from the canonic...
double pcLedgerEnergyToPhys() const
The factor from an internal phase-change ledger entry (a per-cell sum of rcp' dT) to the caller's uni...
void enablePhaseChange(double rhoG, double rhoL, double hlv)
Turn on phase change.
bool hasScalar(const std::string &name) const
void pcBuildInDomain()
WO-P3g — 1 on every cell that CARRIES A ROW in the energy solve, 0 otherwise.
void pcCarryApply(ScalarField &sc)
WO-P3f: consume pcCarrySrc_ into the energy solve's time base.
void pcUpdateCurvature()
The per-cell Dirichlet mask of the energy scalar: T = T_sat + mdot R_int in every interfacial cell,...
double pcEffInterfaceEps() const
The colour tolerance that decides which cells are INTERFACIAL, and it has to be at least the colour a...
void setVofWispEps(double eps)
WO-R2 item 4 — the wisp threshold on the advector's mixed-cell predicate and on the interface Courant...
void setPhaseChangeCurvatureDistance(bool on)
WO-P3g item 3: measure the GFM row's theta and the one-sided fits' sample distances to the CURVED int...
void setPhaseChangeEnergyOrder(int order)
WO-P3g — the SECOND-ORDER interfacial energy operator, as one package.
void requirePhaseChange(const char *who) const
void setMassFlux(const std::vector< double > &v)
Prescribe a per-cell mass flux (P0), x-fastest over the inner region; units and call-order rule as se...
void setPhaseChangeEnergy(double rcpGas, double rcpLiquid)
Turn on the CONSISTENT energy transport (VOF_PLAN §9 item 6) for the scalar set_phase_change_thermal ...
void scalarDirichletMask(const std::string &name)
Allocate (idempotently) the per-cell Dirichlet mask + value of a registered scalar.
void pcRefreshUnits()
Re-derive the INTERNAL phase-change constants (pcRhoG_ ... pcRcpL_) from the caller's physical ones (...
void setPhaseChangeBudget(bool on)
WO-P3f: turn the ENERGY BUDGET instrument on.
PhaseChangeBudget phaseChangeBudgetValues() const
The budget, physical: energies and powers with the consistent transport on, temperature x volume (and...
void setDivergenceSource(const std::vector< double > &v)
A PRESCRIBED extra divergence source (1/time, physical; set_dt must precede it under an extent,...
void setPhaseChangeDepositFallback(bool on)
WO-P3f open item 6 / WO-P3g — the divergence source's 5^3 fallback target, as a setter (it was only r...
bool isCutcellScalar(const std::string &name) const
void setPhaseChangeArea(int mode)
WO-P3c — which geometry the interfacial AREA comes from.
Stats compute(SField c)
Compute the curvature over the inner region from a colour field on the SAME extended block.
void setPhaseRcp(double g, double l)
void extrema(double &lo, double &hi) const
Min/max of the transported temperature over the inner region (a boundedness read-out).
std::function< void(SField)> exchange
Ghost policy for the temperature — the halo/periodic exchange plus the block's non-periodic zero-grad...
void init(const WyAdvector &w, double rcpG, double rcpL)
bool energyMuscl
MinMod-limited donor reconstruction of the face temperature, instead of the DEFAULT plain donor-cell ...
double interfaceEps
The interfacial predicate's wisp threshold.
void init(int nx, int ny, int nz, int ghost)
double pureEps
Purity tolerance of the height-function column walk – the same contract as VofCurvature::pureEps: set...
void init(int nx, int ny, int nz, int ghost)
Stats compute(SField c, int mode)
mode is an InterfaceAreaMode in [kAreaMcColour, kAreaMcPlicSplit].
SField colour() const
Colour field on the extended block (x-fastest).
double wispEps
WO-R2 item 4 — the wisp tolerance on the mixed-cell predicate (wyIsMixed(c, wispEps)),...
double pcGradSolve2(const PcGradFit &f)
The QUADRATIC one-sided fit (WO-P23): T - T_G = G phi + Q phi^2, returning G.
double pcMassFlux(double kg, double gradG, double kl, double gradL, double hlv)
mdot = (k_g dT_g/dn - k_l dT_l/dn)/h_lv with n the PLIC normal (liquid -> gas).
void pcGradAdd(PcGradFit &f, double w, double phi, double T, double Tg)
Add one pure-phase sample at normal distance phi with temperature T.
double pcCentreDistance(double mx, double my, double mz, double alpha)
Signed distance (in cells) from the CELL CENTRE to the PLIC plane, measured along n and POSITIVE ON T...
double pcRegressVolume(double mdot, double area, double dt, double rhoL)
Liquid volume (fraction of a cell) removed from an interfacial cell in one step: dV = mdot A_G dt / r...
bool pcPushWeights(const double n[3], double sgn, const bool avail[3], int step[3], double w[3])
The clip-and-redistribute allocation: which face neighbours a clipped cell's residue is pushed into,...
double pcDivSource(double mdot, double area, double rhoG, double rhoL)
The volumetric divergence source of one interfacial cell (1/s, cell volume = 1): S = mdot A_G (1/rho_...
PcGfmRow pcGfmRow(double theta, bool behind, int order)
double pcCurvedDistance(double phi, double dx, double dy, double dz, const double n[3], double kappa)
WO-P3f — the sample's distance to the CURVED interface, not to the tangent plane.
double pcCarriedValue(double Tg, double grad, double phic)
The plane-anchored (ghost-fluid) interfacial Dirichlet condition (WO-P23, the first item).
bool pcIsInterfacial(double c, double eps)
Is this a cell the regression acts on? The PLIC contract of advect_wy.hpp (wyIsMixed) with an explici...
double pcInterfaceTemperature(double Tsat, double mdot, double Rint)
Interfacial temperature under the Schrage-derived interfacial heat-transfer resistance (Bureš & Sato)...
double pcOffsetDistance(double phic, const double n[3], double dx, double dy, double dz)
Signed distance from the plane of the neighbour at INTEGER cell offset d, given the centre distance p...
double pcGfmThetaKAniso(double phiC, const double n[3], int d, double s, double kappa, double thetaMin, double thetaMax, const VofMetric &g)
The same on an ANISOTROPIC cell (Phase 3, flow/doc/anisotropic_vof.md §7, V5.3).
double pcOperatorMassFlux(double q, double coefSum, double area, double hlv, double Rint)
WO-P3g item 1 — mdot from the ENERGY OPERATOR's own interfacial flux.
double pcPhaseMix(double vg, double vl, double c)
Linear-in-C phase property (k, rho c_p), written so that C = 0 and C = 1 return the endpoint EXACTLY ...
@ kAreaMcColour
the C = 1/2 level set, whole triangles to the centroid's cell
@ kAreaPlic
rung P0/P1: plicArea on the MYC normal. The old numbers.
double plicAreaMetric(double mx, double my, double mz, double alpha, const VofMetric &g)
The PHYSICAL area of the PLIC polygon of an ANISOTROPIC cell, in hRef^2 (flow/doc/anisotropic_vof....
double pcGradSolve(const PcGradFit &f)
The fitted normal derivative dT/dn (0 if the side carried no usable sample).
double pcGradWeight(double dx, double dy, double dz, const double n[3])
Malan's collinearity weight for a sample at cell offset d: w = xi / |d| with xi = (d....
Kokkos::View< double *, CCMem > CCField
Kokkos::DefaultExecutionSpace CCExec
Kokkos::View< const double *, CCMem > CCConst
Kokkos::View< double *, SMem > SField
WO-P3f — the ENERGY BUDGET of the phase-change energy solve.
long nLeaveGas
interfacial -> gas (the bubble swallowing a cell)
double hMasked
the same over MASKED (interfacial) cells
double hOpenNew
the same, after the solve
double qGfm
heat INTO the unmasked set across the plane-anchored rows (W)
double dEoverwrite
sum rcp (dval - T) over masked cells: what the Dirichlet rows inject when they overwrite the transpor...
long nEnterLiquid
liquid -> interfacial transitions this step
double qBehind
WO-P3g: the second-order row's one-sided rescaling of the band BEHIND each interfacial face,...
long calls
how many energy solves have been instrumented
double eLeave
... and of the cells that LEFT it (carrying pcCarriedValue)
double dEoverwriteNew
the part of it on cells that were NOT masked last step
double eEnter
sum rcp (T - T_sat) of the cells that JOINED the masked set
double hLiquid
sum rcp (T - T_sat) over PURE LIQUID cells, before the solve
long nMasked
size of the masked set
double hOpen
sum rcp (T - T_sat) over UNMASKED cells, before the solve
long nLeaveLiquid
interfacial -> liquid
long sourceCells
cells that RECEIVED a deposit
long deficitCells
cells that clipped at C = 0
double redistributed
|clip deficit| pushed into neighbours this step
long areaNone
WO-P3c: cells with NO cascade geometry (kept their MYC PLIC area)
double qOperator
WO-P3g: the heat (W) the operator's Dirichlet rows draw across the interface, i.e.
double mdotFit
WO-P3g: the AREA-WEIGHTED one-sided-fit mdot, kept as the diagnostic that item 1 replaced (0 unless t...
double areaOrphan
WO-P3d: area the JOINED sheet booked to cells that are not interfacial, i.e.
long areaPv
WO-P3c: cells whose area came from the PV paraboloid (tier 3)
long fallbackCells
interfacial cells whose +n walk found no pure gas cell
long areaHf
WO-P3c: cells whose area came from a height function (tiers 1/2a)
double sourceSum
sum of the deposited divergence source over inner cells (1/s)
double area
sum of the PLIC polygon areas over interfacial cells (h^2)
double removedVolume
sum of dV actually subtracted this step (+ = evaporated)
double unresolved
clip residue no neighbour could absorb (pushed anyway)
double qOrphan
... the part of it on interfacial cells with NO area, which the regression drops: the ONE non-conserv...
long excessCells
cells that clipped at C = 1
vof::VofMetric vofMetric() const
Definition flow_ibm.hpp:189
bool physical
an extent was given (the scales are armed)
Definition flow_ibm.hpp:137
double vol
V' = h_x' h_y' h_z' (cell volume in hRef^3)
Definition flow_ibm.hpp:180
double energyToPhys() const
An internal energy (rho c_p T summed over cells of volume hRef^3) to joules: the internal energy unit...
Definition flow_ibm.hpp:222
WO-P3g item 2 — the Gibou–Fedkiw second-order one-sided (ghost-fluid) row.
Accumulator for dT/dn fitted through the interface value: T - T_G = G * phi, so G = sum_j w_j phi_j (...
Per-call census over the inner region (LOCAL; a distributed caller sums it).
double area
the sum this rung exists to get right
static constexpr double U