OpenSWMM Engine  6.0.0-alpha.4
Data-oriented, plugin-extensible SWMM Engine (6.0.0-alpha.4)
Loading...
Searching...
No Matches
LagrangianSolver.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2//
3// Copyright 2026 Caleb Buahin
4//
5// Licensed under the Apache License, Version 2.0 (the "License");
6// you may not use this file except in compliance with the License.
7// You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing, software
12// distributed under the License is distributed on an "AS IS" BASIS,
13// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14// See the License for the specific language governing permissions and
15// limitations under the License.
16
94
95#ifndef OPENSWMM_QUALITY_LARD_LAGRANGIAN_SOLVER_HPP
96#define OPENSWMM_QUALITY_LARD_LAGRANGIAN_SOLVER_HPP
97
98#include <algorithm>
99#include <cmath>
100#include <limits>
101#include <cstdint>
102#include <vector>
104
107#include "../NegativeSources.hpp"
108#include "../QualityRouting.hpp"
112#include "RwptDispersion.hpp"
113#include "SegmentStore.hpp"
122#include "../QualityRouting.hpp"
123
124namespace openswmm {
125namespace lard {
126
127constexpr double kTinyFlow = 1.0e-8;
128
150 int np = 0;
151 int age_row = -1;
152 int temp_row = -1;
153 int msx_first = -1;
155 int ns = 0;
156};
157
170 // E2: the class ENABLES come from the one policy
171 // (transport::network1DEnables — allocation-free, this runs per step).
172 // Identical to the pre-E2 reads: LARD only runs with IGNORE_QUALITY NO,
173 // where the policy's gate is a no-op. The row ORDER below is deliberately
174 // LARD's own (pollutants, age, temperature, MSX) — see the struct note;
175 // E2 unifies the decision, not the index arithmetic.
178 L.np = en.n_pollut;
179 L.ns = L.np;
180 if (en.age)
181 L.age_row = L.ns++;
182 if (en.temperature)
183 L.temp_row = L.ns++; // H7b
184 // L3: MSX species ride the segments after the reserved rows —
185 // stable across the run (the compiled species table is fixed at
186 // open). n_msx == 0 leaves every index above IDENTICAL to H7b's
187 // layout, which is the bit-inertness claim the corpus checks.
189 L.msx_first = L.ns;
190 L.ns += en.n_msx;
191 }
192 return L;
193}
194
196public:
207 std::string checkpointState() const {
208 auto copy = *this;
210 copy.checkpoint(a);
211 return a.bytes();
212 }
213
227 void restoreCheckpointState(const std::string& bytes, SimulationContext& ctx) {
229 bool ready = false;
230 a(ready);
231 if (ready)
232 init(ctx);
233 checkpointFields(a);
234 a.finish();
235 }
236
241 bool initialized() const noexcept { return initialized_; }
253 std::vector<ChannelThermalRegion> thermalRegions(const SimulationContext& ctx) const {
254 std::vector<ChannelThermalRegion> out;
255 const auto layout = rowLayout(ctx);
256 if (!initialized_ || layout.temp_row < 0)
257 return out;
258 std::vector<double> mean(layout.ns);
259 for (int link = 0; link < ctx.n_links(); ++link) {
260 const double volume = store_.total_volume(link) * .028316846592;
261 if (!(volume > 0))
262 continue;
263 store_.mean_conc(link, mean.data());
264 out.push_back({link, 0, 0, 1, volume, mean[layout.temp_row]});
266 }
267 return out;
268 }
269
279 std::vector<ChannelSpeciesRegion> speciesRegions(const SimulationContext& ctx) const {
280 std::vector<ChannelSpeciesRegion> out;
281 const auto layout = rowLayout(ctx);
282 if (!initialized_ || layout.temp_row < 0)
283 return out;
284 std::vector<double> mean(layout.ns);
285 for (int link = 0; link < ctx.n_links(); ++link) {
286 const double volume = store_.total_volume(link) * .028316846592;
287 if (!(volume > 0))
288 continue;
289 store_.mean_conc(link, mean.data());
291 region.link = link;
292 region.region = 0;
293 region.volume = volume;
294 for (int p = 0; p < layout.np; ++p)
295 region.row_names.push_back(ctx.pollutant_names.name_of(p));
296 if (layout.age_row >= 0)
297 region.row_names.emplace_back("__WATER_AGE__");
298 if (layout.temp_row >= 0)
299 region.row_names.emplace_back("__TEMPERATURE__");
300 if (layout.msx_first >= 0)
301 for (int row = layout.msx_first; row < layout.ns; ++row)
302 region.row_names.push_back(
303 ctx.reactions.species_name[std::size_t(row - layout.msx_first)]);
304 region.concentration = mean;
305 out.push_back(std::move(region));
306 }
307 return out;
308 }
309
326 const std::vector<ChannelSpeciesIncrement>& updates) {
327 const auto layout = rowLayout(ctx);
328 if (!initialized_ || layout.temp_row < 0)
329 return "LARD channel species state is unavailable.";
330 for (const auto& u : updates) {
331 if (u.link < 0 || u.link >= ctx.n_links() || u.region != 0 ||
332 u.concentration_delta.size() != std::size_t(layout.ns))
333 return "LARD species increment has an invalid channel region or row count.";
334 if (u.concentration_delta[layout.temp_row] != 0)
335 return "LARD species update cannot modify the temperature row.";
336 std::vector<double> mean(layout.ns);
337 store_.mean_conc(u.link, mean.data());
338 for (int k = 0; k < store_.count(u.link); ++k)
339 for (int row = 0; row < layout.ns; ++row) {
340 const double delta = u.concentration_delta[row];
341 const double target = mean[row] + delta;
342 const double next = mean[row] > 0
343 ? store_.seg_conc(u.link, k, row) * target / mean[row]
344 : target;
345 if (!std::isfinite(delta) || !std::isfinite(next) ||
346 (row != layout.temp_row && next < 0))
347 return "LARD species increment is nonfinite or makes a carried mass negative.";
348 }
349 }
350 // A channel region is a whole reach in LARD. Apply the finite-contact
351 // change proportionally to its current segment concentrations, which
352 // preserves their pattern and keeps every segment nonnegative. The
353 // scaling also makes an inverse rollback restore the original state.
354 for (const auto& u : updates) {
355 std::vector<double> mean(layout.ns);
356 store_.mean_conc(u.link, mean.data());
357 for (int k = 0; k < store_.count(u.link); ++k)
358 for (int row = 0; row < layout.ns; ++row)
359 if (row != layout.temp_row) {
360 const double target = mean[row] + u.concentration_delta[row];
361 const double next =
362 mean[row] > 0 ? store_.seg_conc(u.link, k, row) * target / mean[row]
363 : target;
364 store_.set_seg_conc(u.link, k, row, next);
365 }
366 }
367 publish(ctx, 0);
368 return {};
369 }
370
384 const std::vector<ChannelThermalIncrement>& updates) {
385 const auto layout = rowLayout(ctx);
386 if (!initialized_ || layout.temp_row < 0)
387 return "LARD channel thermal state is unavailable.";
388 for (const auto& u : updates) {
389 if (u.link < 0 || u.link >= ctx.n_links() || u.region != 0 || !std::isfinite(u.celsius))
390 return "LARD thermal increment has an invalid channel region.";
391 for (int k = 0; k < store_.count(u.link); ++k)
392 if (!std::isfinite(store_.seg_conc(u.link, k, layout.temp_row) + u.celsius))
393 return "LARD thermal increment is nonfinite.";
394 }
395 for (const auto& u : updates)
396 for (int k = 0; k < store_.count(u.link); ++k)
397 store_.set_seg_conc(u.link, k, layout.temp_row,
398 store_.seg_conc(u.link, k, layout.temp_row) + u.celsius);
399 publish(ctx, 0);
400 return {};
401 }
402
419 void step(SimulationContext& ctx, double dt_routing) {
420 // X4/H7b: the age and temperature rows ride the segments after
421 // the pollutants — age published to water_age_state (seconds),
422 // temperature to heat_state (degC) — rather than the np-strided
423 // conc arrays.
424 const SpeciesRowLayout L = rowLayout(ctx);
425 const int np = L.np;
426 const int ns = L.ns;
427 if (ns <= 0)
428 return;
429 if (!initialized_)
430 init(ctx);
431
432 const int nl = ctx.n_links();
433 auto& links = ctx.links;
434
435 // ---- 0. Flow reversal (§4.4) — once per routing step: the flow
436 // solution is constant within it. -----------------------------
437 bool topo_dirty = false;
438 for (int l = 0; l < nl; ++l) {
439 const auto ul = static_cast<std::size_t>(l);
440 const double q = links.flow[ul];
441 if (std::abs(q) <= kTinyFlow)
442 continue;
443 const std::int8_t sign = (q >= 0.0) ? 1 : -1;
444 if (sign != flow_sign_[ul]) {
445 if (links.type[ul] == LinkType::CONDUIT)
446 store_.reverse(l);
447 flow_sign_[ul] = sign;
448 topo_dirty = true;
449 }
450 }
451 if (topo_dirty || topo_.empty())
452 computeTopoOrder(ctx);
453
454 // ---- X3a: QUALITY_STEP substepping (strategy §4.2). Equal
455 // substeps; mass/age external loads are RATES and scale
456 // through dt, the per-routing-step external VOLUME
457 // (qual_vol_in) scales through frac. dtq absent or >= the
458 // routing step degenerates to one substep — bit-identical to
459 // the pre-X3a engine by construction.
460 const double dtq = ctx.options.quality_step;
461 const int nsub =
462 (dtq > 0.0 && dtq < dt_routing) ? static_cast<int>(std::ceil(dt_routing / dtq)) : 1;
463 const double dt = dt_routing / static_cast<double>(nsub);
464 const double frac = 1.0 / static_cast<double>(nsub);
465 for (int sub = 0; sub < nsub; ++sub)
466 substep(ctx, dt, frac);
467
468 // H2/H3/H6b under LARD: surface + radiative fluxes and the bed
469 // pair, once per routing step (the ARD slot), applied to the
470 // segment field before publication.
471 applyFluxesAndBed(ctx, dt_routing);
472
473 publish(ctx, dt_routing);
474 }
475
498 void substep(SimulationContext& ctx, double dt, double frac) {
499 const SpeciesRowLayout L = rowLayout(ctx);
500 const int np = L.np;
501 const int ns = L.ns;
502 // Derived from the layout rather than re-read from options, so the
503 // "is there a reserved row" question has exactly one answer per call.
504 const bool age = (L.age_row >= 0);
505 const bool heat = (L.temp_row >= 0);
506 const int nn = ctx.n_nodes();
507 const int nl = ctx.n_links();
508 auto& nodes = ctx.nodes;
509 auto& links = ctx.links;
510 auto& ws = ctx.water_age_state;
511 auto& hs = ctx.heat_state;
512
513 // ---- AGE (before transport): every parcel ages by exactly dt, and
514 // the aged value is this substep's "old" state — the plan §1
515 // convention routeLegacyAge follows (age then mix). ------------
516 if (age) {
517 store_.add_species(L.age_row, dt);
518 for (int n = 0; n < nn; ++n)
519 ws.node_age[static_cast<std::size_t>(n)] += dt;
520 }
521
522 // ---- 1. DRAIN (all conduits, before any node mixes) ---------------
523 scratch_.assign(static_cast<std::size_t>(ns), 0.0);
524 treat_cin_.assign(static_cast<std::size_t>(np > 0 ? np : 0), 0.0);
525 for (int l = 0; l < nl; ++l) {
526 const auto ul = static_cast<std::size_t>(l);
527 if (links.type[ul] != LinkType::CONDUIT)
528 continue;
529 const double q = std::abs(links.flow[ul]);
530 const int dn = downstreamNode(ctx, l);
531 const int up = upstreamNode(ctx, l);
532
533 if (q > kTinyFlow && dn >= 0) {
534 std::fill(scratch_.begin(), scratch_.end(), 0.0);
535 const double drained = store_.drain_back(l, q * dt, scratch_.data());
536 addToLedger(dn, drained, scratch_.data(), ns);
537 }
538 // Volume reconciliation: the slab must sum to links.volume.
539 // A shortfall is filled at RELEASE with upstream water; an
540 // excess left after the outflow drain leaves through the FRONT
541 // to the upstream ledger — booked, not rescaled (see header).
542 const double v_new = links.volume[ul];
543 const double v_rem = store_.total_volume(l);
544 if (v_rem > v_new && up >= 0) {
545 std::fill(scratch_.begin(), scratch_.end(), 0.0);
546 const double shed = store_.drain_front(l, v_rem - v_new, scratch_.data());
547 addToLedger(up, shed, scratch_.data(), ns);
548 }
549 }
550
551 // ---- 2. MIX in topo order, passthrough zero-volume links ----------
552 for (const int n : topo_) {
553 const auto un = static_cast<std::size_t>(n);
554 // The node's mixed STORE (node::storeVolume): the routers book
555 // nodes.volume in legacy's convention, 0 for a junction below
556 // its rim, but the store this solver mixes into is the water
557 // the continuity equation attributes to the node.
558 const double v_old =
559 node::storeVolume(nodes, n, nodes.old_depth[un], nodes.old_volume[un]);
560 const double v_in = node_vol_in_[un];
561 // OUTFALL_BACKFLOW_QUALITY ZERO: an outfall taking no volume
562 // inflow this substep is a fresh boundary — its held state
563 // (pollutant rows AND the age row) reads zero, so the RELEASE
564 // seeding and zero-volume passthrough below draw clean water.
565 const bool zero_bf = ctx.options.outfall_backflow_zero &&
566 nodes.type[un] == NodeType::OUTFALL &&
567 v_in + nodes.qual_vol_in[un] * frac <= 0.0;
568
569 for (int s = 0; s < ns; ++s) {
570 // H7a: identity by INDEX, not by threshold. `s >= np` was
571 // correct only while age was the sole reserved row; it would
572 // silently capture the temperature row too (H7b).
573 const bool is_age = (s == L.age_row);
574 const bool is_temp = (s == L.temp_row);
575 // L3: species rows — state lives in msx_node_conc
576 // ([node*nsp + sp]); U2 gave them the [INFLOWS] pathway
577 // (msx_ext_mass_in, same index).
578 const bool is_msx = (L.msx_first >= 0 && s >= L.msx_first);
579 const auto xi = is_msx ? un * static_cast<std::size_t>(ns - L.msx_first) +
580 static_cast<std::size_t>(s - L.msx_first)
581 : 0;
582 const auto li =
583 un * static_cast<std::size_t>(ns) + static_cast<std::size_t>(s); // ledger index
584 // State and external load per row. Pollutants: nodes.conc +
585 // qual_mass_in (rate × dt — the mixAtNodes convention). Age:
586 // water_age_state.node_age (already aged +dt this step) +
587 // node_age_vol_in (age·ft³/s rate, the D-UT10 parallel
588 // accumulator filled by all seven loader pathways).
589 const auto ci = un * static_cast<std::size_t>(np) + static_cast<std::size_t>(s);
590 // Temperature mirrors age one row over: state in
591 // heat_state.node_temp (degC), external load from
592 // node_temp_vol_in (degC.ft3/s, the D-UT10 twin filled by
593 // the same seven loaders) -- H1's convention, consumed here
594 // instead of by routeLegacyHeat.
595 const double st_old = is_age ? ws.node_age[un]
596 : is_temp ? hs.node_temp[un]
597 : is_msx ? ctx.reactions.msx_node_conc[xi]
598 : nodes.conc[ci];
599 // U2: species rows take the [INFLOWS] species loads
600 // (msx_ext_mass_in, a rate; empty when no such row).
601 const double m_ext = is_age ? ws.node_age_vol_in[un] * dt
602 : is_temp ? hs.node_temp_vol_in[un] * dt
603 : is_msx ? ((xi < ctx.reactions.msx_ext_mass_in.size())
604 ? ctx.reactions.msx_ext_mass_in[xi] * dt
605 : 0.0)
606 : nodes.qual_mass_in[ci] * dt;
607 double m = st_old * v_old + node_mass_in_[li] + m_ext;
608 // P2.3: stash the ARRIVING mass for the treatment cin below
609 // (pollutant rows only; s == p for rows 0..np-1).
610 if (!is_age && !is_temp && !is_msx && s < static_cast<int>(treat_cin_.size()))
611 treat_cin_[static_cast<std::size_t>(s)] = node_mass_in_[li] + m_ext;
612 // D-NS1 (X6, now observable): extraction beyond the
613 // store's mass clamps to available — counted, warned
614 // once, and (pollutant rows) un-booked so the ledger
615 // carries what actually left.
616 // The non-negativity clamp does NOT apply to temperature:
617 // degC water below zero is an ordinary state (the freezing
618 // gate in heat_watershed exists to keep it so), where a
619 // negative mass or age is a defect. Same reasoning as the
620 // report boundary's deliberate no-mask on temperature.
621 if (m < 0.0 && !is_temp) {
622 if (is_age)
624 else if (!is_msx) // L3: species have no ledger row
626 m = 0.0;
627 }
628 const double denom = v_old + v_in + nodes.qual_vol_in[un] * frac;
629 // ALWAYS divide by the full denominator. m/denom is a convex
630 // combination of st_old and the arriving values, so it can
631 // never exceed its inputs; the fallback this replaces
632 // divided a mass that included st_old*v_old by a divisor
633 // that EXCLUDED v_old, and at a nearly-dry junction that
634 // quotient amplified step over step -- measured on a
635 // receding-flow deck (inflow stops at 1 h): node
636 // concentrations reached 2.7e30, 2.0e281, then inf, and the
637 // final-storage row went NaN. Below 1e-12 ft^3 there is no
638 // meaningful water and the store keeps its value.
639 if (is_msx && !zero_bf)
640 m += transport::reactionBoosterMass(ctx, n, s - L.msx_first, m, denom,
641 v_in + nodes.qual_vol_in[un] * frac);
642 const double st_new = zero_bf ? 0.0 : ((denom > 1.0e-12) ? m / denom : st_old);
643 if (is_age)
644 ws.node_age[un] = st_new;
645 else if (is_temp)
646 hs.node_temp[un] = st_new;
647 else if (is_msx)
648 ctx.reactions.msx_node_conc[xi] = st_new;
649 else
650 nodes.conc[ci] = st_new;
651 node_mass_in_[li] = 0.0; // consumed; cycle residue carries
652 }
653 node_vol_in_[un] = 0.0;
654
655 // P2.3: [TREATMENT] applies HERE — to the freshly mixed node
656 // state, BEFORE the passthrough and RELEASE below draw it: the
657 // LEGACY ordering (mix → treat → links), transplanted. The
658 // handoff's end-of-step application outside the solver was a
659 // no-op twice over: the evaluator's cin read the LEGACY
660 // accumulators (external loads only — 0 at an interior node,
661 // and an R-typed expression keeps c_node when cin reads 0),
662 // and a junction's ~zero stored volume gave the late write no
663 // weight in the next mix. Fed with THIS substep's inflow
664 // figures instead; nodes with no treatment pay one flag test.
665 if (np > 0 && dt > 0.0 && un < ctx.treatment.has_treatment.size() &&
666 ctx.treatment.has_treatment[un]) {
667 const double v_in_total = v_in + nodes.qual_vol_in[un] * frac;
668 for (int p = 0; p < np; ++p)
669 treat_cin_[static_cast<std::size_t>(p)] =
670 (v_in_total > 1.0e-12)
671 ? std::max(0.0, treat_cin_[static_cast<std::size_t>(p)]) / v_in_total
672 : 0.0;
673 quality::applyNodeTreatment(ctx, n, dt, v_in_total / dt, treat_cin_.data());
674 }
675
676 // Zero-volume passthrough (§2.4): outgoing pump/orifice/weir/
677 // outlet links deliver the node's NEW concentration downstream
678 // within this step — the property the topo order exists for.
679 for (const int l : node_out_links_[un]) {
680 const auto ul = static_cast<std::size_t>(l);
681 if (links.type[ul] == LinkType::CONDUIT)
682 continue;
683 const double q = std::abs(links.flow[ul]);
684 if (q <= kTinyFlow)
685 continue;
686 const int dn = downstreamNode(ctx, l);
687 if (dn < 0)
688 continue;
689 for (int p = 0; p < np; ++p) {
690 const auto ni = un * static_cast<std::size_t>(np) + static_cast<std::size_t>(p);
691 const auto lp = ul * static_cast<std::size_t>(np) + static_cast<std::size_t>(p);
692 scratch_[static_cast<std::size_t>(p)] = nodes.conc[ni];
693 links.conc[lp] = nodes.conc[ni];
694 }
695 if (age) {
696 scratch_[static_cast<std::size_t>(L.age_row)] = ws.node_age[un];
697 ws.link_age[ul] = ws.node_age[un];
698 }
699 if (heat) {
700 scratch_[static_cast<std::size_t>(L.temp_row)] = hs.node_temp[un];
701 hs.link_temp[ul] = hs.node_temp[un];
702 }
703 if (L.msx_first >= 0) { // L3
704 const int nsp = ns - L.msx_first;
705 for (int sp = 0; sp < nsp; ++sp) {
706 const double c =
707 ctx.reactions.msx_node_conc[un * static_cast<std::size_t>(nsp) +
708 static_cast<std::size_t>(sp)];
709 scratch_[static_cast<std::size_t>(L.msx_first + sp)] = c;
710 ctx.reactions.msx_link_conc[ul * static_cast<std::size_t>(nsp) +
711 static_cast<std::size_t>(sp)] = c;
712 }
713 }
714 addToLedgerRate(dn, q * dt, scratch_.data(), ns);
715 }
716 }
717
718 // ---- 3. RELEASE new front segments --------------------------------
719 for (int l = 0; l < nl; ++l) {
720 const auto ul = static_cast<std::size_t>(l);
721 if (links.type[ul] != LinkType::CONDUIT)
722 continue;
723 const int up = upstreamNode(ctx, l);
724 const double need = links.volume[ul] - store_.total_volume(l);
725 release_vol_[ul] = (need > 0.0) ? need : 0.0; // X3b: RWPT's V_in
726 if (need <= 0.0 || up < 0)
727 continue;
728 const auto uu = static_cast<std::size_t>(up);
729 for (int p = 0; p < np; ++p)
730 scratch_[static_cast<std::size_t>(p)] =
731 nodes.conc[uu * static_cast<std::size_t>(np) + static_cast<std::size_t>(p)];
732 if (age)
733 scratch_[static_cast<std::size_t>(L.age_row)] = ws.node_age[uu];
734 if (heat)
735 scratch_[static_cast<std::size_t>(L.temp_row)] = hs.node_temp[uu];
736 if (L.msx_first >= 0) { // L3
737 const int nsp = ns - L.msx_first;
738 for (int sp = 0; sp < nsp; ++sp)
739 scratch_[static_cast<std::size_t>(L.msx_first + sp)] =
740 ctx.reactions.msx_node_conc[uu * static_cast<std::size_t>(nsp) +
741 static_cast<std::size_t>(sp)];
742 }
743 store_.push_front(l, need, scratch_.data());
744 }
745
746 // ---- 3b. RWPT dispersion (X3b) — on the substep's FINAL segment
747 // field, per link, resolved vertical shear + walk. The age row
748 // disperses with the water like every other species — mixing
749 // moves age, physically. H7b: so does TEMPERATURE, at the same
750 // coefficient as a solute — the ARD engine's deliberate choice
751 // for its temperature row, adopted here for cross-engine
752 // consistency (decision 2026-08-30). ---------------------------
753 if (ctx.options.lard_rwpt) {
754 ++substep_counter_;
755 const auto& cond = ctx.link_subtypes.conduits;
756 for (int l = 0; l < nl; ++l) {
757 const auto ul = static_cast<std::size_t>(l);
758 if (links.type[ul] != LinkType::CONDUIT)
759 continue;
760 const double q = std::abs(links.flow[ul]);
761 if (q <= kTinyFlow)
762 continue;
763 const int row = ctx.link_subtypes.conduit_row(l);
764 if (row < 0)
765 continue;
766 const auto ur = static_cast<std::size_t>(row);
767 const double len = cond.length[ur];
768 const double vol = links.volume[ul];
769 if (len <= 0.0 || vol <= 0.0)
770 continue;
771 const double a_flow = vol / len;
772 const double ubar = q / a_flow;
773 const double h = links.depth[ul];
774 const bool circ = links.xsect_shape[ul] == XsectShape::CIRCULAR;
775 const double rh = rwpt_hyd_radius(a_flow, h, links.xsect_geom1[ul], circ);
776 rwpt_.disperse(ctx, store_, l, ubar, h, rh, cond.roughness[ur], release_vol_[ul],
777 q * dt, dt, substep_counter_,
778 static_cast<std::uint64_t>(ctx.options.rwpt_seed), scratch_);
779 }
780 }
781
782 // ---- 4. DECAY (exact exponential; species-major stripes) ----------
783 for (int p = 0; p < np; ++p) {
784 const double k = ctx.pollutants.k_decay[static_cast<std::size_t>(p)];
785 if (k == 0.0)
786 continue;
787 const double f = std::exp(-k * dt);
788 double removed = store_.decay_species(p, f);
789 for (int n = 0; n < nn; ++n) {
790 const auto idx = static_cast<std::size_t>(n) * static_cast<std::size_t>(np) +
791 static_cast<std::size_t>(p);
792 const double v = node::storeVolume(nodes, n);
793 removed += nodes.conc[idx] * (1.0 - f) * v;
794 nodes.conc[idx] *= f;
795 }
796 if (static_cast<std::size_t>(p) < ctx.mass_balance.qual_routing_reacted.size())
797 ctx.mass_balance.qual_routing_reacted[static_cast<std::size_t>(p)] += removed;
798 }
799
800 // ---- 4b. REACT (L3): MSX species integrate per SEGMENT (pipe
801 // scope, the segment's own pollutant rows as context) and
802 // per NODE store (tank scope, the node's HRT) — D-L1's
803 // gather/scatter through the shared integrator. Pollutant
804 // kdecay is NOT re-applied here: stage 4 owns it (the
805 // integrator handles only MSX kinetics), so there is no
806 // double-decay the way the legacy binding had to avoid.
807 if (L.msx_first >= 0) {
808 quality::updateUntreatedStorageHrt(ctx, dt); // review A6
809 auto& rx = ctx.reactions;
810 const int nsp = ns - L.msx_first;
811 if (static_cast<int>(msx_block_.size()) < nsp)
812 msx_block_.assign(static_cast<std::size_t>(nsp), 0.0);
813 if (static_cast<int>(msx_poll_.size()) < np && np > 0)
814 msx_poll_.assign(static_cast<std::size_t>(np), 0.0);
815 for (int l = 0; l < nl; ++l) {
816 const auto ul = static_cast<std::size_t>(l);
817 if (links.type[ul] != LinkType::CONDUIT)
818 continue;
819 const int cnt = store_.count(l);
820 for (int i = 0; i < cnt; ++i) {
821 for (int sp = 0; sp < nsp; ++sp)
822 msx_block_[static_cast<std::size_t>(sp)] =
823 store_.seg_conc(l, i, L.msx_first + sp);
824 for (int p = 0; p < np; ++p)
825 msx_poll_[static_cast<std::size_t>(p)] = store_.seg_conc(l, i, p);
826 // TEMP: the segment's own temperature row when heat
827 // rides the store (H7b); NaN defers to the
828 // [REACTION_OPTIONS] TEMPERATURE constant.
829 const double seg_temp_c = (L.temp_row >= 0)
830 ? store_.seg_conc(l, i, L.temp_row)
831 : std::numeric_limits<double>::quiet_NaN();
832 transport::reactSpeciesBlock(ctx, /*tank=*/false, dt, msx_block_.data(),
833 np > 0 ? msx_poll_.data() : nullptr, 0.0,
834 seg_temp_c, l);
835 for (int sp = 0; sp < nsp; ++sp)
836 store_.set_seg_conc(l, i, L.msx_first + sp,
837 msx_block_[static_cast<std::size_t>(sp)]);
838 }
839 }
840 for (int n = 0; n < nn; ++n) {
841 const auto un = static_cast<std::size_t>(n);
842 if (!quality::nodeIsReactor(ctx, n) || nodes.volume[un] <= quality::ZERO_VOLUME)
843 continue;
844 for (int sp = 0; sp < nsp; ++sp)
845 msx_block_[static_cast<std::size_t>(sp)] =
846 rx.msx_node_conc[un * static_cast<std::size_t>(nsp) +
847 static_cast<std::size_t>(sp)];
848 for (int p = 0; p < np; ++p)
849 msx_poll_[static_cast<std::size_t>(p)] =
850 nodes.conc[un * static_cast<std::size_t>(np) + static_cast<std::size_t>(p)];
851 const double hrt = (un < nodes.hrt.size()) ? nodes.hrt[un] : 0.0;
852 const double node_temp_c = (L.temp_row >= 0 && un < ctx.heat_state.node_temp.size())
853 ? ctx.heat_state.node_temp[un]
854 : std::numeric_limits<double>::quiet_NaN();
855 transport::reactSpeciesBlock(ctx, /*tank=*/true, dt, msx_block_.data(),
856 np > 0 ? msx_poll_.data() : nullptr, hrt, node_temp_c,
857 n);
858 for (int sp = 0; sp < nsp; ++sp)
859 rx.msx_node_conc[un * static_cast<std::size_t>(nsp) +
860 static_cast<std::size_t>(sp)] =
861 msx_block_[static_cast<std::size_t>(sp)];
862 }
863 }
864 }
865
882 void publish(SimulationContext& ctx, double dt_routing) {
883 // H7b: the fourth layout-aware site joins rowLayout() (H7a
884 // converted step/substep/init and flagged this one).
885 const SpeciesRowLayout L = rowLayout(ctx);
886 const int np = L.np;
887 const bool age = (L.age_row >= 0);
888 const bool heat = (L.temp_row >= 0);
889 const int nl = ctx.n_links();
890 auto& nodes = ctx.nodes;
891 auto& links = ctx.links;
892 auto& ws = ctx.water_age_state;
893 auto& hs = ctx.heat_state;
894
895 // ---- 5. PUBLISH ---------------------------------------------------
896 for (int l = 0; l < nl; ++l) {
897 const auto ul = static_cast<std::size_t>(l);
898 if (links.type[ul] != LinkType::CONDUIT)
899 continue;
900 store_.mean_conc(l, scratch_.data());
901 for (int p = 0; p < np; ++p)
902 links.conc[ul * static_cast<std::size_t>(np) + static_cast<std::size_t>(p)] =
903 scratch_[static_cast<std::size_t>(p)];
904 // Age: volume-weighted mean over the same segments, seconds.
905 // The dry-element report mask (A2b) is at the report boundary,
906 // engine-side — state keeps aging here regardless. An EMPTY
907 // slab holds no parcels to average, so the link's held age
908 // ages in place instead of resetting to 0 — the state/report
909 // separation the dry-mask round established (a dry element's
910 // STATE keeps aging; only the report masks it).
911 if (age) {
912 if (store_.count(l) > 0)
913 ws.link_age[ul] = scratch_[static_cast<std::size_t>(L.age_row)];
914 else
915 ws.link_age[ul] += dt_routing;
916 }
917 // Temperature: volume-weighted mean over the same segments. An
918 // EMPTY slab HOLDS its temperature — unlike age it does not
919 // grow, and unlike the age report it is not masked when dry
920 // (0 degC is an ordinary temperature; the no-mask call is
921 // documented at the snapshot builder).
922 if (heat) {
923 if (store_.count(l) > 0)
924 hs.link_temp[ul] = scratch_[static_cast<std::size_t>(L.temp_row)];
925 }
926 // L3: species rows publish like temperature — an EMPTY
927 // slab HOLDS its concentration.
928 if (L.msx_first >= 0 && store_.count(l) > 0) {
929 const int nsp = L.ns - L.msx_first;
930 for (int sp = 0; sp < nsp; ++sp)
931 ctx.reactions.msx_link_conc[ul * static_cast<std::size_t>(nsp) +
932 static_cast<std::size_t>(sp)] =
933 scratch_[static_cast<std::size_t>(L.msx_first + sp)];
934 }
935 }
936 // conc_old bookkeeping matches the ARD/legacy convention.
937 links.conc_old = links.conc;
938 nodes.conc_old = nodes.conc;
939 }
940
941private:
950 void checkpoint(detail::CompletedSourceArchive& a) {
951 a(initialized_);
952 checkpointFields(a);
953 }
965 void checkpointFields(detail::CompletedSourceArchive& a) {
966 if (!initialized_)
967 return;
968 store_.checkpoint(a);
969 rwpt_.checkpoint(a);
970 a(substep_counter_, topo_, node_out_links_);
971 a.fixed(flow_sign_, node_mass_in_, node_vol_in_);
972 const int nn = int(node_vol_in_.size()), nl = int(flow_sign_.size());
973 if (topo_.size() != std::size_t(nn) || node_out_links_.size() != std::size_t(nn))
974 throw std::runtime_error("Invalid LARD topology shape.");
975 std::vector<bool> seen(nn, false);
976 for (int n : topo_) {
977 if (n < 0 || n >= nn || seen[n])
978 throw std::runtime_error("Invalid LARD checkpoint topology.");
979 seen[n] = true;
980 }
981 for (const auto& links : node_out_links_)
982 for (int l : links)
983 if (l < 0 || l >= nl)
984 throw std::runtime_error("Invalid LARD checkpoint link.");
985 for (auto v : flow_sign_)
986 if (v < -1 || v > 1)
987 throw std::runtime_error("Invalid LARD flow direction.");
988 for (const auto* v : {&node_mass_in_, &node_vol_in_})
989 for (double x : *v)
990 if (!std::isfinite(x))
991 throw std::runtime_error("Nonfinite LARD inflow receipt.");
992 }
1025 void applyFluxesAndBed(SimulationContext& ctx, double dt) {
1026 const SpeciesRowLayout L = rowLayout(ctx);
1027 const bool heat_on = (L.temp_row >= 0);
1028 const bool bed_on = transport::heat::bedExchangeEnabled(ctx);
1029 const bool flux_on =
1030 heat_on && (ctx.heat_config.surface_exchange || ctx.heat_config.radiative_exchange);
1031 if ((!flux_on && !bed_on) || !(dt > 0.0))
1032 return;
1033 namespace th = transport::heat;
1034
1035 if (flux_on)
1036 th::updateSolarForcing(ctx, dt);
1037 if (bed_on)
1038 th::seedBedTemperature(ctx);
1039 const double t_gr = bed_on ? th::groundTemperature(ctx) : 0.0;
1040 const double rho = ctx.options.water_density;
1041 const double cp = ctx.options.water_specific_heat;
1042 constexpr double kSqFt = 0.09290304;
1043 constexpr double kCuFt = 0.028316846592;
1044
1045 auto& hs = ctx.heat_state;
1046 const int nl = ctx.n_links();
1047 const int nn = ctx.n_nodes();
1048
1049 // ---- Nodes: storage surfaces only, HeatFluxes.cpp's convention. --
1050 if (flux_on) {
1051 const int unit_sys = ucf::getUnitSystem(static_cast<int>(ctx.options.flow_units));
1052 for (int n = 0; n < nn; ++n) {
1053 const auto un = static_cast<std::size_t>(n);
1054 if (un >= hs.node_temp.size())
1055 break;
1056 const double vol = node::storeVolume(ctx.nodes, n);
1057 if (!(vol > 0.0))
1058 continue;
1059 const double a = node::getSurfArea(ctx.nodes, n, ctx.nodes.depth[un], &ctx.tables,
1060 unit_sys, &ctx.node_subtypes);
1061 if (!(a > 0.0))
1062 continue;
1063 const double t = hs.node_temp[un];
1064 const HeatElement ne = HeatElement::node(n);
1065 hs.node_temp[un] +=
1066 th::relaxT(th::netFluxOut(ctx, ne, t), th::netFluxOut(ctx, ne, t + th::kProbeC),
1067 th::kProbeC, a * kSqFt, vol * kCuFt, dt, rho, cp);
1068 }
1069 }
1070
1071 // ---- Links: segment field, uniform increments. -------------------
1072 // Bed rows: pollutants then MSX — every row before age/temp.
1073 const int n_bed = L.np + ((L.msx_first >= 0) ? (L.ns - L.msx_first) : 0);
1074 auto& bed = ctx.bed_state;
1075 if (bed_on && n_bed > 0 &&
1076 (bed.n_species != n_bed ||
1077 bed.link_conc.size() !=
1078 static_cast<std::size_t>(nl) * static_cast<std::size_t>(n_bed))) {
1079 bed.link_conc.assign(static_cast<std::size_t>(nl) * static_cast<std::size_t>(n_bed),
1080 0.0);
1081 bed.n_species = n_bed;
1082 }
1083
1084 for (int l = 0; l < nl; ++l) {
1085 const auto ul = static_cast<std::size_t>(l);
1086 if (ul < ctx.heat_state.mesh_contact_link.size() &&
1087 ctx.heat_state.mesh_contact_link[ul])
1088 continue;
1089 if (ctx.links.type[ul] != LinkType::CONDUIT)
1090 continue;
1091 const int cnt = store_.count(l);
1092 if (cnt <= 0)
1093 continue;
1094 const double vol_ft3 = store_.total_volume(l);
1095 if (!(vol_ft3 > 0.0))
1096 continue;
1097
1098 store_.mean_conc(l, scratch_.data());
1099 // PE1: every parcel of a conduit shares the conduit's
1100 // attributes (D-PE1) — the bed and the shading belong to the
1101 // pipe, not to the water passing through it.
1102 const HeatElement le = HeatElement::link(l);
1103
1104 // Heat: one pair/relaxation against the mean, uniform dT.
1105 if (heat_on) {
1106 const double t_mean = scratch_[static_cast<std::size_t>(L.temp_row)];
1107 const double surf_m2 = flux_on ? th::linkFreeSurfaceFt2(ctx, l) * kSqFt : 0.0;
1108 double d_tw = 0.0;
1109 bool bed_stepped = false;
1110 if (bed_on && ul < bed.link_temp.size()) {
1111 // PE2: this link's own bed material and boundary.
1112 const auto& sd = th::sedimentFor(ctx, le);
1113 const th::BedCoupling g =
1114 th::bedCouplingFromContact(ctx, sd, th::linkBedAreaM2(ctx, l), vol_ft3,
1115 th::groundTempFor(ctx, sd, t_gr));
1116 if (g.viable()) {
1117 const th::PairStep ps = th::relaxPair(
1118 g, t_mean, bed.link_temp[ul],
1119 flux_on ? th::netFluxOut(ctx, le, t_mean) : 0.0,
1120 flux_on ? th::netFluxOut(ctx, le, t_mean + th::kProbeC) : 0.0,
1121 flux_on ? th::kProbeC : 0.0, surf_m2, dt);
1122 d_tw = ps.dt_w;
1123 bed.link_temp[ul] += ps.dt_b;
1124 bed_stepped = true;
1125 }
1126 }
1127 if (!bed_stepped && flux_on && surf_m2 > 0.0) {
1128 d_tw = th::relaxT(th::netFluxOut(ctx, le, t_mean),
1129 th::netFluxOut(ctx, le, t_mean + th::kProbeC), th::kProbeC,
1130 surf_m2, vol_ft3 * kCuFt, dt, rho, cp);
1131 }
1132 if (d_tw != 0.0)
1133 for (int i = 0; i < cnt; ++i)
1134 store_.set_seg_conc(l, i, L.temp_row,
1135 store_.seg_conc(l, i, L.temp_row) + d_tw);
1136 }
1137
1138 // Solutes: pair against the mean, uniform dc, per bed row.
1139 if (bed_on && n_bed > 0) {
1140 // PE2: same per-link config the heat pair above used, so the
1141 // two halves of one bed cannot disagree about its material.
1142 const auto& sd = th::sedimentFor(ctx, le);
1143 const double bed_m2 = th::linkBedAreaM2(ctx, l);
1144 const double vol_b = bed_m2 * sd.bed_thickness;
1145 const double q_exch = th::bedExchangeQ(sd, bed_m2);
1146 if (q_exch > 0.0 && vol_b > 0.0) {
1147 for (int b = 0; b < n_bed; ++b) {
1148 // Bed row b: pollutants 0..np-1 map directly, MSX
1149 // rows at msx_first + (b - np).
1150 const int row = (b < L.np) ? b : L.msx_first + (b - L.np);
1151 const double c_mean = scratch_[static_cast<std::size_t>(row)];
1152 double& cb = bed.link_conc[ul * static_cast<std::size_t>(n_bed) +
1153 static_cast<std::size_t>(b)];
1154 const th::SolutePairStep st =
1155 th::exchangePair(c_mean, cb, vol_ft3 * kCuFt, vol_b, q_exch, dt);
1156 if (st.dc_w != 0.0)
1157 for (int i = 0; i < cnt; ++i)
1158 store_.set_seg_conc(l, i, row,
1159 store_.seg_conc(l, i, row) + st.dc_w);
1160 cb += st.dc_b;
1161 }
1162 }
1163 }
1164 }
1165 }
1166
1180 void init(SimulationContext& ctx) {
1181
1182 const SpeciesRowLayout L = rowLayout(ctx);
1183 const int np = L.np;
1184 const int ns = L.ns;
1185 // Derived from the layout rather than re-read from options, so the
1186 // "is there a reserved row" question has exactly one answer per call.
1187 const bool age = (L.age_row >= 0);
1188 const bool heat = (L.temp_row >= 0);
1189 const int nn = ctx.n_nodes();
1190 const int nl = ctx.n_links();
1191 // X3a: slab capacity from [OPTIONS] MAX_SEGMENTS_PER_LINK, floored
1192 // at 2 (one segment to hold, one to receive).
1193 store_.resize(nl, ns, std::max(2, ctx.options.max_segments_per_link));
1194 flow_sign_.assign(static_cast<std::size_t>(nl), 1);
1195 node_mass_in_.assign(static_cast<std::size_t>(nn) * static_cast<std::size_t>(ns), 0.0);
1196 node_vol_in_.assign(static_cast<std::size_t>(nn), 0.0);
1197 scratch_.assign(static_cast<std::size_t>(ns), 0.0);
1198 treat_cin_.assign(static_cast<std::size_t>(np > 0 ? np : 0), 0.0);
1199 node_out_links_.assign(static_cast<std::size_t>(nn), {});
1200 release_vol_.assign(static_cast<std::size_t>(nl), 0.0);
1201 if (ctx.options.lard_rwpt)
1202 rwpt_.resize(nl);
1203
1204 // L3: size + seed the shared MSX element state (GLOBAL fill +
1205 // [REACTION_QUALITY] overrides) with the SAME spelling the
1206 // LEGACY dispatch uses, then let the seeded link values seed
1207 // the segments below like every other row.
1208 if (L.msx_first >= 0)
1210
1211 // X4 age seeding, the ARD precedent: a hotstart-loaded state wins
1212 // (node_age/link_age already carry the restored values, A2a);
1213 // otherwise a configured INITIAL_STATE age fills the network.
1214 // Restored/seeded link ages then seed the segments below — the
1215 // same within-link profile collapse A2a recorded for ARD
1216 // (lesson 37): continuous, not bit-continuous.
1217 if (age) {
1218 auto& ws = ctx.water_age_state;
1219 if (ws.node_age.size() != static_cast<std::size_t>(nn))
1220 ws.resize(nn, nl, ctx.n_subcatches());
1221 if (!ws.hotstart_loaded) {
1222 const double a0 = ctx.water_age_config
1223 .global_age[static_cast<int>(WaterAgeSource::INITIAL_STATE)];
1224 if (a0 > 0.0) {
1225 std::fill(ws.node_age.begin(), ws.node_age.end(), a0);
1226 std::fill(ws.link_age.begin(), ws.link_age.end(), a0);
1227 }
1228 }
1229 // E-A3: [INITIAL_QUALITY] __WATER_AGE__ rows override the
1230 // global fill; the helper no-ops under hotstart (D-IQ7). The
1231 // per-link values then seed the segments below.
1233 }
1234
1235 // H7b temperature seeding, the routeLegacyHeat convention: size the
1236 // state if the loaders have not already, fill with the configured
1237 // INITIAL_STATE once (legacy_seeded — shared with the LEGACY mirror
1238 // so a fallback path never re-seeds), then let per-element
1239 // [INITIAL_QUALITY] __TEMPERATURE__ rows override. The seeded link
1240 // temperatures seed the segments below, the same within-link
1241 // profile collapse the age row records (lesson 37): continuous,
1242 // not bit-continuous. Hotstart does NOT restore temperature — no
1243 // engine's does, the record has no field for it (owed; see the
1244 // H7b round record) — so a restarted run re-seeds from
1245 // INITIAL_STATE exactly as LEGACY and ARD do.
1246 if (heat) {
1247 auto& hstate = ctx.heat_state;
1248 const double t0 =
1249 ctx.heat_config.global_temp[static_cast<int>(HeatSource::INITIAL_STATE)];
1250 if (hstate.node_temp.size() != static_cast<std::size_t>(nn))
1251 hstate.resize(nn, nl, t0);
1252 if (!hstate.legacy_seeded) {
1253 std::fill(hstate.node_temp.begin(), hstate.node_temp.end(), t0);
1254 std::fill(hstate.link_temp.begin(), hstate.link_temp.end(), t0);
1256 hstate.legacy_seeded = true;
1257 }
1258 }
1259
1260 // Seed: one segment per conduit at the link's current volume and
1261 // (initQuality-seeded) concentration — a dry link seeds nothing.
1262 for (int l = 0; l < nl; ++l) {
1263 const auto ul = static_cast<std::size_t>(l);
1264 store_.clear_link(l);
1265 if (ctx.links.type[ul] != LinkType::CONDUIT)
1266 continue;
1267 const double v = ctx.links.volume[ul];
1268 if (v <= 0.0)
1269 continue;
1270 for (int p = 0; p < np; ++p)
1271 scratch_[static_cast<std::size_t>(p)] =
1272 ctx.links.conc[ul * static_cast<std::size_t>(np) + static_cast<std::size_t>(p)];
1273 if (age)
1274 scratch_[static_cast<std::size_t>(L.age_row)] = ctx.water_age_state.link_age[ul];
1275 if (heat)
1276 scratch_[static_cast<std::size_t>(L.temp_row)] = ctx.heat_state.link_temp[ul];
1277 if (L.msx_first >= 0) { // L3
1278 const int nsp = ns - L.msx_first;
1279 for (int sp = 0; sp < nsp; ++sp)
1280 scratch_[static_cast<std::size_t>(L.msx_first + sp)] =
1281 ctx.reactions.msx_link_conc[ul * static_cast<std::size_t>(nsp) +
1282 static_cast<std::size_t>(sp)];
1283 }
1284 store_.push_front(l, v, scratch_.data());
1285 flow_sign_[ul] = (ctx.links.flow[ul] >= 0.0) ? 1 : -1;
1286 }
1287 computeTopoOrder(ctx);
1288 initialized_ = true;
1289 }
1290
1298 int upstreamNode(const SimulationContext& ctx, int l) const {
1299 const auto ul = static_cast<std::size_t>(l);
1300 return (flow_sign_[ul] >= 0) ? ctx.links.node1[ul] : ctx.links.node2[ul];
1301 }
1309 int downstreamNode(const SimulationContext& ctx, int l) const {
1310 const auto ul = static_cast<std::size_t>(l);
1311 return (flow_sign_[ul] >= 0) ? ctx.links.node2[ul] : ctx.links.node1[ul];
1312 }
1313
1327 void addToLedger(int n, double vol, const double* mass, int np) {
1328 const auto un = static_cast<std::size_t>(n);
1329 node_vol_in_[un] += vol;
1330 for (int p = 0; p < np; ++p)
1331 node_mass_in_[un * static_cast<std::size_t>(np) + static_cast<std::size_t>(p)] +=
1332 mass[static_cast<std::size_t>(p)];
1333 }
1349 void addToLedgerRate(int n, double vol, const double* conc, int np) {
1350 const auto un = static_cast<std::size_t>(n);
1351 node_vol_in_[un] += vol;
1352 for (int p = 0; p < np; ++p)
1353 node_mass_in_[un * static_cast<std::size_t>(np) + static_cast<std::size_t>(p)] +=
1354 vol * conc[static_cast<std::size_t>(p)];
1355 }
1356
1371 void computeTopoOrder(SimulationContext& ctx) {
1372 const int nn = ctx.n_nodes();
1373 const int nl = ctx.n_links();
1374 std::vector<int> indeg(static_cast<std::size_t>(nn), 0);
1375 for (auto& v : node_out_links_)
1376 v.clear();
1377 for (int l = 0; l < nl; ++l) {
1378 const auto ul = static_cast<std::size_t>(l);
1379 const int up = upstreamNode(ctx, l);
1380 const int dn = downstreamNode(ctx, l);
1381 if (up < 0 || dn < 0)
1382 continue;
1383 node_out_links_[static_cast<std::size_t>(up)].push_back(l);
1384 if (std::abs(ctx.links.flow[ul]) > kTinyFlow)
1385 indeg[static_cast<std::size_t>(dn)] += 1;
1386 }
1387 topo_.clear();
1388 topo_.reserve(static_cast<std::size_t>(nn));
1389 std::vector<int> q;
1390 for (int n = 0; n < nn; ++n)
1391 if (indeg[static_cast<std::size_t>(n)] == 0)
1392 q.push_back(n);
1393 std::vector<char> seen(static_cast<std::size_t>(nn), 0);
1394 std::size_t qi = 0;
1395 while (qi < q.size()) {
1396 const int n = q[qi++];
1397 if (seen[static_cast<std::size_t>(n)])
1398 continue;
1399 seen[static_cast<std::size_t>(n)] = 1;
1400 topo_.push_back(n);
1401 for (const int l : node_out_links_[static_cast<std::size_t>(n)]) {
1402 if (std::abs(ctx.links.flow[static_cast<std::size_t>(l)]) <= kTinyFlow)
1403 continue;
1404 const int dn = downstreamNode(ctx, l);
1405 if (dn >= 0 && --indeg[static_cast<std::size_t>(dn)] == 0)
1406 q.push_back(dn);
1407 }
1408 }
1409 for (int n = 0; n < nn; ++n) // cycle leftovers
1410 if (!seen[static_cast<std::size_t>(n)])
1411 topo_.push_back(n);
1412 }
1413
1414 SegmentStore store_;
1415 RwptDispersion rwpt_;
1416 std::vector<double> release_vol_;
1417 std::uint64_t substep_counter_ = 0;
1418 std::vector<int> topo_;
1419 std::vector<std::vector<int>> node_out_links_;
1420 std::vector<std::int8_t> flow_sign_;
1421 std::vector<double> node_mass_in_;
1422 std::vector<double> node_vol_in_;
1423 std::vector<double> scratch_;
1427 std::vector<double> treat_cin_;
1428 std::vector<double> msx_block_;
1429 std::vector<double> msx_poll_;
1430 bool initialized_ = false;
1431};
1432
1433} // namespace lard
1434} // namespace openswmm
1435
1436#endif // OPENSWMM_QUALITY_LARD_LAGRANGIAN_SOLVER_HPP
Plan H6b — bed conduction, deep-ground conduction, and hyporheic exchange, integrated SIMULTANEOUSLY ...
Versioned archives of completed source-boundary state.
Plan D-H5e — the single node/link surface-flux binding.
Plan PE — resolve per-element attributes, and read them.
[INITIAL_QUALITY] reserved-species (WATER_AGE/__TEMPERATURE__) helpers for the per-engine seed sites ...
D-NS1: one clamp-bookkeeping seam for all three quality engines.
Node hydraulics — volume/depth/head conversions, surface area, overflow.
Water quality routing — constituent transport, mixing, decay.
Geometric quantities required by reaction components.
Phase R4 — reaction binding for the LEGACY (CSTR) quality engine.
Resolution and application of species reaction source terms.
X3b: RWPT longitudinal dispersion on LARD segments.
LARD segment slabs — per-link ring buffers of plug-flow segments.
The central, reentrant simulation context for the new engine.
Solar radiation inputs and calculations for thermal components.
The Domain × Species-class transport contract (OPT plan §5, E2).
Global unit conversion factors — matching legacy SWMM Ucf[]/Qcf[].
const std::string & name_of(int idx) const
Borrow a registered name by its ordered index.
Definition NameIndex.hpp:186
Definition CompletedSourceArchive.hpp:25
const std::string & bytes() const
Borrow the complete owned byte buffer.
Definition CompletedSourceArchive.hpp:51
Definition LagrangianSolver.hpp:195
void publish(SimulationContext &ctx, double dt_routing)
Per-routing-step publication: link means + the conc_old convention. dt_routing ages the held state of...
Definition LagrangianSolver.hpp:882
std::string applyThermalIncrements(SimulationContext &ctx, const std::vector< ChannelThermalIncrement > &updates)
Add caller channel temperature increments uniformly to represented segments then republish.
Definition LagrangianSolver.hpp:383
void step(SimulationContext &ctx, double dt_routing)
One routing step of LTD transport. Lazily initializes on the first call (needs router-set volumes,...
Definition LagrangianSolver.hpp:419
void restoreCheckpointState(const std::string &bytes, SimulationContext &ctx)
Restore solver-owned completed-source state against a matching prepared context.
Definition LagrangianSolver.hpp:227
bool initialized() const noexcept
Read whether this segment owner completed initialization.
Definition LagrangianSolver.hpp:241
std::vector< ChannelThermalRegion > thermalRegions(const SimulationContext &ctx) const
Export one mean-temperature channel region per wet represented link.
Definition LagrangianSolver.hpp:253
std::string applySpeciesIncrements(SimulationContext &ctx, const std::vector< ChannelSpeciesIncrement > &updates)
Apply caller concentration deltas to represented nonthermal channel rows then republish.
Definition LagrangianSolver.hpp:325
void substep(SimulationContext &ctx, double dt, double frac)
One LTD substep: AGE → DRAIN → MIX(+passthrough) → RELEASE → DECAY.
Definition LagrangianSolver.hpp:498
std::vector< ChannelSpeciesRegion > speciesRegions(const SimulationContext &ctx) const
Export mean carried-species tuples for each wet represented channel link.
Definition LagrangianSolver.hpp:279
std::string checkpointState() const
Export a validated completed-source snapshot of solver-owned segment/particle/topology state.
Definition LagrangianSolver.hpp:207
void checkpoint(detail::CompletedSourceArchive &a)
Serialize/restore fixed particle state then validate position/ordinate values.
Definition RwptDispersion.hpp:255
void checkpoint(detail::CompletedSourceArchive &a)
Serialize or restore fixed-sized segment ring arrays and validate restored numerical state.
Definition SegmentStore.hpp:82
Definition LagrangianSolver.hpp:125
double rwpt_hyd_radius(double area, double depth, double diam, bool circular)
Hydraulic radius: exact for CIRCULAR from flow area, ≈ depth otherwise (wide-channel v1 approximation...
Definition RwptDispersion.hpp:226
constexpr double kTinyFlow
cfs; below this a link moves nothing
Definition LagrangianSolver.hpp:127
SpeciesRowLayout rowLayout(const SimulationContext &ctx)
The single source of truth for the row layout. Reserved rows are appended after the pollutants in a f...
Definition LagrangianSolver.hpp:169
double getSurfArea(const NodeData &nodes, int idx, double depth, TableData *tables, int unit_sys, const NodeSubtypes *subs)
Evaluate the raw derivative of storage volume with depth.
Definition Node.cpp:335
double storeVolume(const NodeData &nodes, int idx, double depth, double volume)
Evaluate a transport store using an explicit depth and booked volume.
Definition Node.cpp:460
constexpr double ZERO_VOLUME
1 liter in ft3
Definition QualityRouting.hpp:58
bool nodeIsReactor(const SimulationContext &ctx, int node)
Select storage or retained-water node mixing.
Definition QualityRouting.cpp:145
void bookNegativeAgeClamp(SimulationContext &ctx, int node)
Age-row clamp: counted, not ledgered (no age row until A2c) and not warned per-clamp (see bookNegativ...
Definition NegativeSources.hpp:99
void bookNegativeSourceClamp(SimulationContext &ctx, int node, int p, double shortfall)
Book one pollutant-row clamp: count it and un-book the shortfall from the external-extraction ledger ...
Definition NegativeSources.hpp:71
void updateUntreatedStorageHrt(SimulationContext &ctx, double dt)
Advance residence time only for untreated storage nodes under active native reactions.
Definition QualityRouting.cpp:204
void applyNodeTreatment(SimulationContext &ctx, int j, double dt, double q_raw, const double *cin)
Resolve one node's compiled co-treatment laws and book accepted pollutant removal.
Definition QualityRouting.cpp:2078
void fillChannelThermalGeometry(const SimulationContext &ctx, ChannelThermalRegion &region) noexcept
Publish native conduit thermal region geometry in SI.
Definition BedExchange.cpp:388
bool bedExchangeEnabled(const SimulationContext &ctx) noexcept
Test current native finite-bed heat family process enable.
Definition BedExchange.cpp:251
ClassEnables network1DEnables(const SimulationContext &ctx) noexcept
Resolve allocation-free native network class counts from current project policy.
Definition TransportPolicy.cpp:133
void ensureMsxState(SimulationContext &ctx)
Lazily size + seed the MSX element state; warn once about the R4b transport limitation when any RATE ...
Definition ReactionLegacyBinding.cpp:95
void applyInitialTempOverrides(SimulationContext &ctx)
Apply TEMPERATURE rows onto heat_state.node_temp/link_temp (degC). No-op on unsized arrays.
Definition InitialQualitySeeds.hpp:147
void applyInitialAgeOverrides(SimulationContext &ctx)
Apply WATER_AGE rows onto water_age_state.node_age/link_age (hours -> seconds). No-op when a hotstart...
Definition InitialQualitySeeds.hpp:118
bool legacyReactionsActive(const SimulationContext &ctx)
Test configured and compiled native reaction readiness.
Definition ReactionLegacyBinding.cpp:413
double reactionBoosterMass(SimulationContext &ctx, int node, int species, double mass, double mixed_volume, double arriving_volume)
Compute and book the first matching node/species booster inventory addition.
Definition ReactionSources.hpp:60
void reactSpeciesBlock(SimulationContext &ctx, bool tank, double dt, double *species_block, const double *pollut, double hrt_seconds, double temp_c, int element)
React one LARD-owned tuple with held HRT/temperature and optional element geometry.
Definition ReactionLegacyBinding.cpp:377
int getUnitSystem(int flow_units)
Determine unit system (0=US, 1=SI) from flow units.
Definition UnitConversion.cpp:33
Definition ExchangeLaw.hpp:60
@ CONDUIT
Definition LinkData.hpp:58
@ OUTFALL
Definition NodeData.hpp:65
@ INITIAL_STATE
water in the network at t = 0
Definition WaterAgeData.hpp:59
@ CIRCULAR
Definition LinkData.hpp:65
@ INITIAL_STATE
water in the network at t = 0
Definition HeatData.hpp:79
Definition ChannelThermalData.hpp:32
int link
Definition ChannelThermalData.hpp:33
std::vector< std::string > row_names
Definition ChannelThermalData.hpp:35
std::vector< double > concentration
Definition ChannelThermalData.hpp:36
int region
Definition ChannelThermalData.hpp:33
double volume
Definition ChannelThermalData.hpp:34
static HeatElement link(int i)
Construct a LINK heat-element descriptor.
Definition HeatOverrideData.hpp:112
static HeatElement node(int i)
Construct a NODE heat-element descriptor.
Definition HeatOverrideData.hpp:105
std::vector< double > node_temp
[node], °C
Definition HeatData.hpp:438
std::vector< double > k_decay
First-order decay coefficient (1/sec). The [POLLUTANTS] Kdecay column is 1/day: the parser divides by...
Definition PollutantData.hpp:96
std::vector< std::string > species_name
cold
Definition ReactionData.hpp:103
std::vector< double > msx_ext_mass_in
Definition ReactionData.hpp:300
std::vector< double > msx_node_conc
Definition ReactionData.hpp:289
std::vector< double > msx_link_conc
Definition ReactionData.hpp:290
std::vector< double > qual_routing_reacted
Quality mass lost to decay.
Definition SimulationContext.hpp:1324
Central, reentrant simulation context.
Definition SimulationContext.hpp:390
struct openswmm::SimulationContext::MassBalance mass_balance
LinkSubtypes link_subtypes
Relational per-subtype link side-tables (Phase 6) — the link analogue of node_subtypes....
Definition SimulationContext.hpp:588
ReactionData reactions
Multispecies reaction system (EPANET-MSX conventions), parsed from the reactions component's config f...
Definition SimulationContext.hpp:624
int n_nodes() const noexcept
Number of nodes.
Definition SimulationContext.hpp:2357
NodeData nodes
All node state and properties.
Definition SimulationContext.hpp:563
WaterAgeState water_age_state
Definition SimulationContext.hpp:640
TreatmentData treatment
Definition SimulationContext.hpp:764
LinkData links
All link state and properties.
Definition SimulationContext.hpp:580
int n_links() const noexcept
Number of links.
Definition SimulationContext.hpp:2360
HeatState heat_state
Definition SimulationContext.hpp:656
NameIndex pollutant_names
Pollutant name → pollutant index.
Definition SimulationContext.hpp:748
SimulationOptions options
Parsed simulation options (from [OPTIONS] section).
Definition SimulationContext.hpp:452
PollutantData pollutants
Pollutant definitions and per-object quality state.
Definition SimulationContext.hpp:606
bool outfall_backflow_zero
[OPTIONS] OUTFALL_BACKFLOW_QUALITY LAST|ZERO — quality carried by reverse flow at outfalls (false = L...
Definition SimulationOptions.hpp:338
int rwpt_seed
[OPTIONS] RWPT_SEED — deterministic counter-RNG seed (D-L6). Same seed ⇒ bit-identical runs at any th...
Definition SimulationOptions.hpp:306
bool lard_rwpt
[OPTIONS] DISPERSION RWPT|OFF — LARD RWPT dispersion (X3b; strategy §5; the GUI plan's Lagrangian-gro...
Definition SimulationOptions.hpp:300
double quality_step
[OPTIONS] QUALITY_STEP — transport substep, seconds (HH:MM:SS or seconds; 0 = follow ROUTING_STEP).
Definition SimulationOptions.hpp:280
std::vector< bool > has_treatment
Per-node flag: true if any pollutant has a treatment expression.
Definition QualityData.hpp:208
The segment store's species-row layout, computed in ONE place.
Definition LagrangianSolver.hpp:149
int np
pollutant rows occupy [0, np)
Definition LagrangianSolver.hpp:150
int temp_row
temperature row index, or -1 (H7b)
Definition LagrangianSolver.hpp:152
int ns
total rows the store carries
Definition LagrangianSolver.hpp:155
int age_row
water-age row index, or -1 when absent
Definition LagrangianSolver.hpp:151
int msx_first
Definition LagrangianSolver.hpp:153
Allocation-free class enables for one domain — what the engines size from. n_msx is the reactions com...
Definition TransportPolicy.hpp:110
bool temperature
Definition TransportPolicy.hpp:114
bool age
Definition TransportPolicy.hpp:113
int n_pollut
Definition TransportPolicy.hpp:111
int n_msx
Definition TransportPolicy.hpp:112
Phase H2 — latent and sensible heat exchange at the water surface (heat plan §2.1; CSHComponent §4....