ERF
Energy Research and Forecasting: An Atmospheric Modeling Code
ERF_FireAtmCoupling.H
Go to the documentation of this file.
1 // One-way fire-to-atmosphere coupling: distribute fire heat flux through
2 // the atmospheric column and inject into cc_source.
3 //
4 // This mirrors the WRF-Fire approach in module_fr_sfire_atm.F
5 // (subroutine fire_tendency), which:
6 // 1. Builds a vertical flux profile with exponential decay:
7 // hfx(z) = (Q_sfc / Cp) * exp(-z / alfg)
8 // where z is the height above the lowest cell centre of the column (half
9 // a cell above the terrain: the profile's datum is that centre, so the
10 // first cell takes the share 1 - e^{-dz/a}, 0.105 for dz = 5 m and
11 // a = 45 m, and nothing is lost below it) and alfg is the e-folding
12 // scale [m].
13 // 2. Converts the flux profile to a theta tendency via flux divergence:
14 // d(RhoTheta)/dt = -d(hfx)/dz
15 // with dz the thickness of the cell (face to face), so the column
16 // receives the flux whatever the vertical spacing; erf.fire.
17 // heat_tendency_density multiplies by rho and erf.fire.heat_tendency_exner
18 // divides by the Exner function (see below).
19 // 3. Does the same for latent heat (grnqfx -> rqvfrten):
20 // qfx(z) = (Q_lat / Lv) * exp(-z / alfg)
21 // d(RhoQ1)/dt = -d(qfx)/dz
22 //
23 // The e-folding scale alfg controls how deep into the atmosphere the fire
24 // heating penetrates. With alfg = 45 m, the flux has decayed to 37% at 45 m,
25 // 5% at 135 m, and 1% at 225 m above the surface.
26 //
27 // Reference:
28 // Mandel, J., Beezley, J. D., Coen, J. L. and Kim, M. (2009). Data assimilation
29 // for wildland fires. IEEE Control Systems Magazine, 29(3), 47-65; and
30 // Mandel, J., Beezley, J. D. and Kochanski, A. K. (2011). Coupled
31 // atmosphere-wildland fire modeling with WRF 3.3 and SFIRE 2011. Geoscientific
32 // Model Development, 4, 591-610.
33 // WRF-SFIRE source: phys/module_fr_sfire_atm.F, subroutine fire_tendency.
34 //
35 // Coupling time lag:
36 // ERF uses one-step explicit lag: the fire heat flux computed at the end of
37 // step n is held in Q_atm_prev and injected into cc_source during step n+1,
38 // from the slow right-hand side of every RK stage (ERF_TI_slow_rhs_pre.H),
39 // after make_sources has rebuilt cc_source for that stage.
40 // This is consistent with WRF-Fire's explicit coupling approach.
41 //
42 // RK-stage safety:
43 // apply_fire_tendency_to_cc_source is invoked once per RK stage of the
44 // slow-RHS integrator. Because the lagged Q_atm_prev is fixed for the whole
45 // timestep, the fire tendency it produces must also be identical at every
46 // RK stage. With erf.fire.source_mode = overwrite (the default) we OVERWRITE
47 // cc_source[RhoTheta_comp] (and RhoQ1_comp when latent heat is on): the
48 // values are cleared to zero at the top of the routine and then set via '='
49 // inside the kernel, which discards every other source make_sources put
50 // there (radiative heating, Rayleigh damping, nudging). source_mode = add
51 // adds to the rebuilt source instead. Accumulating with '+=' across RK
52 // stages without the per-stage rebuild would triple the injected heat.
53 //
54 // Latent heat:
55 // WRF-Fire computes grnqfx (ground latent heat flux) alongside grnhfx and
56 // passes both through fire_tendency. Here the fire engine computes the
57 // latent flux (from the fuel moisture and the water of combustion of the fuel
58 // it burns) and hands it over in FireFluxes::latent [W/m2]; the coupling
59 // distributes it with the same exponential profile.
60 //
61 // Assumptions:
62 // - The tendency goes into the source of the level the fire grid refines
63 // (erf.fire.anchor_level, the finest level by default); coarser levels see
64 // the result through average-down with erf.coupling_type = TwoWay (the
65 // default), not with OneWay; finer levels not at all.
66 // - The engine's sensible and latent fluxes (FireFluxes) are on the fire grid
67 // (C*nx x C*ny x 1 over the fire region).
68 // - The coarsening from fire grid to atmospheric grid is the area average of
69 // average_down(), with the region's lower corner added (FireGrid::atm_lo).
70 // - z_phys_cc is the cell-centre terrain-following height MultiFab [m].
71 // - The atmospheric BoxArray is not split in z (max_grid_size_z = nz enforced).
72 // - Latent heat injection into RhoQ1_comp is skipped when moisture is inactive.
73 // - fire_atm_feedback multiplier (equivalent to WRF-Fire's fire_atm_feedback)
74 // scales both sensible and latent flux. Set to 0 for one-way coupling only
75 // (fire responds to wind but atmosphere does not respond to fire heat).
76 // - The RhoTheta / RhoQ1 slots of cc_source are OWNED by the fire coupling
77 // for the duration of a timestep. If any other physics writes into those
78 // same slots per stage, this routine must be moved out of the per-stage
79 // slow-RHS path and called once before mri_integrator.advance() instead.
80 
81 #ifndef ERF_FIRE_ATM_COUPLING_H
82 #define ERF_FIRE_ATM_COUPLING_H
83 
84 #include <AMReX_MultiFab.H>
85 #include <AMReX_Geometry.H>
86 #include <AMReX_Box.H>
87 #include <AMReX_MFIter.H>
88 #include <AMReX_MultiFabUtil.H>
89 #include <cmath>
90 #include <limits>
91 #include <ERF_Constants.H>
92 #include <ERF_IndexDefines.H>
93 #include <ERF_EOS.H>
94 #include <ERF_FireGrid.H>
95 
96 // Default e-folding height for ground fire heat distribution [m].
97 // WRF-Fire default: alfg ~ 45-75 m. At z = alfg: flux decays to 1/e ~ 37%.
98 // The crown-fire release is added to the same surface flux and injected with
99 // this one profile; there is no separate canopy e-folding height.
101 
102 // Coarsen a fire-grid field (C*nx x C*ny x 1) onto the atmospheric columns of
103 // the level the fire grid refines (nx x ny x 1) by area averaging.
104 //
105 // Q_atm_out lives on the k = 0 slabs of that level's boxes, in their order and
106 // with their owners; Q_fire on the fire grid, whose index space starts at the
107 // region's corner. On level 0 the corner is the origin and this is
108 // amrex::average_down() with ratio (C, C, 1), as it always was. Elsewhere the
109 // same average is taken with the corner added: fire cells (i - lo_x) C + r,
110 // (j - lo_y) C + q for r, q in [0, C) average into atmospheric cell (i, j).
111 // average_down() itself would place the result at the fire grid's own indices.
113  amrex::MultiFab& Q_atm_out,
114  const amrex::MultiFab& Q_fire,
115  const amrex::Geometry& geom_atm,
116  const FireGrid& fg)
117 {
118  const int C = fg.C;
119  if (fg.atm_lo == amrex::IntVect::TheZeroVector()) {
120  amrex::average_down(Q_fire, Q_atm_out, fg.geom, geom_atm, 0, Q_fire.nComp(),
121  amrex::IntVect(C, C, 1));
122  return;
123  }
124 
126  Q_fire.boxArray().size() == Q_atm_out.boxArray().size() &&
127  Q_fire.DistributionMap() == Q_atm_out.DistributionMap(),
128  "[FIRE] coarsen_fire_flux_to_atm: the fire grid and the atmospheric slabs must pair box for box");
129  AMREX_ALWAYS_ASSERT(Q_atm_out.nComp() >= Q_fire.nComp());
130 
131  const int lo_x = fg.atm_lo[0];
132  const int lo_y = fg.atm_lo[1];
133  const int ncomp = Q_fire.nComp();
134  const amrex::Real volfrac = amrex::Real(1.0) / amrex::Real(C * C);
135  for (amrex::MFIter mfi(Q_atm_out); mfi.isValid(); ++mfi) {
136  const amrex::Box& bx = mfi.validbox();
137  auto crse = Q_atm_out.array(mfi);
138  auto fine = Q_fire.const_array(mfi);
139  amrex::ParallelFor(bx, ncomp, [=] AMREX_GPU_DEVICE (int i, int j, int k, int n) noexcept
140  {
141  const int ii = (i - lo_x) * C;
142  const int jj = (j - lo_y) * C;
143  amrex::Real c = amrex::Real(0.0);
144  for (int q = 0; q < C; ++q) {
145  for (int r = 0; r < C; ++r) {
146  c += fine(ii + r, jj + q, 0, n);
147  }
148  }
149  crse(i, j, k, n) = volfrac * c;
150  });
151  }
152 }
153 
154 // Height of the lower (upper = 0) or upper (upper = 1) face of cell (i, j, k):
155 // the mean of its four nodes where the nodal heights exist, half a cell below
156 // (above) the centre on a uniform grid without them.
157 AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE
158 amrex::Real fire_cell_face (amrex::Array4<amrex::Real const> const& z_nd,
159  amrex::Array4<amrex::Real const> const& z_cc,
160  bool has_nd, amrex::Real dz_avg,
161  int i, int j, int k, int upper) noexcept
162 {
163  if (has_nd) {
164  const int kf = k + upper;
165  return amrex::Real(0.25) * (z_nd(i, j, kf) + z_nd(i+1, j, kf)
166  + z_nd(i, j+1, kf) + z_nd(i+1, j+1, kf));
167  }
168  return z_cc(i, j, k) + (upper ? amrex::Real(0.5) : amrex::Real(-0.5)) * dz_avg;
169 }
170 
171 // Thickness of a cell from its faces. Only a degenerate (zero or inverted)
172 // cell is floored, at 1e-6 of the mean spacing, so that the division stays
173 // finite; a thin first cell on a stretched grid keeps its own thickness (a
174 // floor of 0.1 dz_avg put 59 % of the heat into a column with a 1 m first
175 // cell stretched by 1.1 over 64 cells, at the default alfg of 45 m).
176 AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE
178 {
179  return amrex::max(z_hi - z_lo, amrex::Real(1.0e-6) * dz_avg);
180 }
181 
182 // Distribute fire surface heat flux through the atmospheric column and write
183 // the resulting tendency into cc_source.
184 //
185 // Behaviour: OVERWRITES cc_source[RhoTheta_comp] and (when latent heat is on)
186 // cc_source[RhoQ1_comp]. See file-level "RK-stage safety" note above.
187 //
188 // Optional fire_debug argument prints per-call tendency diagnostics to stdout:
189 // [FIRE COUPLING] RhoTheta tendency sum = ... max = ... [kg K m^-3 s^-1]
190 // expected_surface_max = feedback * Q_max / Cp * (1 - exp(-dz/alfg)) / dz
191 // (the default heat_tendency_density = false form, d(rho theta)/dt =
192 // -(1/Cp) dQ/dz, for the lowest cell on a uniform grid; with the density
193 // factor the tendency carries an extra rho, with the Exner factor 1/Pi)
194 //
195 // With Q_max=14330 W/m2, Cp=Cp_d (1004.5 J/(kg K)), alfg=45 m, dz=5 m:
196 // expected_surface_max ~ 0.30 kg K m^-3 s^-1
197 //
198 // z_phys_nd, the nodal heights of the level, gives each cell's faces: its
199 // thickness divides the tendency, and the open-fraction placement measures the
200 // roof height from the ground. ERF always passes it; with nullptr (callers
201 // without nodal heights, such as the unit tests) the faces are half a uniform
202 // cell from the centres, which is right only on a uniform grid.
204  amrex::MultiFab& cc_source,
205  const amrex::MultiFab& Q_atm,
206  const amrex::MultiFab* Q_lat_atm,
207  const amrex::MultiFab& z_phys_cc,
208  const amrex::MultiFab* z_phys_nd,
209  const amrex::MultiFab& S_old,
210  const amrex::Geometry& geom_atm,
211  amrex::Real alfg,
212  amrex::Real feedback,
213  bool has_moisture,
214  bool fire_debug = false,
215  bool additive = false,
216  const amrex::MultiFab* open_frac_atm = nullptr,
217  const amrex::MultiFab* roof_h_atm = nullptr,
218  bool use_open_weights = false,
219  bool density_factor = false,
220  bool exner_factor = false,
221  bool bottom_cell = false)
222 {
223  using namespace amrex;
224 
225  if (alfg <= Real(0.0)) { return; }
226  if (feedback <= Real(0.0)) { return; }
227 
228  // Source conversion must use the same dry-air heat capacity as ERF's EOS.
229  constexpr Real Cp_fire = Cp_d;
230  // and the latent heat ERF uses (an engine converting a vapour mass flux to
231  // its latent flux multiplies by this same L_v, so the water round-trips exactly)
232  constexpr Real L_V = L_v;
233  // Exner function of the cell from its rho theta (erf.fire.heat_tendency_exner):
234  // heating q [W/m3] gives d(theta)/dt = q / (rho cp Pi), so the rho theta
235  // tendency carries 1/Pi; without it the enthalpy injected is Pi times the
236  // flux, 4.5 % short at 850 hPa. WRF-SFIRE omits the factor; it is on by
237  // default here.
238  const Real rdOcp = R_d / Cp_d;
239 
240  const Box& domain = geom_atm.Domain();
241  int klo = domain.smallEnd(2);
242  int khi = domain.bigEnd(2);
243  const auto& dx = geom_atm.CellSize();
244  Real dz_avg = dx[2];
245 
246  const bool has_latent_global = (Q_lat_atm != nullptr) && has_moisture;
247  // The open fraction and roof height are handed in whenever the engine
248  // reports them; the weights only use them when asked.
249  const bool have_open = (open_frac_atm != nullptr) && (roof_h_atm != nullptr);
250  const bool use_open = have_open && use_open_weights;
251 
252  // Debug only: the fire tendency alone, times the cell thickness (in additive mode
253  // cc_source also holds the other sources), for the energy check below.
254  MultiFab tend_dbg;
255  if (fire_debug) {
256  tend_dbg.define(cc_source.boxArray(), cc_source.DistributionMap(), 1, 0);
257  tend_dbg.setVal(0.0);
258  }
259 
260  // Legacy (overwrite) mode: zero the components we own before writing them,
261  // so any state left over from a previous stage does not leak through. In
262  // additive mode make_sources has just rebuilt the source for this stage,
263  // so the fire tendency is accumulated once per stage on top of it.
264  if (!additive) {
265  cc_source.setVal(0.0, RhoTheta_comp, 1, cc_source.nGrowVect());
266  if (has_latent_global) {
267  cc_source.setVal(0.0, RhoQ1_comp, 1, cc_source.nGrowVect());
268  }
269  }
270 
271  // Column normalisation of the open-fraction weights: one k = klo slab per
272  // box, filled once per column (the boxes hold whole columns, which the
273  // fire prerequisites assert), before the tiled loop that reads it.
274  // The engine's open fraction and roof height are read with the level's
275  // MFIter below, so they must hold one k = klo slab per box of the level,
276  // in its order and on its DistributionMapping (only a debug build checks
277  // that inside AMReX).
278  if (have_open) {
279  const BoxArray& cba = cc_source.boxArray();
280  bool same_layout = open_frac_atm->DistributionMap() == cc_source.DistributionMap() &&
281  roof_h_atm->DistributionMap() == cc_source.DistributionMap() &&
282  open_frac_atm->boxArray().size() == cba.size() &&
283  roof_h_atm->boxArray().size() == cba.size();
284  for (int ib = 0; same_layout && ib < cba.size(); ++ib) {
285  const Box slab = makeSlab(cba[ib], 2, klo);
286  same_layout = (open_frac_atm->boxArray()[ib] == slab) && (roof_h_atm->boxArray()[ib] == slab);
287  }
289  "[FIRE] the engine's heat_open_fraction_atm and roof_height_atm must hold one k = 0 slab "
290  "per box of the fire grid's level, in its box order and on its DistributionMapping");
291  }
292  MultiFab norm_mf;
293  if (use_open) {
294  norm_mf.define(open_frac_atm->boxArray(), cc_source.DistributionMap(), 1, 0);
295  for (MFIter mfi(norm_mf); mfi.isValid(); ++mfi) {
296  auto const& norm_a = norm_mf.array(mfi);
297  auto const& open_arr = open_frac_atm->const_array(mfi);
298  auto const& roof_arr = roof_h_atm->const_array(mfi);
299  auto const& z_cc_a = z_phys_cc.const_array(mfi);
300  const bool has_nd = (z_phys_nd != nullptr);
301  Array4<Real const> z_nd_a;
302  if (has_nd) { z_nd_a = z_phys_nd->const_array(mfi); }
303  amrex::ParallelFor(mfi.validbox(), [=] AMREX_GPU_DEVICE (int i, int j, int /*kslab*/) noexcept {
304  const Real f_open = open_arr(i, j, 0);
305  const Real h_roof = roof_arr(i, j, 0); // above the ground
306  const Real z_sfc = z_cc_a(i, j, klo);
307  const Real ground = fire_cell_face(z_nd_a, z_cc_a, has_nd, dz_avg, i, j, klo, 0);
308  Real norm = Real(0.0);
309  for (int kk = klo; kk <= khi; ++kk) {
310  const Real zz = z_cc_a(i, j, kk) - z_sfc;
311  const Real zzp1 = (kk < khi) ? (z_cc_a(i, j, kk + 1) - z_sfc) : (zz + dz_avg);
312  Real s = Real(1.0);
313  if (h_roof > Real(0.0)) {
314  const Real zlo2 = fire_cell_face(z_nd_a, z_cc_a, has_nd, dz_avg, i, j, kk, 0) - ground;
315  const Real zhi2 = fire_cell_face(z_nd_a, z_cc_a, has_nd, dz_avg, i, j, kk, 1) - ground;
316  const Real dzz = fire_cell_thickness(zlo2, zhi2, dz_avg);
317  if (zhi2 <= h_roof) { s = f_open; }
318  else if (zlo2 < h_roof) { s = f_open + (Real(1.0) - f_open) * (zhi2 - h_roof) / dzz; }
319  }
320  norm += (std::exp(-zz / alfg) - std::exp(-zzp1 / alfg)) * s;
321  }
322  norm_a(i, j, klo) = norm;
323  });
324  }
325  }
326 
327  for (MFIter mfi(cc_source, TilingIfNotGPU()); mfi.isValid(); ++mfi) {
328  auto const& cc_src_a = cc_source.array(mfi);
329  auto const& q_atm_a = Q_atm.array(mfi);
330  auto const& z_cc_a = z_phys_cc.array(mfi);
331  auto const& s_old_a = S_old.array(mfi);
332  // Faces of cell k: the mean of its four lower (upper) nodes where the
333  // nodal heights exist, half a cell below (above) the centre otherwise
334  const bool has_nd = (z_phys_nd != nullptr);
335  Array4<Real const> z_nd_a;
336  if (has_nd) { z_nd_a = z_phys_nd->const_array(mfi); }
337 
338  const Box& bx = mfi.tilebox();
339 
340  const bool has_latent = has_latent_global;
341  Array4<Real const> q_lat_arr;
342  if (has_latent) {
343  q_lat_arr = Q_lat_atm->const_array(mfi);
344  }
345  Array4<Real const> open_arr, roof_arr;
346  if (use_open) {
347  open_arr = open_frac_atm->const_array(mfi);
348  roof_arr = roof_h_atm->const_array(mfi);
349  }
350  Array4<Real> dbg_arr;
351  if (fire_debug) { dbg_arr = tend_dbg.array(mfi); }
352 
353  // Column normalisation of the open-fraction weights, computed once per
354  // column above the tile loop.
355  Array4<Real const> norm_arr;
356  if (use_open) { norm_arr = norm_mf.const_array(mfi); }
357 
358  amrex::ParallelFor(bx, [=] AMREX_GPU_DEVICE(const IntVect& iv) {
359  int i = iv[0];
360  int j = iv[1];
361  int k = iv[2];
362 
363  Real q_sfc_sensible = feedback * q_atm_a(i, j, 0);
364  Real q_sfc_latent = Real(0.0);
365  if (has_latent) {
366  q_sfc_latent = feedback * q_lat_arr(i, j, 0);
367  }
368 
369  Real z_sfc = z_cc_a(i, j, klo);
370  Real z_k = z_cc_a(i, j, k) - z_sfc;
371  Real exp_k = std::exp(-z_k / alfg);
372 
373  Real z_kp1 = (k < khi) ? (z_cc_a(i, j, k + 1) - z_sfc) : (z_k + dz_avg);
374  Real exp_kp1 = std::exp(-z_kp1 / alfg);
375 
376  // The cell's own thickness, face to face: the column then receives
377  // sum_k w_k of the flux, 1 - exp(-z_top/alfg), on any vertical
378  // spacing (the centre-to-centre spacing used before put a share
379  // dz_cell / dz_cc into each cell, 2 / (1 + r) of the flux on a grid
380  // stretched by r)
381  const Real z_lo = fire_cell_face(z_nd_a, z_cc_a, has_nd, dz_avg, i, j, k, 0);
382  const Real z_hi = fire_cell_face(z_nd_a, z_cc_a, has_nd, dz_avg, i, j, k, 1);
383  const Real dz = fire_cell_thickness(z_lo, z_hi, dz_avg);
384 
385  Real rho = s_old_a(i, j, k, Rho_comp);
386  if (rho <= Real(0.0)) { rho = Real(1.2); }
387  // Heating gives d(rho theta)/dt = -(1/Cp) dQ/dz, with no density
388  // factor. The legacy form carries an extra rho, so its injected
389  // energy is rho times the flux; density_factor = false drops it.
390  // The Exner factor divides the tendency by Pi(p) so that the
391  // enthalpy, not rho theta, integrates to the flux.
392  // Preserve the explicit legacy density option, but apply Exner
393  // only to heat. Q_lat / L_V is a water mass flux: converting it
394  // to d(rho_d q_v)/dt must not multiply that mass by 1/Pi.
395  const Real mass_fac = density_factor ? rho : Real(1.0);
396  Real heat_fac = mass_fac;
397  if (exner_factor) {
398  const Real rhotheta = amrex::max(s_old_a(i, j, k, RhoTheta_comp), Real(1.0e-10));
399  // the moist pressure where there is vapour (about 1 % in Pi at 15 g/kg)
400  const Real qv = has_moisture ? amrex::max(s_old_a(i, j, k, RhoQ1_comp), Real(0.0)) / rho : Real(0.0);
401  const Real p = getPgivenRTh(rhotheta, qv);
402  const Real exner = std::pow(p / p_0, rdOcp);
403  heat_fac /= amrex::max(exner, Real(1.0e-6));
404  }
405 
406  // Layer weight: the fraction of the column's exponential profile
407  // that falls in this cell. Without an open fraction this is the flux
408  // difference form used since the coupling was written. With the
409  // open-fraction option, cells below the sampled roof height carry
410  // only their open share and the column is renormalised over the
411  // cells that remain, so the injected energy per column is unchanged
412  // and none of it lands inside a building.
413  Real w_k = exp_k - exp_kp1;
414  // erf.fire.heat_profile = bottom_cell: the whole flux enters the
415  // lowest cell of the column (the LSFIRE coupling)
416  if (bottom_cell) { w_k = (k == klo) ? Real(1.0) : Real(0.0); }
417  if (use_open) {
418  const Real f_open = open_arr(i, j, 0);
419  const Real h_roof = roof_arr(i, j, 0);
420  // Open share of this cell: fully open above the roof, f_open
421  // below it, linear across the cell that holds the roof.
422  // The roof height is above the ground, so are the cell's faces.
423  Real share = Real(1.0);
424  if (h_roof > Real(0.0)) {
425  const Real ground = fire_cell_face(z_nd_a, z_cc_a, has_nd, dz_avg, i, j, klo, 0);
426  const Real zlo_g = z_lo - ground;
427  const Real zhi_g = z_hi - ground;
428  if (zhi_g <= h_roof) { share = f_open; }
429  else if (zlo_g < h_roof) { share = f_open + (Real(1.0) - f_open) * (zhi_g - h_roof) / dz; }
430  }
431  // Column normalisation over the open shares of the same
432  // profile, precomputed per column above.
433  const Real norm = norm_arr(i, j, klo);
434  // The legacy profile integrates to (1 - exp(-z_top/alfg)); keep
435  // that total so the two modes inject the same energy per column.
436  const Real z_top = (z_cc_a(i, j, khi) - z_sfc) + dz_avg;
437  const Real legacy = Real(1.0) - std::exp(-z_top / alfg);
438  w_k = (norm > Real(1.0e-30)) ? w_k * share * legacy / amrex::max(norm, Real(1.0e-30)) : Real(0.0);
439  }
440 
441  // Without the open-fraction option the flux-difference expression
442  // is kept (exp_k - exp_kp1 times the flux, over the cell thickness).
443  Real theta_tend, q_tend = Real(0.0);
444  if (use_open || bottom_cell) {
445  theta_tend = heat_fac * (q_sfc_sensible / Cp_fire) * w_k / dz;
446  if (has_latent) { q_tend = mass_fac * (q_sfc_latent / L_V) * w_k / dz; }
447  } else {
448  Real hfx_k = (q_sfc_sensible / Cp_fire) * exp_k;
449  Real hfx_kp1 = (q_sfc_sensible / Cp_fire) * exp_kp1;
450  theta_tend = -heat_fac * (hfx_kp1 - hfx_k) / dz;
451  if (has_latent) {
452  Real qfx_k = (q_sfc_latent / L_V) * exp_k;
453  Real qfx_kp1 = (q_sfc_latent / L_V) * exp_kp1;
454  q_tend = -mass_fac * (qfx_kp1 - qfx_k) / dz;
455  }
456  }
457  if (additive) {
458  cc_src_a(i, j, k, RhoTheta_comp) += theta_tend;
459  } else {
460  // OVERWRITE (see file-level RK-stage safety note).
461  cc_src_a(i, j, k, RhoTheta_comp) = theta_tend;
462  }
463  if (has_latent) {
464  if (additive) {
465  cc_src_a(i, j, k, RhoQ1_comp) += q_tend;
466  } else {
467  cc_src_a(i, j, k, RhoQ1_comp) = q_tend;
468  }
469  }
470  // the cell's true thickness (not the floored one), so a degenerate
471  // cell's lost energy shows in the ratio
472  if (dbg_arr) { dbg_arr(i, j, k) = theta_tend * (z_hi - z_lo); }
473  });
474  }
475 
476  // Tendency diagnostics: runs on host after GPU kernel completes.
477  // Prints once per apply call (3x per timestep for RK3).
478  // Expected rt_tend_max ~ feedback * Q_max / Cp * (1 - exp(-dz/alfg)) / dz
479  // (times rho with the density factor) on a uniform grid
480  if (fire_debug) {
481  Real Q_max = Q_atm.max(0);
482  Real rt_tend_sum = cc_source.sum(RhoTheta_comp);
483  Real rt_tend_max = cc_source.max(RhoTheta_comp);
484  Real expected_max = feedback * Q_max / Cp_fire
485  * (Real(1.0) - std::exp(-dz_avg / alfg)) / dz_avg;
486  amrex::Print() << "[FIRE COUPLING] Q_atm_max=" << Q_max
487  << " W/m2 alfg=" << alfg << " m\n"
488  << "[FIRE COUPLING] RhoTheta tendency"
489  << " sum=" << rt_tend_sum
490  << " max=" << rt_tend_max
491  << " expected_surface_max=" << expected_max
492  << " [kg K m^-3 s^-1]\n";
493 
494  // Energy check: Cp times the sum over cells of (rho theta tendency) dz
495  // per unit area, against the flux handed in. The profile carries
496  // 1 - exp(-z_top/alfg) of the flux, essentially all of it for any
497  // realistic domain height, so with neither the density nor the Exner
498  // factor the ratio is that (the density factor multiplies it by rho,
499  // the Exner factor by 1/Pi, both profile-weighted means, essentially
500  // their values near the surface). The largest fire tendency inside a fully blocked cell below the roof must be
501  // zero with the open-fraction option.
502  const Real area = dx[0] * dx[1];
503  const Real e_out = Cp_fire * tend_dbg.sum(0) * area;
504  const Real e_in = feedback * Q_atm.sum(0) * area;
505  // Columns that straddle a footprint (0 < open fraction < 1) are where
506  // the placement matters: fully covered columns carry no flux once their
507  // fire cells are masked. For those partial columns report the largest
508  // share of the column's heating that lands below the roof H (above the
509  // ground). With B the plain profile's share below the roof (measured
510  // from the first cell centre) and L = 1 - exp(-z_top/alfg), the
511  // open-fraction weights give f B L / (L - (1 - f) B), more than f B
512  // because the column is renormalised; and the largest potential temperature of the
513  // state in cells below a roof, which shows heat that earlier steps left
514  // inside buildings.
515  Real below_roof_max = -Real(1.0), theta_blocked_max = -Real(1.0);
516  if (have_open) {
517  MultiFab col(open_frac_atm->boxArray(), open_frac_atm->DistributionMap(), 1, 0);
518  MultiFab thb(tend_dbg.boxArray(), tend_dbg.DistributionMap(), 1, 0);
519  col.setVal(0.0); thb.setVal(0.0);
520  for (MFIter mfi(col, TilingIfNotGPU()); mfi.isValid(); ++mfi) {
521  const Box& bx2 = mfi.tilebox();
522  auto const& c = col.array(mfi);
523  auto const& t = tend_dbg.const_array(mfi);
524  auto const& z = z_phys_cc.const_array(mfi);
525  auto const& ofrac = open_frac_atm->const_array(mfi);
526  auto const& hr = roof_h_atm->const_array(mfi);
527  const bool has_nd = (z_phys_nd != nullptr);
528  Array4<Real const> znd;
529  if (has_nd) { znd = z_phys_nd->const_array(mfi); }
530  amrex::ParallelFor(bx2, [=] AMREX_GPU_DEVICE (int i, int j, int /*k*/) noexcept {
531  const Real f = ofrac(i, j, 0);
532  if (f <= Real(1.0e-6) || f >= Real(1.0) - Real(1.0e-6)) { return; }
533  const Real ground = fire_cell_face(znd, z, has_nd, dz_avg, i, j, klo, 0);
534  Real below = Real(0.0), total = Real(0.0);
535  for (int kk = klo; kk <= khi; ++kk) {
536  const Real zk = z(i, j, kk) - ground;
537  total += t(i, j, kk);
538  if (zk < hr(i, j, 0)) { below += t(i, j, kk); }
539  }
540  c(i, j, 0) = (total > Real(1.0e-30)) ? below / amrex::max(total, Real(1.0e-30)) : Real(0.0);
541  });
542  }
543  for (MFIter mfi(thb, TilingIfNotGPU()); mfi.isValid(); ++mfi) {
544  const Box& bx = mfi.tilebox();
545  auto const& b = thb.array(mfi);
546  auto const& z = z_phys_cc.const_array(mfi);
547  auto const& so = S_old.const_array(mfi);
548  auto const& ofrac = open_frac_atm->const_array(mfi);
549  auto const& hr = roof_h_atm->const_array(mfi);
550  const bool has_nd = (z_phys_nd != nullptr);
551  Array4<Real const> znd;
552  if (has_nd) { znd = z_phys_nd->const_array(mfi); }
553  amrex::ParallelFor(bx, [=] AMREX_GPU_DEVICE (int i, int j, int k) noexcept {
554  const Real zk = z(i, j, k) - fire_cell_face(znd, z, has_nd, dz_avg, i, j, klo, 0);
555  if (ofrac(i, j, 0) < Real(1.0) - Real(1.0e-6) && zk < hr(i, j, 0)) {
556  b(i, j, k) = so(i, j, k, RhoTheta_comp) / so(i, j, k, Rho_comp);
557  }
558  });
559  }
560  below_roof_max = col.max(0);
561  theta_blocked_max = thb.max(0);
562  }
563  // The ratio divides only when e_in > 0, but the optimiser evaluates the division
564  // speculatively for both arms of the selection, and 0/0 on the first lagged step
565  // raised the invalid flag that aborts the run under amrex.fpe_trap_invalid; the
566  // floored denominator (the smallest normal Real, so it holds in single
567  // precision too) keeps the discarded value finite and the printed one unchanged.
568  amrex::Print() << "[FIRE COUPLING] energy_in=" << e_in << " W energy_out=" << e_out
569  << " W ratio=" << ((e_in > 0.0) ? e_out / amrex::max(e_in, std::numeric_limits<Real>::min()) : 0.0)
570  << " below_roof_share_max=" << below_roof_max
571  << " theta_blocked_max=" << theta_blocked_max << "\n";
572  }
573 }
574 
575 #endif // ERF_FIRE_ATM_COUPLING_H
constexpr amrex::Real Cp_d
Definition: ERF_Constants.H:51
constexpr amrex::Real p_0
Definition: ERF_Constants.H:69
constexpr amrex::Real R_d
Definition: ERF_Constants.H:49
constexpr amrex::Real L_v
Definition: ERF_Constants.H:67
AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE amrex::Real getPgivenRTh(const amrex::Real rhotheta, const amrex::Real qv=amrex::Real(0))
Definition: ERF_EOS.H:81
void apply_fire_tendency_to_cc_source(amrex::MultiFab &cc_source, const amrex::MultiFab &Q_atm, const amrex::MultiFab *Q_lat_atm, const amrex::MultiFab &z_phys_cc, const amrex::MultiFab *z_phys_nd, const amrex::MultiFab &S_old, const amrex::Geometry &geom_atm, amrex::Real alfg, amrex::Real feedback, bool has_moisture, bool fire_debug=false, bool additive=false, const amrex::MultiFab *open_frac_atm=nullptr, const amrex::MultiFab *roof_h_atm=nullptr, bool use_open_weights=false, bool density_factor=false, bool exner_factor=false, bool bottom_cell=false)
Definition: ERF_FireAtmCoupling.H:203
AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE amrex::Real fire_cell_thickness(amrex::Real z_lo, amrex::Real z_hi, amrex::Real dz_avg) noexcept
Definition: ERF_FireAtmCoupling.H:177
constexpr amrex::Real FIRE_ATM_ALFG_DEFAULT
Definition: ERF_FireAtmCoupling.H:100
AMREX_GPU_HOST_DEVICE AMREX_FORCE_INLINE amrex::Real fire_cell_face(amrex::Array4< amrex::Real const > const &z_nd, amrex::Array4< amrex::Real const > const &z_cc, bool has_nd, amrex::Real dz_avg, int i, int j, int k, int upper) noexcept
Definition: ERF_FireAtmCoupling.H:158
void coarsen_fire_flux_to_atm(amrex::MultiFab &Q_atm_out, const amrex::MultiFab &Q_fire, const amrex::Geometry &geom_atm, const FireGrid &fg)
Definition: ERF_FireAtmCoupling.H:112
Fire grid data structure and construction.
#define Rho_comp
Definition: ERF_IndexDefines.H:53
#define RhoTheta_comp
Definition: ERF_IndexDefines.H:54
#define RhoQ1_comp
Definition: ERF_IndexDefines.H:66
const Real rdOcp
Definition: ERF_InitCustomPert_ABL.H:72
const Real dx
Definition: ERF_InitCustomPert_ABL.H:44
AMREX_ALWAYS_ASSERT_WITH_MESSAGE(axis >=0 &&axis< AMREX_SPACEDIM, "prob.axis must be 0, 1 or 2")
const int khi
Definition: ERF_InitCustomPert_Bubble.H:25
AMREX_ALWAYS_ASSERT(bx.length()[2]==khi+1)
const int klo
Definition: ERF_InitCustomPert_KE.H:9
ParallelFor(fab_box, [=] AMREX_GPU_DEVICE(int i, int j, int k) { qrcuten_arr(i, j, k)=Real(0);qscuten_arr(i, j, k)=Real(0);qicuten_arr(i, j, k)=Real(0);})
amrex::Real Real
Definition: ERF_ShocInterface.H:19
@ fg
Definition: ERF_AdvanceMorrison.cpp:111
@ rho
Definition: ERF_Kessler.H:25
@ qv
Definition: ERF_Kessler.H:31
@ total
Definition: ERF_NOAHMP_Fields.H:137
@ dz
Definition: ERF_AdvanceWDM6.cpp:273
@ q
Definition: ERF_WSM6.H:294
@ t
Definition: ERF_WSM6.H:293
@ p
Definition: ERF_WSM6.H:301
Definition: ERF_ConsoleIO.cpp:17
2D horizontal fire computational grid
Definition: ERF_FireGrid.H:29