#include #include #include #include #include #include #include #include #include #include #include #include #include #include "analysis/thermal_transient_algebra.h" #include "fesa/analysis/transient_heat_procedure.h" #include "fesa/solvers/linear/mkl_pardiso_solver.h" #include "thermal_gauss_oracle.h" #include "thermal_slab_oracle.h" #include "fesa/assembly/thermal_assembler.h" namespace { namespace ti = fesa::thermal_transient_internal; using fesa::Vector; using fesa::Vector3; // Independent test arithmetic: outward endpoints after each scalar operation. // No production interval, kernel, transcendental, or tolerance helper is used // to construct the analytical expectations. double Down(double x) { return std::nextafter(x, -std::numeric_limits::infinity()); } double Up(double x) { return std::nextafter(x, std::numeric_limits::infinity()); } struct I { double lo, hi; I(double x = 0) : lo(x), hi(x) {} I(double lo, double hi) : lo(lo), hi(hi) {} double Mid() const { return lo + (hi - lo) * .5; } double Abs() const { return std::max(std::abs(lo), std::abs(hi)); } }; I operator+(I a, I b) { if (a.lo == 0 && a.hi == 0) return b; if (b.lo == 0 && b.hi == 0) return a; return {Down(a.lo + b.lo), Up(a.hi + b.hi)}; } I operator-(I a, I b) { return a + I{-b.hi, -b.lo}; } I operator*(I a, I b) { if ((a.lo == 0 && a.hi == 0) || (b.lo == 0 && b.hi == 0)) return I{0}; const std::array v{a.lo * b.lo, a.lo * b.hi, a.hi * b.lo, a.hi * b.hi}; return {Down(*std::min_element(v.begin(), v.end())), Up(*std::max_element(v.begin(), v.end()))}; } I operator/(I a, I b) { if (b.lo <= 0 && b.hi >= 0) throw std::runtime_error{"Oracle division includes zero."}; return a * I{Down(1 / b.hi), Up(1 / b.lo)}; } I Sqrt(I a) { if (a.lo < 0) throw std::runtime_error{"Oracle square root includes negative values."}; if (a.hi == 0) return I{0}; return {a.lo == 0 ? 0 : Down(std::sqrt(a.lo)), Up(std::sqrt(a.hi))}; } I Square(I x) { if (x.lo == 0 && x.hi == 0) return I{0}; const double lower = x.lo <= 0 && x.hi >= 0 ? 0 : Down(std::min(x.lo * x.lo, x.hi * x.hi)); return {lower, Up(x.Abs() * x.Abs())}; } I Gamma(std::size_t n) { return (I{static_cast(n)} * I{0x1p-53}) / (I{1} - I{static_cast(n)} * I{0x1p-53}); } I Pi() { static const I value = [] { // Machin's formula; alternating arctan remainder after terms 0..63. const auto atan = [](double denominator) { const I q = I{1} / I{denominator}, q2 = q * q; I power = q, sum; for (int j = 0; j < 64; ++j) { sum = sum + power / I{static_cast(2 * j + 1)}; power = power * q2 * I{-1}; } const double tail = (power / I{129}).Abs(); return sum + I{-tail, tail}; }; return I{16} * atan(5) - I{4} * atan(239); }(); return value; } I Cos(I x) { if (x.hi < 0) x = I{-x.hi, -x.lo}; if (x.lo > Pi().hi / 2) { const auto reduced = Cos(Pi() - x); return I{-reduced.hi, -reduced.lo}; } // |x|<=pi<4. Thirty-two terms; the omitted absolute term bounds the // alternating tail after decreasing terms begin. All polynomial work is // enclosed independently of a libm accuracy assumption. I term{1}, sum{1}; const I x2 = x * x; for (int j = 1; j < 32; ++j) { term = term * x2 * I{-1} / I{static_cast((2 * j - 1) * 2 * j)}; sum = sum + term; } const double tail = (term * x2 / I{63. * 64.}).Abs(); return sum + I{-tail, tail}; } I ExpNegative(I x) { if (x.lo < 0) throw std::runtime_error{"Negative exponential argument."}; int squares = 0; while (x.hi > .5) { x = x / I{2}; ++squares; } I term{1}, sum{1}; for (int j = 1; j <= 64; ++j) { term = term * x * I{-1} / I{static_cast(j)}; sum = sum + term; } const double tail = (term * x / I{65}).Abs(); sum = sum + I{-tail, tail}; for (int j = 0; j < squares; ++j) sum = sum * sum; return sum; } I Power(I x, std::uint32_t n) { I result{1}; for (; n; n >>= 1) { if (n & 1U) result = result * x; if (n > 1) x = x * x; } return result; } template T Take(fesa::Result r) { if (!r.HasValue()) { std::ostringstream text; for (const auto& d : r.GetStatus().Diagnostics()) text << d.code << ": " << d.message << '\n'; throw std::runtime_error{text.str()}; } return std::move(r.Value()); } void Checked(const fesa::Status& status) { if (!status.IsOk()) { std::ostringstream text; for (const auto& d : status.Diagnostics()) text << d.code << ": " << d.message << '\n'; throw std::runtime_error{text.str()}; } } fesa::ModelDefinition Mesh( std::size_t n, double period, std::uint32_t count, const std::function& initial) { fesa::ModelDefinition m; for (std::size_t j = 0; j <= n; ++j) for (std::size_t i = 0; i <= n; ++i) { const auto index = static_cast(j * (n + 1) + i); m.nodes.push_back( {{"plate", static_cast(index + 1), std::to_string(index + 1)}, {static_cast(i) / n, static_cast(j) / n, 0}, {}}); m.thermal_initial_conditions.push_back({index, initial(i, j), {}}); } m.thermal_materials = {{{"plate", 1, "1"}, "unit", 1, 1, 1, {}}}; m.thermal_sections = {{{"plate", 1, "1"}, "unit", 1, 0, {}}}; for (std::size_t j = 0; j < n; ++j) for (std::size_t i = 0; i < n; ++i) { const auto node = static_cast(j * (n + 1) + i); const auto label = static_cast(j * n + i + 1); m.thermal_elements.push_back( {{"plate", label, std::to_string(label)}, {{node, node + 1, static_cast(node + n + 2), static_cast(node + n + 1)}}, 0, {}}); } m.thermal_step = fesa::ThermalStepDefinition{"physics", period / count, period, count}; return m; } struct Prepared { fesa::Domain domain; fesa::AnalysisModel model; fesa::DofManager dofs; fesa::ThermalAssembly assembly; fesa::ThermalAnalysisState state; Prepared(fesa::ModelDefinition definition, const fesa::ParallelFor& parallel) : domain(Take(fesa::Domain::Create(std::move(definition)))), model(Take(fesa::AnalysisModel::Create(domain))), dofs(Take(fesa::DofManager::CreateThermal(model))), assembly(Take(fesa::AssembleThermal(model, dofs, parallel))), state(Take(fesa::ThermalAnalysisState::Create(model, dofs, assembly))) { } }; struct RunMetrics { double beta = 0, full_beta = 0, capacity_budget = 0; double energy_defect = 0, energy_bound = 0, dissipation_defect = 0, dissipation_bound = 0; std::uint64_t temperature_values = 0, flux_values = 0; std::vector steps; double last_free_residual_l1 = 0, last_free_residual_inf = 0, last_reaction_evaluation_error = 0; }; RunMetrics Advance(Prepared& p, const fesa::ParallelFor& parallel, double mass_lower, double initial_capacity_budget = 0) { fesa::MklPardisoSolver solver; ti::ThermalFactorization cache; ti::ThermalSystem system{ p.assembly.Conductivity(), p.assembly.Capacity(), p.assembly.HeatInput(), &p.assembly.ConductivityError(), &p.assembly.CapacityError(), &p.assembly.HeatInputError()}; ti::ThermalPartition partition{p.dofs.FreeDofs(), p.dofs.ConstrainedDofs(), p.dofs.PrescribedValues()}; RunMetrics metrics; I budget{initial_capacity_budget}; const std::uint64_t count = p.state.TimeGrid().Count(); for (std::uint64_t step = 1; step <= count; ++step) { const auto schedule = Take(p.state.TimeGrid().Frame(static_cast(step))); const auto& old = p.state.CommittedState().temperature; auto next = Take(ti::SolveThermalIncrement(system, partition, old, schedule.delta, solver, cache)); const auto& e = next.evidence; metrics.steps.push_back(e); EXPECT_LE(e.beta_cert, e.beta_limit); EXPECT_LE(e.full_beta_cert, e.beta_limit); EXPECT_LE(e.energy_defect, e.energy_bound); if (e.dissipation_checked) EXPECT_LE(e.dissipation_defect, e.dissipation_bound); metrics.beta = std::max(metrics.beta, e.beta_cert); metrics.full_beta = std::max(metrics.full_beta, e.full_beta_cert); metrics.energy_defect = std::max(metrics.energy_defect, e.energy_defect); metrics.energy_bound = std::max(metrics.energy_bound, e.energy_bound); metrics.dissipation_defect = std::max(metrics.dissipation_defect, e.dissipation_defect); metrics.dissipation_bound = std::max(metrics.dissipation_bound, e.dissipation_bound); I residual_square; for (auto row : partition.free) residual_square = residual_square + Square(I{next.certified_residual[row]}); // Approved contraction for structured homogeneous error, using observed // component residual enclosures including E_C/E_K, never beta_limit. budget = budget + I{schedule.delta} * Sqrt(residual_square) / Sqrt(I{mass_lower}); if (step == count) { I free_l1, reaction_error; for (auto row : partition.free) { free_l1 = free_l1 + I{next.certified_residual[row]}; metrics.last_free_residual_inf = std::max( metrics.last_free_residual_inf, next.certified_residual[row]); } for (std::size_t c = 0; c < partition.constrained.size(); ++c) { const auto row = partition.constrained[c]; I residual = I{-system.q[row]}; for (auto entry = system.c.RowOffsets()[row]; entry < system.c.RowOffsets()[row + 1]; ++entry) { const auto col = system.c.ColumnIndices()[entry]; const I capacity = I{system.c.Values()[entry]} + I{-system.c_error->Values()[entry], system.c_error->Values()[entry]}; const I conductivity = I{system.k.Values()[entry]} + I{-system.k_error->Values()[entry], system.k_error->Values()[entry]}; residual = residual + capacity * ((I{next.temperature[col]} - I{old[col]}) / I{schedule.delta}) + conductivity * I{next.temperature[col]}; } residual = residual + I{-(*system.q_error)[row], (*system.q_error)[row]}; reaction_error = reaction_error + I{(residual - I{next.reaction[c]}).Abs()}; } metrics.last_free_residual_l1 = free_l1.hi; metrics.last_reaction_evaluation_error = reaction_error.hi; } auto flux = Take(p.assembly.RecoverFlux(next.temperature, parallel)); auto frame = Take(p.state.MakeCompletedFrame( static_cast(step), std::move(next.temperature), std::move(next.reaction), std::move(flux))); Checked(p.state.CommitAcceptedFrame(frame)); } metrics.capacity_budget = budget.hi; EXPECT_TRUE(p.state.IsComplete()); EXPECT_EQ(p.state.AcceptedFrames().size(), count + 1); EXPECT_FALSE(p.state.AcceptedFrames()[0].completed.has_value()); for (const auto& frame : p.state.AcceptedFrames()) { metrics.temperature_values += frame.temperature.Size(); if (frame.completed) metrics.flux_values += frame.completed->global_heat_flux.size() * 72U; } return metrics; } void Print(const char* name, std::size_t mesh, std::uint32_t count, const RunMetrics& m) { std::cout << std::setprecision(17) << "THERMAL_PHYSICS case=" << name << " n=" << mesh << " steps=" << count << " beta=" << m.beta << " physical_beta=" << m.full_beta << " capacity_budget=" << m.capacity_budget << " energy_defect=" << m.energy_defect << " energy_bound=" << m.energy_bound << " dissipation_defect=" << m.dissipation_defect << " dissipation_bound=" << m.dissipation_bound << " temperature_values=" << m.temperature_values << " flux_values=" << m.flux_values << '\n'; } // Exact tensor mass independently assembled from the rational Q1 and P2 // element integrals. This checks actual 2-D nodal/station vectors. I CapacityNorm(const Vector& values, std::size_t n) { constexpr int q[4][4] = { {4, 2, 1, 2}, {2, 4, 2, 1}, {1, 2, 4, 2}, {2, 1, 2, 4}}; constexpr int z[3][3] = {{4, 2, -1}, {2, 16, 2}, {-1, 2, 4}}; I sum; for (std::size_t y = 0; y < n; ++y) for (std::size_t x = 0; x < n; ++x) { const std::size_t a = y * (n + 1) + x; const std::array nodes{a, a + 1, a + n + 2, a + n + 1}; for (std::size_t i = 0; i < 4; ++i) for (std::size_t j = 0; j < 4; ++j) for (std::size_t segment = 0; segment < 2; ++segment) for (std::size_t u = 0; u < 3; ++u) for (std::size_t v = 0; v < 3; ++v) sum = sum + I{values[5 * nodes[i] + 2 * segment + u]} * I{values[5 * nodes[j] + 2 * segment + v]} * (I{static_cast(q[i][j] * z[u][v])} / I{2160. * n * n}); } return Sqrt(sum); } TEST(ThermalPhysics, InsulatedUniformAndZeroRetainAllFramesWithConservation) { fesa::SerialParallelFor parallel; for (double temperature : {0., 2., -2.}) { Prepared p{Mesh(2, .25, 8, [&](auto, auto) { return temperature; }), parallel}; const auto metrics = Advance(p, parallel, Down(1. / 8640)); Print("uniform", 2, 8, metrics); const double bound = (I{metrics.capacity_budget} / Sqrt(I{1. / 8640})).hi; for (const auto& frame : p.state.AcceptedFrames()) for (std::size_t i = 0; i < frame.temperature.Size(); ++i) EXPECT_LE(std::abs(frame.temperature[i] - temperature), bound); if (temperature == 0) { EXPECT_EQ(metrics.capacity_budget, 0); EXPECT_EQ(metrics.energy_defect, 0); EXPECT_EQ(metrics.dissipation_defect, 0); } EXPECT_EQ(metrics.flux_values, 8U * 4U * 72U); } } void VerifyDiscreteEigenmode(const Prepared& p, const std::array& mode, I lambda) { constexpr int mass[4][4] = { {4, 2, 1, 2}, {2, 4, 2, 1}, {1, 2, 4, 2}, {2, 1, 2, 4}}; constexpr int stiffness[4][4] = { {4, -1, -2, -1}, {-1, 4, -1, -2}, {-2, -1, 4, -1}, {-1, -2, -1, 4}}; constexpr int z_mass[3][3] = {{4, 2, -1}, {2, 16, 2}, {-1, 2, 4}}; constexpr int z_stiffness[3][3] = {{7, -8, 1}, {-8, 16, -8}, {1, -8, 7}}; std::array cv{}, kv{}; for (std::size_t y = 0; y < 8; ++y) for (std::size_t x = 0; x < 8; ++x) { const auto node = y * 9 + x; const std::array nodes{node, node + 1, node + 10, node + 9}; for (std::size_t i = 0; i < 4; ++i) for (std::size_t j = 0; j < 4; ++j) for (std::size_t segment = 0; segment < 2; ++segment) for (std::size_t a = 0; a < 3; ++a) for (std::size_t b = 0; b < 3; ++b) { const auto row = 5 * nodes[i] + 2 * segment + a; const I m = I{static_cast(mass[i][j])} / I{36 * 64.}; const I s = I{static_cast(stiffness[i][j])} / I{6}; const I c = I{static_cast(z_mass[a][b])} / I{60}; const I k = I{2. * z_stiffness[a][b]} / I{3}; cv[row] = cv[row] + m * c * mode[nodes[j] % 9]; kv[row] = kv[row] + (s * c + m * k) * mode[nodes[j] % 9]; } } const auto& c = p.assembly.Capacity(); const auto& k = p.assembly.Conductivity(); I independent_norm, actual_norm; double residual_bound = 0; const auto intersects = [](I a, I b) { EXPECT_LE(std::max(a.lo, b.lo), std::min(a.hi, b.hi)); }; for (std::size_t row = 0; row < 405; ++row) { I actual_c, actual_k; for (auto entry = c.RowOffsets()[row]; entry < c.RowOffsets()[row + 1]; ++entry) { const I v = mode[(c.ColumnIndices()[entry] / 5) % 9]; actual_c = actual_c + (I{c.Values()[entry]} + I{-p.assembly.CapacityError().Values()[entry], p.assembly.CapacityError().Values()[entry]}) * v; actual_k = actual_k + (I{k.Values()[entry]} + I{-p.assembly.ConductivityError().Values()[entry], p.assembly.ConductivityError().Values()[entry]}) * v; } intersects(actual_c, cv[row]); intersects(actual_k, kv[row]); const I residual = kv[row] - lambda * cv[row]; EXPECT_LE(residual.lo, 0); EXPECT_GE(residual.hi, 0); const I assembled_residual = actual_k - lambda * actual_c; EXPECT_LE(assembled_residual.lo, 0); EXPECT_GE(assembled_residual.hi, 0); residual_bound = std::max(residual_bound, assembled_residual.Abs()); independent_norm = independent_norm + mode[(row / 5) % 9] * cv[row]; actual_norm = actual_norm + mode[(row / 5) % 9] * actual_c; } const I analytical_norm = (I{2} + mode[1]) / I{6}; intersects(independent_norm, analytical_norm); intersects(actual_norm, analytical_norm); const I normalized = actual_norm / analytical_norm; EXPECT_LE(normalized.lo, 1); EXPECT_GE(normalized.hi, 1); std::cout << std::setprecision(17) << "THERMAL_EIGENMODE residual_bound=" << residual_bound << " C_norm_squared_lower=" << actual_norm.lo << " C_norm_squared_upper=" << actual_norm.hi << " normalized_lower=" << normalized.lo << " normalized_upper=" << normalized.hi << '\n'; } TEST(ThermalPhysics, DiscreteEigenmodeHasFirstOrderTimeConvergence) { fesa::SerialParallelFor parallel; std::array cosines; double initial_error = 0; for (std::size_t i = 0; i < cosines.size(); ++i) { cosines[i] = Cos(Pi() * I{static_cast(i)} / I{8}); initial_error = std::max(initial_error, (cosines[i] - I{cosines[i].Mid()}).Abs()); } const I lambda = I{384} * (I{1} - cosines[1]) / (I{2} + cosines[1]); const double period = (I{1} / lambda).Mid(); const I exact = ExpNegative(lambda * I{period}); double previous_error = 0; for (std::uint32_t count : {16U, 32U, 64U, 128U, 256U}) { Prepared p{Mesh(8, period, count, [&](std::size_t i, auto) { return cosines[i].Mid(); }), parallel}; if (count == 16) VerifyDiscreteEigenmode(p, cosines, lambda); const auto metrics = Advance(p, parallel, Down(1. / (2160 * 64)), initial_error); const auto& actual = p.state.CommittedState().temperature; Vector error{actual.Size()}, mode{actual.Size()}, difference{actual.Size()}; const I be = Power(I{1} / (I{1} + lambda * I{period / count}), count); double oracle_width = 0; for (std::size_t node = 0; node < 81; ++node) for (std::size_t a = 0; a < 5; ++a) { const auto i = node % 9; const I v = cosines[i] * exact, expected = cosines[i] * be; error[5 * node + a] = actual[5 * node + a] - v.Mid(); mode[5 * node + a] = cosines[i].Mid(); difference[5 * node + a] = actual[5 * node + a] - expected.Mid(); oracle_width = std::max(oracle_width, (expected - I{expected.Mid()}).Abs()); } const I mode_norm = CapacityNorm(mode, 8); const I error_norm = CapacityNorm(error, 8) / mode_norm; const I agreement = CapacityNorm(difference, 8); const I total_budget = I{metrics.capacity_budget} + I{oracle_width}; EXPECT_LE(agreement.hi, total_budget.hi); EXPECT_LE((total_budget / mode_norm).hi, error_norm.lo / 1000); if (previous_error > 0) { const double order = std::log2(previous_error / error_norm.Mid()); EXPECT_GE(order, .97); EXPECT_LE(order, 1.02); std::cout << std::setprecision(17) << "THERMAL_TIME N=" << count << " error=" << error_norm.Mid() << " order=" << order << " oracle_width=" << oracle_width << '\n'; } previous_error = error_norm.Mid(); Print("time", 8, count, metrics); } } TEST(ThermalPhysics, SignedOverlappingAndConstrainedPowerBalanceByComponent) { fesa::SerialParallelFor parallel; auto definition = Mesh(1, .5, 8, [](auto, auto) { return 0.; }); for (fesa::EntityIndex i = 0; i < 4; ++i) { auto node = definition.nodes[i]; node.source_id.instance_name = "other"; node.coordinates[0] += 3; definition.nodes.push_back(node); definition.thermal_initial_conditions.push_back({i + 4, 0, {}}); } definition.thermal_elements.push_back( {{"other", 1, "1"}, {{4, 5, 7, 6}}, 0, {}}); definition.thermal_model_boundaries = {{0, 11, 0, {}}}; definition.thermal_step->loads = {{0, 11, 3, {}, 0}, {1, 15, -3, {}, 1}, {4, 11, 2, {}, 2}, {4, 11, 3, {}, 3}, {6, 15, -5, {}, 4}}; auto without_constrained = definition; without_constrained.thermal_step->loads.erase( without_constrained.thermal_step->loads.begin()); Prepared p{std::move(definition), parallel}, other{std::move(without_constrained), parallel}; EXPECT_EQ(p.assembly.HeatInput()[20], 5); EXPECT_EQ(p.assembly.HeatInput()[34], -5); const auto metrics = Advance(p, parallel, Down(1. / 2160)); const auto other_metrics = Advance(other, parallel, Down(1. / 2160)); Print("signed-component", 1, 8, metrics); Print("without-constrained-power", 1, 8, other_metrics); const std::array weights{I{1} / I{48}, I{1} / I{12}, I{1} / I{24}, I{1} / I{12}, I{1} / I{48}}; for (std::size_t step = 1; step < p.state.AcceptedFrames().size(); ++step) { const auto& frame = p.state.AcceptedFrames()[step]; const auto& old = p.state.AcceptedFrames()[step - 1]; const auto& other_frame = other.state.AcceptedFrames()[step]; std::array energy; for (std::size_t i = 0; i < 40; ++i) { energy[i / 20] = energy[i / 20] + weights[i % 5] * (I{frame.temperature[i]} - I{old.temperature[i]}); EXPECT_DOUBLE_EQ(frame.temperature[i], other_frame.temperature[i]); } const double reaction = frame.completed->reaction_heat_rate[0]; const I first = energy[0] - I{frame.time_increment} * I{reaction}; // Independent exact unit-volume quadrature and component inventory. EXPECT_LE(first.Abs(), metrics.steps[step - 1].energy_bound); EXPECT_LE(energy[1].Abs(), metrics.steps[step - 1].energy_bound); const double other_reaction = other_frame.completed->reaction_heat_rate[0]; const I reaction_shift = I{reaction} - I{other_reaction} + I{3}; const I subtraction = Gamma(4) * (I{std::abs(reaction)} + I{std::abs(other_reaction)} + I{3}); EXPECT_LE(reaction_shift.Abs(), subtraction.hi); } } fesa::SparseMatrix ZeroLike(const fesa::SparseMatrix& a) { std::vector coo; for (std::size_t i = 0; i < a.Rows(); ++i) for (std::size_t j = a.RowOffsets()[i]; j < a.RowOffsets()[i + 1]; ++j) coo.push_back({i, a.ColumnIndices()[j], 0, 0, j}); return Take(fesa::SparseMatrix::FromCoo(a.Rows(), a.Columns(), std::move(coo), {a.RowOffsets(), a.ColumnIndices()})); } // Independent dense inverse with a posteriori interval residual certificate. // Used only for small fixture partitions, never as a production solver. double CertifiedInverse(const fesa::SparseMatrix& matrix, const std::vector& free) { const auto n = free.size(); std::vector> a(n, std::vector(n)), z(n, std::vector(n)); for (std::size_t i = 0; i < n; ++i) { z[i][i] = 1; for (std::size_t k = matrix.RowOffsets()[free[i]]; k < matrix.RowOffsets()[free[i] + 1]; ++k) for (std::size_t j = 0; j < n; ++j) if (matrix.ColumnIndices()[k] == free[j]) a[i][j] = matrix.Values()[k]; } const auto original = a; for (std::size_t k = 0; k < n; ++k) { std::size_t pivot = k; for (std::size_t i = k + 1; i < n; ++i) if (std::abs(a[i][k]) > std::abs(a[pivot][k])) pivot = i; if (a[pivot][k] == 0) throw std::runtime_error{"Singular independent inverse."}; std::swap(a[k], a[pivot]); std::swap(z[k], z[pivot]); const double scale = a[k][k]; for (std::size_t j = 0; j < n; ++j) { a[k][j] /= scale; z[k][j] /= scale; } for (std::size_t i = 0; i < n; ++i) if (i != k) { const double factor = a[i][k]; for (std::size_t j = 0; j < n; ++j) { a[i][j] -= factor * a[k][j]; z[i][j] -= factor * z[k][j]; } } } double theta = 0, znorm = 0; for (std::size_t i = 0; i < n; ++i) { I defect, row; for (std::size_t j = 0; j < n; ++j) { I entry{static_cast(i == j)}; for (std::size_t k = 0; k < n; ++k) entry = entry - I{z[i][k]} * I{original[k][j]}; defect = defect + I{entry.Abs()}; row = row + I{std::abs(z[i][j])}; } theta = std::max(theta, defect.hi); znorm = std::max(znorm, row.hi); } if (theta >= 1) throw std::runtime_error{"Independent inverse certificate inconclusive."}; return (I{znorm} / (I{1} - I{theta})).hi; } TEST(ThermalPhysics, PrivateFullEndStripRespectsThicknessMaterialJumpAndFoldResistance) { fesa::SerialParallelFor parallel; for (bool folded : {false, true}) { auto m = Mesh(1, 1, 1, [](auto, auto) { return 0.; }); m.nodes.push_back( {{"plate", 5, "5"}, {folded ? 1. : 2., 0, folded ? 1. : 0.}, {}}); m.nodes.push_back( {{"plate", 6, "6"}, {folded ? 1. : 2., 1, folded ? 1. : 0.}, {}}); m.thermal_initial_conditions.push_back({4, 0, {}}); m.thermal_initial_conditions.push_back({5, 0, {}}); m.thermal_materials.push_back({{"plate", 2, "2"}, "high-k", 10, 1, 1, {}}); m.thermal_sections.push_back({{"plate", 2, "2"}, "thin", .5, 1, {}}); m.thermal_elements.push_back({{"plate", 2, "2"}, {{1, 4, 5, 3}}, 1, {}}); Prepared p{std::move(m), parallel}; const auto zero = ZeroLike(p.assembly.Capacity()); ti::ThermalPartition partition; std::vector g; for (std::size_t node = 0; node < 6; ++node) for (std::size_t station = 0; station < 5; ++station) { if (node == 1 || node == 3) partition.free.push_back(5 * node + station); else { partition.constrained.push_back(5 * node + station); g.push_back(node >= 4 ? 1. : 0.); } } partition.prescribed = Vector{g.size()}; for (std::size_t i = 0; i < g.size(); ++i) partition.prescribed[i] = g[i]; ti::ThermalFactorization cache; fesa::MklPardisoSolver solver; const auto result = Take(ti::SolveThermalIncrement( {p.assembly.Conductivity(), zero, p.assembly.HeatInput(), &p.assembly.ConductivityError(), &zero, &p.assembly.HeatInputError()}, partition, Vector{30}, 1, solver, cache)); const double inverse = CertifiedInverse(p.assembly.Conductivity(), partition.free); double residual = 0, coefficient_error = 0; for (auto i : partition.free) { residual = std::max(residual, result.certified_residual[i]); I row; for (std::size_t k = p.assembly.ConductivityError().RowOffsets()[i]; k < p.assembly.ConductivityError().RowOffsets()[i + 1]; ++k) row = row + I{p.assembly.ConductivityError().Values()[k]}; coefficient_error = std::max(coefficient_error, row.hi); } const I denominator = I{1} - I{inverse} * I{coefficient_error}; ASSERT_GT(denominator.lo, 0); const I bound = I{inverse} / denominator * I{residual}; const I mid = I{5} / I{6}; // R=1/(1*1*1)+1/(10*.5*1)=6/5. for (auto i : partition.free) EXPECT_LE((I{result.temperature[i]} - mid).Abs(), bound.hi + (mid - I{mid.Mid()}).Abs()); I left, right; for (std::size_t i = 0; i < partition.constrained.size(); ++i) if (partition.constrained[i] / 5 >= 4) right = right + I{result.reaction[i]}; else left = left + I{result.reaction[i]}; I reaction_budget; for (auto i : partition.constrained) { I row, scale, error; for (std::size_t k = p.assembly.Conductivity().RowOffsets()[i]; k < p.assembly.Conductivity().RowOffsets()[i + 1]; ++k) { const auto j = p.assembly.Conductivity().ColumnIndices()[k]; row = row + I{std::abs(p.assembly.Conductivity().Values()[k])} * bound; scale = scale + I{std::abs(p.assembly.Conductivity().Values()[k])} * I{std::abs(result.temperature[j])}; error = error + I{p.assembly.ConductivityError().Values()[k]} * I{std::abs(result.temperature[j])}; } const auto terms = p.assembly.Conductivity().RowOffsets()[i + 1] - p.assembly.Conductivity().RowOffsets()[i]; reaction_budget = reaction_budget + row + error + Gamma(2 * terms + 2) * scale; } EXPECT_LE((right - mid).Abs(), reaction_budget.hi); EXPECT_LE((left + mid).Abs(), reaction_budget.hi); EXPECT_LE((left + right).Abs(), result.evidence.energy_bound); auto flux = Take(p.assembly.RecoverFlux(result.temperature, parallel)); for (const auto& q : flux[0]) EXPECT_LE((I{q.X()} + mid).Abs(), 24 * bound.hi + Gamma(2048).hi * 24); for (const auto& q : flux[1]) { const double component = folded ? q.Z() : q.X(); EXPECT_LE((I{component} + I{5} / I{3}).Abs(), 400 * bound.hi + Gamma(2048).hi * 400); } std::cout << std::setprecision(17) << "THERMAL_STRIP folded=" << folded << " resistance=1.2 reaction=" << right.Mid() << " forward_bound=" << bound.hi << " beta=" << result.evidence.beta_cert << '\n'; } } TEST(ThermalPhysics, PrivateAffineFieldRotationNormalAndUnaveragedMidplaneTraces) { fesa::SerialParallelFor parallel; const auto rotate = [](Vector3 v) { return Vector3{v.Z(), v.X(), v.Y()}; }; for (double h : {.5, 1., 2.}) for (double k : {1., 10.}) for (bool flip : {false, true}) for (bool rotated : {false, true}) { SCOPED_TRACE(h); SCOPED_TRACE(k); SCOPED_TRACE(flip); SCOPED_TRACE(rotated); auto m = Mesh(1, 1, 1, [](auto, auto) { return 0.; }); m.thermal_sections[0].thickness = h; m.thermal_materials[0].conductivity = k; if (flip) m.thermal_elements[0].node_indices = {{0, 2, 3, 1}}; Vector values{20}; for (std::size_t node = 0; node < 4; ++node) { const auto& r = m.nodes[node].coordinates; for (std::size_t station = 0; station < 5; ++station) { const double zeta = -1 + .5 * station; values[5 * node + station] = 2 + 3 * r[0] - 2 * r[1] + 4 * (flip ? -1 : 1) * h * zeta / 2; } if (rotated) { const auto r_new = rotate(Vector3{r[0], r[1], r[2]}); m.nodes[node].coordinates = {{r_new.X(), r_new.Y(), r_new.Z()}}; } } Prepared p{std::move(m), parallel}; ti::ThermalPartition partition; for (std::size_t i = 0; i < 20; ++i) partition.constrained.push_back(i); partition.prescribed = values; fesa::MklPardisoSolver solver; ti::ThermalFactorization cache; const auto result = Take(ti::SolveThermalIncrement( {p.assembly.Conductivity(), p.assembly.Capacity(), p.assembly.HeatInput()}, partition, values, 1, solver, cache)); EXPECT_FALSE(cache.matrix.has_value()); const auto flux = Take(p.assembly.RecoverFlux(result.temperature, parallel)); Vector3 expected{-3 * k, 2 * k, -4 * k}; if (rotated) expected = rotate(expected); double scale = 0; for (std::size_t i = 0; i < 20; ++i) scale = std::max(scale, std::abs(values[i])); const double budget = (Gamma(2048) * I{k * (8 + 16 / h) * scale}).hi; for (std::size_t point = 0; point < 4; ++point) { const auto& r = p.state.FluxLocalFrames()[0][point]; for (std::size_t a = 0; a < 3; ++a) for (std::size_t b = 0; b < 3; ++b) { I dot, absolute; for (std::size_t c = 0; c < 3; ++c) { dot = dot + I{r[a][c]} * I{r[b][c]}; absolute = absolute + I{std::abs(r[a][c] * r[b][c])}; } EXPECT_LE((dot - I{static_cast(a == b)}).Abs(), (Gamma(2048) * absolute).hi); } EXPECT_GT(r[0].Cross(r[1]).Dot(r[2]), 0); for (std::size_t trace = 0; trace < 6; ++trace) { const auto& q = flux[0][6 * point + trace]; for (std::size_t c = 0; c < 3; ++c) EXPECT_LE(std::abs(q[c] - expected[c]), budget); for (std::size_t axis = 0; axis < 3; ++axis) { I actual_local, expected_local; for (std::size_t c = 0; c < 3; ++c) { actual_local = actual_local + I{r[axis][c]} * I{q[c]}; expected_local = expected_local + I{r[axis][c]} * I{expected[c]}; } EXPECT_LE((actual_local - expected_local).Abs(), budget + Gamma(16).hi * k * (8 + 16 / h) * scale); } } } // The discontinuous derivative at the shared station has two distinct // one-sided traces; prescribed interior stations exist only in this // private algebra fixture, never in the public Domain BC definition. for (std::size_t node = 0; node < 4; ++node) { for (std::size_t station = 0; station < 5; ++station) values[5 * node + station] = station < 3 ? 0. : static_cast(station - 2); } partition.prescribed = values; const auto trace_result = Take(ti::SolveThermalIncrement( {p.assembly.Conductivity(), p.assembly.Capacity(), p.assembly.HeatInput()}, partition, values, 1, solver, cache)); const auto traces = Take(p.assembly.RecoverFlux(trace_result.temperature, parallel)); for (std::size_t point = 0; point < 4; ++point) { const auto& normal = p.state.FluxLocalFrames()[0][point][2]; EXPECT_EQ(traces[0][6 * point + 2].Dot(normal), 0); EXPECT_LE( std::abs(traces[0][6 * point + 3].Dot(normal) + 4 * k / h), (Gamma(2048) * I{2 * k * (8 + 16 / h)}).hi); } } } I ThicknessMass(const std::array& v) { I result; for (std::size_t segment = 0; segment < 2; ++segment) { const I m = (v[2 * segment] + v[2 * segment + 2]) / I{2}; const I d = (v[2 * segment + 2] - v[2 * segment]) / I{2}; const I middle = v[2 * segment + 1]; // Positive square decomposition of the exact two-P2 consistent mass. result = result + (I{6} * Square(m + I{2} / I{3} * middle) + I{40} / I{3} * Square(middle) + I{10} * Square(d)) / I{60}; } return result; } I ThicknessGradient(const std::array& v) { I result; for (std::size_t segment = 0; segment < 2; ++segment) { const I left = v[2 * segment] - v[2 * segment + 1], right = v[2 * segment + 2] - v[2 * segment + 1]; result = result + I{2} / I{3} * (I{6} * (Square(left) + Square(right)) + Square(left + right)); } return result; } struct SpatialError { I temperature, gradient; }; template SpatialError IntegrateSpatialError( const Vector& values, std::size_t n, const std::array& rule, I amplitude) { I temperature, gradient; for (std::size_t y = 0; y < n; ++y) for (std::size_t x = 0; x < n; ++x) { const std::size_t node = y * (n + 1) + x; const std::array nodes{node, node + 1, node + n + 2, node + n + 1}; for (const auto& gx : rule) { const I u = (I{1} + I{gx.x_lo, gx.x_hi}) / I{2}; const I position = (I{static_cast(x)} + u) / I{static_cast(n)}; const I phase = Pi() * position; const I exact = Cos(phase) * amplitude; const I exact_gradient = I{-1} * Pi() * Cos(Pi() / I{2} - phase) * amplitude; for (const auto& gy : rule) { const I v = (I{1} + I{gy.x_lo, gy.x_hi}) / I{2}; const I weight = I{gx.w_lo, gx.w_hi} * I{gy.w_lo, gy.w_hi} / I{4. * n * n}; std::array field, dx, dy, error; for (std::size_t a = 0; a < 5; ++a) { const I a0{values[5 * nodes[0] + a]}, a1{values[5 * nodes[1] + a]}, a2{values[5 * nodes[2] + a]}, a3{values[5 * nodes[3] + a]}; field[a] = (I{1} - v) * ((I{1} - u) * a0 + u * a1) + v * ((I{1} - u) * a3 + u * a2); error[a] = field[a] - exact; dx[a] = I{static_cast(n)} * ((I{1} - v) * (a1 - a0) + v * (a2 - a3)) - exact_gradient; dy[a] = I{static_cast(n)} * ((I{1} - u) * (a3 - a0) + u * (a2 - a1)); } temperature = temperature + weight * ThicknessMass(error); gradient = gradient + weight * (ThicknessMass(dx) + ThicknessMass(dy) + ThicknessGradient(field)); } } } return {Sqrt(temperature), Sqrt(gradient)}; } TEST(ThermalPhysics, FullTwoDimensionalMeshesHaveSecondOrderTemperatureAndFirstOrderGradient) { fesa::SerialParallelFor parallel; constexpr double period = .001; constexpr std::uint32_t count = 32; const I continuum_amplitude = ExpNegative(Pi() * Pi() * I{period}); double previous_temperature = 0, previous_gradient = 0; for (std::size_t n : {8U, 16U, 32U, 64U}) { SCOPED_TRACE(n); std::vector mode(n + 1); double initial_error = 0; for (std::size_t i = 0; i <= n; ++i) { mode[i] = Cos(Pi() * I{static_cast(i)} / I{static_cast(n)}); initial_error = std::max(initial_error, (mode[i] - I{mode[i].Mid()}).Abs()); } Prepared p{Mesh(n, period, count, [&](std::size_t i, auto) { return mode[i].Mid(); }), parallel}; const auto metrics = Advance(p, parallel, Down(1. / (2160 * n * n)), initial_error); const auto& actual = p.state.CommittedState().temperature; const auto twelve = IntegrateSpatialError( actual, n, thermal_oracle::kGauss12, continuum_amplitude); const auto sixteen = IntegrateSpatialError( actual, n, thermal_oracle::kGauss16, continuum_amplitude); EXPECT_LE((twelve.temperature - sixteen.temperature).Abs(), sixteen.temperature.lo * 1e-8); EXPECT_LE((twelve.gradient - sixteen.gradient).Abs(), sixteen.gradient.lo * 1e-8); const I lambda = I{6. * n * n} * (I{1} - mode[1]) / (I{2} + mode[1]); const I semidiscrete = ExpNegative(lambda * I{period}); const I be = Power(I{1} / (I{1} + lambda * I{period / count}), count); Vector spatial{actual.Size()}, difference{actual.Size()}, mode_values{actual.Size()}; double oracle_width = 0; for (std::size_t node = 0; node < (n + 1) * (n + 1); ++node) for (std::size_t a = 0; a < 5; ++a) { const auto i = node % (n + 1); const I expected = mode[i] * be; spatial[5 * node + a] = (mode[i] * semidiscrete).Mid(); mode_values[5 * node + a] = mode[i].Mid(); difference[5 * node + a] = actual[5 * node + a] - expected.Mid(); oracle_width = std::max(oracle_width, (expected - I{expected.Mid()}).Abs()); } const I agreement = CapacityNorm(difference, n); const I forward = I{metrics.capacity_budget} + I{oracle_width}; EXPECT_LE(agreement.hi, forward.hi); EXPECT_LE(forward.hi, sixteen.temperature.lo / 1000); EXPECT_LE((Sqrt(I{24. * n * n + 1280}) * forward).hi, sixteen.gradient.lo / 1000); const auto spatial_error = IntegrateSpatialError( spatial, n, thermal_oracle::kGauss12, continuum_amplitude); const I temporal = I{(be - semidiscrete).Abs()} * CapacityNorm(mode_values, n); EXPECT_LE(temporal.hi, spatial_error.temperature.lo / 32); double temperature_order = 0, gradient_order = 0; if (previous_temperature > 0) { temperature_order = std::log2(previous_temperature / sixteen.temperature.Mid()); gradient_order = std::log2(previous_gradient / sixteen.gradient.Mid()); EXPECT_GE(temperature_order, 1.95); EXPECT_LE(temperature_order, 2.05); EXPECT_GE(gradient_order, .98); EXPECT_LE(gradient_order, 1.03); } previous_temperature = sixteen.temperature.Mid(); previous_gradient = sixteen.gradient.Mid(); Print("space", n, count, metrics); std::cout << std::setprecision(17) << "THERMAL_SPACE n=" << n << " T_error=" << sixteen.temperature.Mid() << " gradient_error=" << sixteen.gradient.Mid() << " T_order=" << temperature_order << " gradient_order=" << gradient_order << " time_spatial_ratio=" << (temporal / spatial_error.temperature).hi << " quadrature_T_gap=" << (twelve.temperature - sixteen.temperature).Abs() << " quadrature_gradient_gap=" << (twelve.gradient - sixteen.gradient).Abs() << " modal_agreement=" << agreement.hi << " roundoff_budget=" << forward.hi << '\n'; if (n == 64) EXPECT_EQ(8 * (metrics.temperature_values + metrics.flux_values), 81074472U); } } I Bracket(thermal_oracle::Bracket x) { return {x.lo, x.hi}; } class ThermalSlab : public testing::TestWithParam> {}; TEST_P(ThermalSlab, ActualMeshesMatchCertifiedP2AndSeparateFourierError) { const auto [case_index, count] = GetParam(); const auto& oracle = thermal_oracle::kSlab[case_index]; const auto& be = count == 4096 ? oracle.be4096 : oracle.be8192; fesa::SerialParallelFor parallel; std::array first_mesh; std::array first_budget{}; for (std::size_t n : {1U, 2U}) { auto definition = Mesh(n, oracle.time, count, [](auto, auto) { return 0.; }); for (std::size_t node = 0; node < definition.nodes.size(); ++node) definition.thermal_model_boundaries.push_back( {static_cast(node), 11, 1, {}}); Prepared p{std::move(definition), parallel}; const double mass_lower = Down(1. / (2160 * n * n)); for (std::size_t row = 0; row < p.state.CommittedState().temperature.Size(); ++row) EXPECT_EQ(p.state.CommittedState().temperature[row], 0); const auto metrics = Advance(p, parallel, mass_lower); const auto& final = p.state.CommittedState(); // Independently certify the small last-step inverse as well as the // structured C-norm propagation used for the full 4096/8192-step history. const auto& c = p.assembly.Capacity(); const auto& k = p.assembly.Conductivity(); const double delta = final.time_increment; std::vector coo; double operator_error = 0; for (std::size_t row = 0; row < c.Rows(); ++row) { I error; for (auto entry = c.RowOffsets()[row]; entry < c.RowOffsets()[row + 1]; ++entry) { const double center = c.Values()[entry] / delta + k.Values()[entry]; coo.push_back({row, c.ColumnIndices()[entry], center, 0, entry}); const I exact = (I{c.Values()[entry]} + I{-p.assembly.CapacityError().Values()[entry], p.assembly.CapacityError().Values()[entry]}) / I{delta} + I{k.Values()[entry]} + I{-p.assembly.ConductivityError().Values()[entry], p.assembly.ConductivityError().Values()[entry]}; error = error + I{(exact - I{center}).Abs()}; } operator_error = std::max(operator_error, error.hi); } const auto a = Take(fesa::SparseMatrix::FromCoo(c.Rows(), c.Columns(), std::move(coo), {c.RowOffsets(), c.ColumnIndices()})); const double inverse = CertifiedInverse(a, p.dofs.FreeDofs()); const I inverse_denominator = I{1} - I{inverse} * I{operator_error}; ASSERT_GT(inverse_denominator.lo, 0); const I local_forward = I{inverse} / inverse_denominator * I{metrics.last_free_residual_inf}; const I temperature_budget = I{metrics.capacity_budget} / Sqrt(I{mass_lower}); double temperature_scale = 0; for (std::size_t row = 0; row < final.temperature.Size(); ++row) temperature_scale = std::max(temperature_scale, std::abs(final.temperature[row])); const I flux_budget = I{8. * n + 16} * (temperature_budget + Gamma(2048) * I{temperature_scale}); I energy, reaction; constexpr int thickness_weight[5] = {1, 4, 2, 4, 1}; for (std::size_t j = 0; j <= n; ++j) for (std::size_t i = 0; i <= n; ++i) { const int plane_weight = (i == 0 || i == n ? 1 : 2) * (j == 0 || j == n ? 1 : 2); for (std::size_t a = 0; a < 5; ++a) energy = energy + I{final.temperature[5 * (j * (n + 1) + i) + a]} * I{static_cast(plane_weight * thickness_weight[a])} / I{48. * n * n}; } for (std::size_t row = 0; row < final.completed->reaction_heat_rate.Size(); ++row) reaction = reaction + I{final.completed->reaction_heat_rate[row]}; // Exact P2 Schur reaction row L = (-400/51,56/17,-80/51,28/51), // L Cff^-1 L^T = 6447104/14739. Plane integration is a contraction // in the product C norm; Ccf Cff^-1 has infinity norm 3/17. const I reaction_budget = Sqrt(I{6447104} / I{14739}) * I{metrics.capacity_budget} + (I{3} / I{17}) * I{metrics.last_free_residual_l1} + I{metrics.last_reaction_evaluation_error} + I{(reaction - I{reaction.Mid()}).Abs()}; const std::array solver_budget{ temperature_budget.hi, flux_budget.hi, (I{metrics.capacity_budget} + I{(energy - I{energy.Mid()}).Abs()}).hi, reaction_budget.hi}; std::array agreement{}, physical{}, oracle_budget{}, time_error{}, space_error{}; const auto observe = [&](std::size_t quantity, std::size_t group, I value) { agreement[group] = std::max(agreement[group], (value - Bracket(be[quantity])).Abs()); physical[group] = std::max( physical[group], (value - Bracket(oracle.fourier[quantity])).Abs()); }; for (std::size_t quantity = 0; quantity < 13; ++quantity) { const std::size_t group = quantity < 5 ? 0 : quantity < 11 ? 1 : quantity - 9; oracle_budget[group] = std::max( oracle_budget[group], (I{be[quantity].hi} - I{be[quantity].lo} + I{oracle.continuous[quantity].hi} - I{oracle.continuous[quantity].lo} + I{oracle.fourier[quantity].hi} - I{oracle.fourier[quantity].lo}) .hi); time_error[group] = std::max( time_error[group], (Bracket(be[quantity]) - Bracket(oracle.continuous[quantity])).Abs()); space_error[group] = std::max(space_error[group], (Bracket(oracle.continuous[quantity]) - Bracket(oracle.fourier[quantity])) .Abs()); } for (std::size_t row = 0; row < final.temperature.Size(); ++row) observe(row % 5, 0, I{final.temperature[row]}); for (const auto& element : final.completed->global_heat_flux) for (std::size_t sample = 0; sample < 24; ++sample) { EXPECT_LE(std::abs(element[sample][0]), flux_budget.hi); EXPECT_LE(std::abs(element[sample][1]), flux_budget.hi); observe(5 + sample % 6, 1, I{element[sample][2]}); } observe(11, 2, energy); observe(12, 3, reaction); std::array samples; for (std::size_t a = 0; a < 5; ++a) samples[a] = I{final.temperature[a]}; for (std::size_t trace = 0; trace < 6; ++trace) samples[5 + trace] = I{final.completed->global_heat_flux[0][trace][2]}; samples[11] = energy; samples[12] = reaction; if (n == 1) { first_mesh = samples; first_budget = solver_budget; } else { for (std::size_t quantity = 0; quantity < 13; ++quantity) { const std::size_t group = quantity < 5 ? 0 : quantity < 11 ? 1 : quantity - 9; EXPECT_LE((samples[quantity] - first_mesh[quantity]).Abs(), (I{first_budget[group]} + I{solver_budget[group]}).hi); } } Print("slab", n, count, metrics); for (std::size_t group = 0; group < 4; ++group) { const I roundoff = I{solver_budget[group]} + I{oracle_budget[group]}; EXPECT_LE(roundoff.hi, oracle.space[group] / 1000); EXPECT_LE(agreement[group], roundoff.hi); EXPECT_LE(physical[group], (I{oracle.space[group]} + I{time_error[group]} + roundoff).hi); EXPECT_LE((I{physical[group]} - I{space_error[group]}).Abs(), (I{time_error[group]} + roundoff).hi); const bool plateau = (I{time_error[group]} + roundoff).hi <= space_error[group] / 10; if (case_index < 4 && count == 8192) EXPECT_TRUE(plateau); std::cout << std::setprecision(17) << "THERMAL_SLAB time=" << oracle.time << " n=" << n << " N=" << count << " quantity=" << group << " finite_BE_gap=" << agreement[group] << " solver_bound=" << solver_budget[group] << " oracle_bound=" << oracle_budget[group] << " physical_error=" << physical[group] << " space_error=" << space_error[group] << " D_space=" << oracle.space[group] << " D_time=" << time_error[group] << " plateau=" << plateau << " last_step_inverse_bound=" << inverse << " last_step_forward_bound=" << local_forward.hi << " penetration=" << std::sqrt(oracle.time) << " station_spacing=0.25 segment_length=0.5" << '\n'; } } } INSTANTIATE_TEST_SUITE_P( FixedStations, ThermalSlab, testing::Values(std::make_pair(0, 4096U), std::make_pair(0, 8192U), std::make_pair(1, 4096U), std::make_pair(1, 8192U), std::make_pair(2, 4096U), std::make_pair(2, 8192U), std::make_pair(3, 4096U), std::make_pair(3, 8192U), std::make_pair(4, 4096U), std::make_pair(4, 8192U), std::make_pair(5, 4096U), std::make_pair(5, 8192U))); } // namespace