#pragma once #include #include #include #include #include #include #include #include #include #include "polytrope_analytic_reference.hpp" #include "polytrope_physical_state.hpp" namespace experiment::polytrope_validation { using Measurements = std::map; // Exact fields evaluated on the actual mapped mesh. This checks the same // integration and physical-point locator used for a numerical solution, // without constructing a Newton context or using its Lane-Emden seed. class AnalyticMeshState final { public: const mean_field::fem::FEM &finiteElements; mean_field::mapping::GridFunctionMappingEvaluator mapping; N1Reference reference; AnalyticMeshState(const mean_field::fem::FEM &fem, const N1Reference &analytic) : finiteElements(fem), mapping(*fem.domainMapperStateless, *fem.displacement, *fem.compactificationCoordinate), reference(analytic) { } [[nodiscard]] bool isStellar(const int element) const { using Schema = mean_field::utils::domain::CoreEnvelopeVacuumDomainSchema; return Schema::template attribute_belongs_to( finiteElements.mesh->GetAttribute(element) ); } mean_field::mapping::MappingStatus Evaluate( const int element, const mfem::IntegrationPoint &point, PhysicalPoint &result ) { auto *transformation = finiteElements.mesh->GetElementTransformation(element); transformation->SetIntPoint(&point); const auto status = mapping.EvaluatePoint(*transformation, point, result.mapping); if (status != mean_field::mapping::MappingStatus::valid) return status; const double radius = result.mapping.physical_position.Norml2(); const auto analytic = reference.AtRadius(radius); result.element = element; result.attribute = transformation->Attribute; result.stellarMaterial = isStellar(element); result.rho = analytic.density; result.h = analytic.enthalpy; result.phi = analytic.potential; result.rotationPotential = 0.0; result.gravityGradientPhysical.SetSize(3); result.enthalpyGradientPhysical.SetSize(3); result.potentialGradientPhysical.SetSize(3); for (int component = 0; component < 3; ++component) { const double gradient = radius > 0.0 ? analytic.radialPotentialGradient * result.mapping.physical_position(component) / radius : 0.0; result.gravityGradientPhysical(component) = gradient; result.potentialGradientPhysical(component) = gradient; result.enthalpyGradientPhysical(component) = radius <= reference.radius ? -gradient : 0.0; } return status; } }; struct ErrorIntegral final { long double errorSquared{0.0L}; long double referenceSquared{0.0L}; double maximumScaledError{0.0}; void Add(const double value, const double exact, const double weight, const double scale) { const long double difference = static_cast(value) - exact; errorSquared += weight * difference * difference; referenceSquared += static_cast(weight) * exact * exact; maximumScaledError = std::max(maximumScaledError, std::abs(value - exact) / scale); } [[nodiscard]] double RelativeL2() const { return referenceSquared > 0.0L ? static_cast(std::sqrt(errorSquared / referenceSquared)) : std::numeric_limits::quiet_NaN(); } }; template Measurements MeasureVolumes(SampleState &state, const N1Reference &reference, const int quadratureOrder) { long double volume = 0.0L, mass = 0.0L, binding = 0.0L, forceBinding = 0.0L; long double pressureIntegral = 0.0L, enthalpyPressureIntegral = 0.0L, kinetic = 0.0L; long double momentOfInertia = 0.0L, bernoulliOffsetMean = 0.0L, bernoulliCenteredSquared = 0.0L; long double closureSquared = 0.0L, closureDensityInnerProduct = 0.0L; long double gradientMismatch = 0.0L, hydrostaticGradient = 0.0L; std::array firstMoment{}; ErrorIntegral densityError, enthalpyError, potentialError, pressureError, gravityError; double minimumDensity = std::numeric_limits::infinity(); double minimumEnthalpy = std::numeric_limits::infinity(); double minimumDeterminant = std::numeric_limits::infinity(); double bernoulliMinimum = std::numeric_limits::infinity(); double bernoulliMaximum = -std::numeric_limits::infinity(); std::uint64_t samples = 0, negativeDensitySamples = 0, negativeEnthalpySamples = 0; PhysicalPoint point; const double densityScale = reference.CentralDensity(); const double enthalpyScale = reference.CentralEnthalpy(); const double pressureScale = reference.PolytropicConstant() * densityScale * densityScale; const double gravityScale = reference.gravitationalConstant * reference.mass / (reference.radius * reference.radius); for (int element = 0; element < state.finiteElements.mesh->GetNE(); ++element) { if (!state.isStellar(element)) continue; auto *transformation = state.finiteElements.mesh->GetElementTransformation(element); const auto &rule = mfem::IntRules.Get(transformation->GetGeometryType(), quadratureOrder); for (int q = 0; q < rule.GetNPoints(); ++q) { const auto &ip = rule.IntPoint(q); if (state.Evaluate(element, ip, point) != mean_field::mapping::MappingStatus::valid) { throw std::runtime_error("Invalid physical mapping in polytrope volume verification."); } transformation->SetIntPoint(&ip); // Explicitly check the orientation of both the reference map // and the deformation before integrating physical volume. const double referenceDeterminant = transformation->Jacobian().Det(); const double weight = ip.weight * referenceDeterminant * point.mapping.mapping_determinant; if (!(referenceDeterminant > 0.0) || !std::isfinite(referenceDeterminant)) { throw std::runtime_error("Non-positive or non-finite reference Jacobian in polytrope volume verification."); } if (!(weight > 0.0) || !std::isfinite(weight) || !std::isfinite(point.rho) || !std::isfinite(point.h) || !std::isfinite(point.phi) || !std::isfinite(point.rotationPotential)) { throw std::runtime_error("Non-finite field or non-positive physical integration weight."); } ++samples; const auto &position = point.mapping.physical_position; const double radius = position.Norml2(); const auto exact = reference.AtRadius(radius); // Do not clamp numerical density/enthalpy. Negative values are // reported, and pressure consistency is checked independently. const double pressure = reference.PolytropicConstant() * point.rho * point.rho; const double pressureFromEnthalpy = point.h * point.h / (4.0 * reference.PolytropicConstant()); const long double specificBernoulli = static_cast(point.h) + point.phi - point.rotationPotential; volume += weight; mass += weight * point.rho; binding += 0.5L * weight * point.rho * point.phi; forceBinding -= weight * point.rho * (position * point.gravityGradientPhysical); pressureIntegral += weight * pressure; enthalpyPressureIntegral += weight * pressureFromEnthalpy; // RigidRotation::potential is positive +|Omega x r|^2/2. kinetic += weight * point.rho * point.rotationPotential; momentOfInertia += weight * point.rho * (position(0) * position(0) + position(1) * position(1)); // Weighted Welford accumulation about the fixed analytic // Bernoulli constant C=-h_c resolves small spatial variations // without subtracting two O(h_c^2) second moments. No fitted // potential offset is applied to any physical field/error. const long double bernoulliOffset = specificBernoulli + enthalpyScale; const long double bernoulliDelta = bernoulliOffset - bernoulliOffsetMean; bernoulliOffsetMean += (static_cast(weight) / volume) * bernoulliDelta; bernoulliCenteredSquared += weight * bernoulliDelta * (bernoulliOffset - bernoulliOffsetMean); const double closure = point.h - 2.0 * reference.PolytropicConstant() * point.rho; closureSquared += weight * closure * closure; closureDensityInnerProduct += weight * point.rho * closure; bernoulliMinimum = std::min(bernoulliMinimum, static_cast(specificBernoulli)); bernoulliMaximum = std::max(bernoulliMaximum, static_cast(specificBernoulli)); minimumDensity = std::min(minimumDensity, point.rho); minimumEnthalpy = std::min(minimumEnthalpy, point.h); minimumDeterminant = std::min(minimumDeterminant, point.mapping.mapping_determinant); negativeDensitySamples += point.rho < 0.0; negativeEnthalpySamples += point.h < 0.0; densityError.Add(point.rho, exact.density, weight, densityScale); enthalpyError.Add(point.h, exact.enthalpy, weight, enthalpyScale); potentialError.Add(point.phi, exact.potential, weight, enthalpyScale); pressureError.Add(pressure, exact.pressure, weight, pressureScale); for (int component = 0; component < 3; ++component) { if (!std::isfinite(point.gravityGradientPhysical(component)) || !std::isfinite(point.potentialGradientPhysical(component)) || !std::isfinite(point.enthalpyGradientPhysical(component))) { throw std::runtime_error("Non-finite physical field gradient in polytrope volume verification."); } firstMoment[component] += weight * point.rho * position(component); const double exactGradient = radius > 0.0 ? exact.radialPotentialGradient * position(component) / radius : 0.0; gravityError.Add(point.gravityGradientPhysical(component), exactGradient, weight, gravityScale); const double mismatch = point.gravityGradientPhysical(component) - point.potentialGradientPhysical(component); gradientMismatch += weight * mismatch * mismatch; const double hydrostatic = point.enthalpyGradientPhysical(component) + point.gravityGradientPhysical(component); hydrostaticGradient += weight * hydrostatic * hydrostatic; } } } if (!(volume > 0.0L) || !(mass > 0.0L) || !(binding < 0.0L)) { throw std::runtime_error("Polytrope verification requires positive stellar volume/mass and negative binding energy."); } Measurements result{ {"quadrature_order", static_cast(quadratureOrder)}, {"stellar_samples", static_cast(samples)}, {"volume", static_cast(volume)}, {"mass", static_cast(mass)}, {"mass_relative_error", static_cast(std::abs(mass / reference.mass - 1.0L))}, {"volume_radius", static_cast(std::cbrt(3.0L * volume / (4.0L * std::numbers::pi_v)))}, {"binding_energy", static_cast(binding)}, {"force_binding_energy", static_cast(forceBinding)}, {"pressure_integral", static_cast(pressureIntegral)}, {"enthalpy_pressure_integral", static_cast(enthalpyPressureIntegral)}, {"kinetic_energy", static_cast(kinetic)}, {"virial_signed", static_cast((2.0L * kinetic + binding + 3.0L * pressureIntegral) / std::abs(binding))}, {"virial_error", static_cast(std::abs(2.0L * kinetic + binding + 3.0L * pressureIntegral) / std::abs(binding))}, {"virial_ratio", static_cast((2.0L * kinetic + 3.0L * pressureIntegral) / std::abs(binding))}, {"force_virial_error", static_cast(std::abs(2.0L * kinetic + forceBinding + 3.0L * pressureIntegral) / std::abs(binding))}, {"enthalpy_virial_error", static_cast(std::abs(2.0L * kinetic + binding + 3.0L * enthalpyPressureIntegral) / std::abs(binding))}, {"enthalpy_force_virial_error", static_cast(std::abs(2.0L * kinetic + forceBinding + 3.0L * enthalpyPressureIntegral) / std::abs(binding))}, {"gravity_energy_consistency", static_cast(std::abs(binding - forceBinding) / std::abs(binding))}, {"binding_relative_error", static_cast(std::abs(binding / reference.BindingEnergy() - 1.0L))}, {"pressure_integral_relative_error", static_cast(std::abs(pressureIntegral / reference.PressureIntegral() - 1.0L))}, {"pressure_integral_eos_disagreement", static_cast(std::abs(pressureIntegral - enthalpyPressureIntegral) / reference.PressureIntegral())}, {"closure_projection_pressure_gap", static_cast(closureSquared / (4.0L * reference.PolytropicConstant()))}, {"closure_density_inner_product", static_cast(closureDensityInnerProduct)}, {"closure_projection_pressure_gap_relative_defect", static_cast((enthalpyPressureIntegral - pressureIntegral - closureSquared / (4.0L * reference.PolytropicConstant())) / reference.PressureIntegral())}, {"moment_of_inertia", static_cast(momentOfInertia)}, {"moment_of_inertia_relative_error", static_cast(std::abs(momentOfInertia / reference.MomentOfInertia() - 1.0L))}, {"bernoulli_mean", static_cast(bernoulliOffsetMean - enthalpyScale)}, {"bernoulli_mean_scaled_error", static_cast(std::abs(bernoulliOffsetMean) / enthalpyScale)}, {"bernoulli_scaled_range", (bernoulliMaximum - bernoulliMinimum) / enthalpyScale}, {"bernoulli_scaled_rms_variation", static_cast(std::sqrt(std::max(0.0L, bernoulliCenteredSquared / volume)) / enthalpyScale)}, {"eos_enthalpy_scaled_rms", static_cast(std::sqrt(closureSquared / volume) / enthalpyScale)}, {"gravity_gradient_vs_broken_potential_gradient_scaled_rms", static_cast(std::sqrt(gradientMismatch / volume) / gravityScale)}, {"nonrotating_hydrostatic_gradient_scaled_rms", static_cast(std::sqrt(hydrostaticGradient / volume) / gravityScale)}, {"minimum_density", minimumDensity}, {"minimum_enthalpy", minimumEnthalpy}, {"negative_density_samples", static_cast(negativeDensitySamples)}, {"negative_enthalpy_samples", static_cast(negativeEnthalpySamples)}, {"minimum_mapping_determinant", minimumDeterminant} }; result["volume_radius_relative_error"] = std::abs(result.at("volume_radius") / reference.radius - 1.0); for (const auto &[name, error] : std::array, 5>{{ {"density", &densityError}, {"enthalpy", &enthalpyError}, {"potential", &potentialError}, {"pressure", &pressureError}, {"gravity_gradient", &gravityError}}}) { result[std::string(name) + "_relative_l2_error"] = error->RelativeL2(); result[std::string(name) + "_maximum_scaled_error"] = error->maximumScaledError; } for (int component = 0; component < 3; ++component) { result["center_of_mass_" + std::to_string(component)] = static_cast(firstMoment[component] / mass); } return result; } template Measurements MeasureSurfaceAndCorners(SampleState &state, const N1Reference &reference, const int order) { auto &mesh = *state.finiteElements.mesh; PhysicalPoint mapped; double minimumRadius = std::numeric_limits::infinity(), maximumRadius = 0.0; double maximumSurfaceEnthalpy = 0.0, maximumSurfacePotentialError = 0.0; long double area = 0.0L, radiusIntegral = 0.0L, radiusError = 0.0L; std::uint64_t boundarySamples = 0, invalidCornerSamples = 0, cornerSamples = 0; double minimumCornerDeterminant = std::numeric_limits::infinity(); double maximumCornerCondition = 0.0; for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) { if (mesh.GetBdrAttribute(boundary) != 1) continue; // Canonical sandbox stellar surface. // The tagged stellar surface is an interior material interface // when a vacuum region is present. Resolve the actual mesh face // to retain both adjacent traces in that case. const int faceIndex = mesh.GetBdrElementFaceIndex(boundary); if (faceIndex < 0) throw std::runtime_error("Missing stellar surface mesh face."); auto *face = mesh.GetFaceElementTransformations(faceIndex); if (face == nullptr || face->Elem1 == nullptr) throw std::runtime_error("Missing stellar surface transformation."); const bool first = state.isStellar(face->Elem1No); if (!first && (face->Elem2 == nullptr || !state.isStellar(face->Elem2No))) { throw std::runtime_error("Stellar surface has no stellar-material trace."); } const int element = first ? face->Elem1No : face->Elem2No; const auto &rule = mfem::IntRules.Get(face->GetGeometryType(), order); for (int q = 0; q < rule.GetNPoints(); ++q) { const auto &ip = rule.IntPoint(q); // Field evaluation may reuse MFEM's cached element transforms; // restore both adjacent face traces before each quadrature point. face = mesh.GetFaceElementTransformations(faceIndex); face->SetAllIntPoints(&ip); const auto volumePoint = first ? face->Elem1->GetIntPoint() : face->Elem2->GetIntPoint(); mfem::DenseMatrix referenceFaceJacobian(face->Jacobian()); if (state.Evaluate(element, volumePoint, mapped) != mean_field::mapping::MappingStatus::valid) { throw std::runtime_error("Invalid mapping on stellar surface."); } mfem::DenseMatrix physicalFaceJacobian(3, 2); mfem::Mult(mapped.mapping.mapping_jacobian, referenceFaceJacobian, physicalFaceJacobian); const double weight = ip.weight * physicalFaceJacobian.Weight(); const double radius = mapped.mapping.physical_position.Norml2(); if (!(weight > 0.0) || !std::isfinite(weight) || !std::isfinite(radius) || !std::isfinite(mapped.h) || !std::isfinite(mapped.phi)) { throw std::runtime_error("Non-finite field or non-positive physical surface integration weight."); } area += weight; radiusIntegral += weight * radius; radiusError += weight * (radius - reference.radius) * (radius - reference.radius); minimumRadius = std::min(minimumRadius, radius); maximumRadius = std::max(maximumRadius, radius); maximumSurfaceEnthalpy = std::max(maximumSurfaceEnthalpy, std::abs(mapped.h) / reference.CentralEnthalpy()); maximumSurfacePotentialError = std::max(maximumSurfacePotentialError, std::abs(mapped.phi + reference.CentralEnthalpy()) / reference.CentralEnthalpy()); ++boundarySamples; } } for (int element = 0; element < mesh.GetNE(); ++element) { if (!state.isStellar(element)) continue; const auto geometry = mesh.GetElementBaseGeometry(element); const auto &vertices = *mfem::Geometries.GetVertices(geometry); const auto ¢er = mfem::Geometries.GetCenter(geometry); for (int vertex = 0; vertex < vertices.GetNPoints(); ++vertex) { for (const double inset : {0.0, 0.005, 0.02}) { const auto &v = vertices.IntPoint(vertex); mfem::IntegrationPoint ip; ip.Set3((1.0-inset)*v.x+inset*center.x, (1.0-inset)*v.y+inset*center.y, (1.0-inset)*v.z+inset*center.z); ++cornerSamples; if (state.Evaluate(element, ip, mapped) != mean_field::mapping::MappingStatus::valid) { ++invalidCornerSamples; continue; } auto *transformation = mesh.GetElementTransformation(element); transformation->SetIntPoint(&ip); mfem::DenseMatrix totalJacobian(3); mfem::Mult(mapped.mapping.mapping_jacobian, transformation->Jacobian(), totalJacobian); minimumCornerDeterminant = std::min(minimumCornerDeterminant, mapped.mapping.mapping_determinant); const double determinant = totalJacobian.Det(); const double smallest = totalJacobian.CalcSingularvalue(2); const double largest = totalJacobian.CalcSingularvalue(0); if (!(determinant > 0.0) || !std::isfinite(determinant) || !(smallest > 0.0) || !std::isfinite(smallest) || !std::isfinite(largest)) ++invalidCornerSamples; else maximumCornerCondition = std::max(maximumCornerCondition, largest / smallest); } } } if (!(area > 0.0L) || !std::isfinite(area)) throw std::runtime_error("No finite positive stellar surface area."); return {{"surface_samples", static_cast(boundarySamples)}, {"surface_area", static_cast(area)}, {"surface_radius_minimum", minimumRadius}, {"surface_radius_maximum", maximumRadius}, {"surface_radius_area_mean", static_cast(radiusIntegral / area)}, {"surface_radius_relative_rms_error", static_cast(std::sqrt(radiusError / area) / reference.radius)}, {"surface_radius_relative_range", (maximumRadius - minimumRadius) / reference.radius}, {"surface_enthalpy_maximum_scaled", maximumSurfaceEnthalpy}, {"surface_potential_maximum_scaled_error", maximumSurfacePotentialError}, {"stellar_corner_samples", static_cast(cornerSamples)}, {"invalid_stellar_corner_samples", static_cast(invalidCornerSamples)}, {"minimum_stellar_corner_mapping_determinant", minimumCornerDeterminant}, {"maximum_stellar_corner_element_condition", maximumCornerCondition}}; } } // namespace experiment::polytrope_validation