feat(topology): vacuum coordinate and multi block
Two major changes in this version. First stroid now embeds a vacuum coordinate as part of its StroidMesh file (this is a packed set of mfem meshes and GridFunction). This is a logical coordinate from 0 at the stellar surface to 1 at the mesh surface / compactified infinity which can be used by consumers to much more stablly infer position in the vacuum region. Second, there is a new topology backend, multi_block, which has been made the default. See the readme for more information but the basic jist is that multi_block addes 6 transition blocks onto the edge of the core domain. This allows for a much more well conditioned transition from the internal cartesien region to the external spherical region. The mesh conditioning improves by roughly a factor of 1000 for the same refinement level when compared to the legacy topology. The legacy topology is maintained as a option if core_mapping is set to spherified in the config.
This commit is contained in:
126
tests/python/core_mapping_smoke.py
Normal file
126
tests/python/core_mapping_smoke.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Focused core-mapping smoke checks for a built or staged STROID Python module.
|
||||
|
||||
Example:
|
||||
python core_mapping_smoke.py --module _stroid --module-dir build/build-python
|
||||
python core_mapping_smoke.py --module-dir /path/to/staged/site-packages
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def element_counts(stroid, mesh):
|
||||
result = stroid.stats.ComputeMeshStats(
|
||||
mesh, stroid.stats.MeshStatFeatures.ELEMENT_COUNT
|
||||
)
|
||||
assert not result.errors, result.errors
|
||||
assert result.element_counts is not None
|
||||
return result.element_counts
|
||||
|
||||
|
||||
def run(stroid):
|
||||
default = stroid.config.MeshConfig()
|
||||
assert default.core_mapping == "spherified"
|
||||
default.core_mapping = "multi_block"
|
||||
assert default.core_mapping == "multi_block"
|
||||
assert "core_mapping: multi_block" in repr(default)
|
||||
|
||||
summaries = []
|
||||
with tempfile.TemporaryDirectory(prefix="stroid-python-smoke-") as output:
|
||||
output_path = Path(output)
|
||||
config_path = output_path / "multi_block.toml"
|
||||
config_path.write_text(
|
||||
'[main]\ncore_mapping = "multi_block"\nrefinement_levels = 0\n'
|
||||
'order = 3\ninclude_external_domain = false\n'
|
||||
'r_core = 0.25\nr_star = 1.0\nr_infinity = 6.0\n'
|
||||
'flattening = 0.0\nr_instability = 1e-14\n'
|
||||
'core_steepness = 1.0\ncontinuity_order = 2\n'
|
||||
'surface_bdr_id = 1\ninf_bdr_id = 2\n'
|
||||
'core_id = 1\nenvelope_id = 2\nvacuum_id = 3\n'
|
||||
'[main.optimization_methods]\ntmop = false\nsmoothstep = true\n'
|
||||
)
|
||||
configured_mesh = stroid.GenerateMesh(str(config_path))
|
||||
assert configured_mesh.config.core_mapping == "multi_block"
|
||||
assert element_counts(stroid, configured_mesh).total == 13
|
||||
|
||||
for mapping in ("spherified", "multi_block"):
|
||||
for external in (False, True):
|
||||
config = stroid.config.MeshConfig(
|
||||
core_mapping=mapping,
|
||||
refinement_levels=0,
|
||||
order=3,
|
||||
include_external_domain=external,
|
||||
optimization_methods=stroid.config.OptimizationMethods(
|
||||
tmop=False, smoothstep=True
|
||||
),
|
||||
)
|
||||
mesh = stroid.GenerateMesh(config)
|
||||
assert mesh.has_mesh() and mesh.has_rmesh()
|
||||
counts = element_counts(stroid, mesh)
|
||||
expected_core = 7 if mapping == "multi_block" else 1
|
||||
expected_total = expected_core + 6 + (6 if external else 0)
|
||||
assert counts.total == expected_total
|
||||
assert counts.core == expected_core
|
||||
assert counts.envelope == 6
|
||||
assert counts.vacuum == (6 if external else 0)
|
||||
|
||||
path = output_path / f"{mapping}-{external}.smesh"
|
||||
stroid.IO.SaveStroidMesh(mesh, str(path), "Python core-mapping smoke")
|
||||
loaded = stroid.IO.LoadStroidMesh(str(path))
|
||||
assert loaded.config.core_mapping == mapping
|
||||
assert element_counts(stroid, loaded).total == expected_total
|
||||
|
||||
stroid.refinement.UniformRefinement(loaded, 1)
|
||||
assert loaded.config.core_mapping == mapping
|
||||
assert loaded.refinement_levels == 1
|
||||
refined_counts = element_counts(stroid, loaded)
|
||||
assert refined_counts.total == expected_total * 8
|
||||
assert refined_counts.core == expected_core * 8
|
||||
|
||||
if mapping == "spherified":
|
||||
legacy = "\n".join(
|
||||
line for line in path.read_text().splitlines()
|
||||
if not line.startswith("core_mapping:")
|
||||
)
|
||||
legacy_mesh = stroid.IO.ParseStroidMesh(legacy)
|
||||
assert legacy_mesh.config.core_mapping == "spherified"
|
||||
assert element_counts(stroid, legacy_mesh).total == expected_total
|
||||
|
||||
summaries.append({
|
||||
"mapping": mapping,
|
||||
"external": external,
|
||||
"initial_elements": expected_total,
|
||||
"refined_elements": refined_counts.total,
|
||||
})
|
||||
|
||||
invalid = stroid.config.MeshConfig(
|
||||
core_mapping="unknown", refinement_levels=0
|
||||
)
|
||||
try:
|
||||
stroid.GenerateMesh(invalid)
|
||||
except (ValueError, RuntimeError):
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("Unsupported core_mapping was accepted")
|
||||
|
||||
return summaries
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--module", default="stroid")
|
||||
parser.add_argument("--module-dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.module_dir is not None:
|
||||
sys.path.insert(0, str(args.module_dir.resolve()))
|
||||
stroid = importlib.import_module(args.module)
|
||||
summaries = run(stroid)
|
||||
print(json.dumps({"module": stroid.__file__, "cases": summaries}, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -7,10 +7,13 @@
|
||||
#include "stroid/topology/mapping.h"
|
||||
#include "stroid/topology/topology.h"
|
||||
#include "stroid/utils/mesh_utils.h"
|
||||
#include "stroid/stroid.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <cstdlib>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <set>
|
||||
@@ -126,6 +129,94 @@ std::unique_ptr<mfem::Mesh> BuildProjectedMesh(const Config& cfg) {
|
||||
return mesh;
|
||||
}
|
||||
|
||||
void ExpectExteriorCoordinateRange(stroid::StroidMesh& stroid_mesh) {
|
||||
ASSERT_NE(stroid_mesh.mesh, nullptr);
|
||||
ASSERT_NE(stroid_mesh.exterior_coordinate, nullptr);
|
||||
ASSERT_NE(stroid_mesh.exterior_coordinate->space, nullptr);
|
||||
ASSERT_NE(stroid_mesh.exterior_coordinate->values, nullptr);
|
||||
ASSERT_EQ(stroid_mesh.exterior_coordinate->space->GetMesh(), stroid_mesh.mesh.get());
|
||||
ASSERT_EQ(stroid_mesh.exterior_coordinate->values->FESpace(), stroid_mesh.exterior_coordinate->space.get());
|
||||
|
||||
mfem::Mesh& mesh = *stroid_mesh.mesh;
|
||||
mfem::GridFunction& coordinate = *stroid_mesh.exterior_coordinate->values;
|
||||
const int vacuum_attribute = static_cast<int>(stroid_mesh.config.vacuum_id.value());
|
||||
bool sampled_vacuum = false;
|
||||
|
||||
for (int element_id = 0; element_id < mesh.GetNE(); ++element_id) {
|
||||
const mfem::FiniteElement& element = *stroid_mesh.exterior_coordinate->space->GetFE(element_id);
|
||||
const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(element.GetGeomType(), 2 * element.GetOrder() + 4);
|
||||
|
||||
for (int q = 0; q < integration_rule.GetNPoints(); ++q) {
|
||||
const double value = coordinate.GetValue(element_id, integration_rule.IntPoint(q));
|
||||
EXPECT_TRUE(std::isfinite(value));
|
||||
|
||||
if (mesh.GetAttribute(element_id) == vacuum_attribute) {
|
||||
sampled_vacuum = true;
|
||||
EXPECT_GE(value, -1.0e-12);
|
||||
EXPECT_LE(value, 1.0 + 1.0e-12);
|
||||
} else {
|
||||
EXPECT_NEAR(value, 0.0, 1.0e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(sampled_vacuum);
|
||||
}
|
||||
|
||||
void ExpectExteriorCoordinateBoundaryTraces(stroid::StroidMesh& stroid_mesh) {
|
||||
ASSERT_NE(stroid_mesh.mesh, nullptr);
|
||||
ASSERT_NE(stroid_mesh.exterior_coordinate, nullptr);
|
||||
ASSERT_NE(stroid_mesh.exterior_coordinate->values, nullptr);
|
||||
|
||||
mfem::Mesh& mesh = *stroid_mesh.mesh;
|
||||
mfem::GridFunction& coordinate = *stroid_mesh.exterior_coordinate->values;
|
||||
const int vacuum_attribute = static_cast<int>(stroid_mesh.config.vacuum_id.value());
|
||||
const int infinity_boundary = static_cast<int>(stroid_mesh.config.inf_bdr_id.value());
|
||||
int stellar_vacuum_faces = 0;
|
||||
int infinity_faces = 0;
|
||||
|
||||
for (int face_id = 0; face_id < mesh.GetNumFaces(); ++face_id) {
|
||||
mfem::FaceElementTransformations* transformation = mesh.GetFaceElementTransformations(face_id);
|
||||
if (transformation == nullptr || transformation->Elem1 == nullptr || transformation->Elem2 == nullptr) continue;
|
||||
|
||||
const bool element_1_vacuum = transformation->Elem1->Attribute == vacuum_attribute;
|
||||
const bool element_2_vacuum = transformation->Elem2->Attribute == vacuum_attribute;
|
||||
if (element_1_vacuum == element_2_vacuum) continue;
|
||||
|
||||
++stellar_vacuum_faces;
|
||||
const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 6);
|
||||
|
||||
for (int q = 0; q < integration_rule.GetNPoints(); ++q) {
|
||||
const mfem::IntegrationPoint& face_point = integration_rule.IntPoint(q);
|
||||
transformation->SetAllIntPoints(&face_point);
|
||||
|
||||
const int vacuum_element = element_1_vacuum ? transformation->Elem1No : transformation->Elem2No;
|
||||
const mfem::IntegrationPoint& vacuum_point = element_1_vacuum ? transformation->Elem1->GetIntPoint() : transformation->Elem2->GetIntPoint();
|
||||
EXPECT_NEAR(coordinate.GetValue(vacuum_element, vacuum_point), 0.0, 1.0e-12);
|
||||
}
|
||||
}
|
||||
|
||||
for (int boundary_element = 0; boundary_element < mesh.GetNBE(); ++boundary_element) {
|
||||
if (mesh.GetBdrAttribute(boundary_element) != infinity_boundary) continue;
|
||||
|
||||
mfem::FaceElementTransformations* transformation = mesh.GetBdrFaceTransformations(boundary_element);
|
||||
ASSERT_NE(transformation, nullptr);
|
||||
ASSERT_NE(transformation->Elem1, nullptr);
|
||||
++infinity_faces;
|
||||
|
||||
const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 6);
|
||||
|
||||
for (int q = 0; q < integration_rule.GetNPoints(); ++q) {
|
||||
const mfem::IntegrationPoint& face_point = integration_rule.IntPoint(q);
|
||||
transformation->SetAllIntPoints(&face_point);
|
||||
EXPECT_NEAR(coordinate.GetValue(transformation->Elem1No, transformation->Elem1->GetIntPoint()), 1.0, 1.0e-12);
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_GT(stellar_vacuum_faces, 0);
|
||||
EXPECT_GT(infinity_faces, 0);
|
||||
}
|
||||
|
||||
double ComputeStellarVolumeWithDomainLFIntegrator(mfem::Mesh& mesh, const Config& cfg) {
|
||||
const int mesh_max_attr = mesh.attributes.Size() > 0 ? mesh.attributes.Max() : 0;
|
||||
const int cfg_max_attr = static_cast<int>(std::max({cfg->core_id.value(), cfg->envelope_id.value(), cfg->vacuum_id.value()}));
|
||||
@@ -1043,6 +1134,134 @@ TEST_F(stroidTest, Refinement_UniformRefinementProducesExpectedElementCounts) {
|
||||
EXPECT_EQ(mesh.mesh->GetNE(), init_elements * 8);
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, ExteriorCoordinate_HasValidRangeAndExactBoundaryTraces) {
|
||||
const auto cfg_ptr = LoadConfigFromRepo("configs/test_volume_with_external.toml");
|
||||
const auto& cfg = *cfg_ptr;
|
||||
|
||||
stroid::StroidMesh mesh;
|
||||
ASSERT_NO_THROW(mesh = stroid::GenerateMesh(cfg));
|
||||
|
||||
ExpectExteriorCoordinateRange(mesh);
|
||||
ExpectExteriorCoordinateBoundaryTraces(mesh);
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, ExteriorCoordinate_IsRebuiltAfterUniformRefinement) {
|
||||
const auto cfg_ptr = LoadConfigFromRepo("configs/test_volume_with_external.toml");
|
||||
const auto& cfg = *cfg_ptr;
|
||||
|
||||
stroid::StroidMesh mesh;
|
||||
ASSERT_NO_THROW(mesh = stroid::GenerateMesh(cfg));
|
||||
ASSERT_NE(mesh.exterior_coordinate, nullptr);
|
||||
|
||||
const int initial_elements = mesh.mesh->GetNE();
|
||||
const int initial_coordinate_dofs = mesh.exterior_coordinate->space->GetNDofs();
|
||||
|
||||
ASSERT_NO_THROW(stroid::refinement::UniformRefinement(mesh, 1));
|
||||
ASSERT_NE(mesh.exterior_coordinate, nullptr);
|
||||
EXPECT_EQ(mesh.mesh->GetNE(), initial_elements * 8);
|
||||
EXPECT_GT(mesh.exterior_coordinate->space->GetNDofs(), initial_coordinate_dofs);
|
||||
|
||||
ExpectExteriorCoordinateRange(mesh);
|
||||
ExpectExteriorCoordinateBoundaryTraces(mesh);
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, ExteriorCoordinate_SurvivesSaveAndLoad) {
|
||||
const auto cfg_ptr = LoadConfigFromRepo("configs/test_volume_with_external.toml");
|
||||
const auto& cfg = *cfg_ptr;
|
||||
|
||||
stroid::StroidMesh original;
|
||||
ASSERT_NO_THROW(original = stroid::GenerateMesh(cfg));
|
||||
ASSERT_NE(original.exterior_coordinate, nullptr);
|
||||
|
||||
const std::filesystem::path path = std::filesystem::temp_directory_path() / "stroid_exterior_coordinate_round_trip.smesh";
|
||||
ASSERT_NO_THROW(stroid::IO::SaveStroidMesh(original, path.string(), "Exterior-coordinate round-trip test"));
|
||||
|
||||
auto loaded_result = stroid::IO::LoadStroidMesh(path.string());
|
||||
if (!loaded_result.has_value()) FAIL() << loaded_result.error();
|
||||
stroid::StroidMesh loaded = std::move(*loaded_result);
|
||||
|
||||
ASSERT_NE(loaded.exterior_coordinate, nullptr);
|
||||
ASSERT_EQ(loaded.exterior_coordinate->space->GetNDofs(), original.exterior_coordinate->space->GetNDofs());
|
||||
ASSERT_EQ(loaded.exterior_coordinate->values->Size(), original.exterior_coordinate->values->Size());
|
||||
|
||||
for (int dof = 0; dof < original.exterior_coordinate->values->Size(); ++dof) {
|
||||
EXPECT_DOUBLE_EQ((*loaded.exterior_coordinate->values)(dof), (*original.exterior_coordinate->values)(dof));
|
||||
}
|
||||
|
||||
ExpectExteriorCoordinateRange(loaded);
|
||||
ExpectExteriorCoordinateBoundaryTraces(loaded);
|
||||
|
||||
std::error_code error;
|
||||
std::filesystem::remove(path, error);
|
||||
EXPECT_FALSE(error);
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, ExteriorCoordinate_IsAbsentWithoutExternalDomainAcrossSaveAndLoad) {
|
||||
const auto cfg_ptr = LoadConfigFromRepo("configs/test_volume_spherical_no_external.toml");
|
||||
const auto& cfg = *cfg_ptr;
|
||||
|
||||
stroid::StroidMesh original;
|
||||
ASSERT_NO_THROW(original = stroid::GenerateMesh(cfg));
|
||||
EXPECT_EQ(original.exterior_coordinate, nullptr);
|
||||
|
||||
const std::filesystem::path path = std::filesystem::temp_directory_path() / "stroid_no_exterior_coordinate_round_trip.smesh";
|
||||
ASSERT_NO_THROW(stroid::IO::SaveStroidMesh(original, path.string(), "No-exterior-coordinate round-trip test"));
|
||||
|
||||
auto loaded_result = stroid::IO::LoadStroidMesh(path.string());
|
||||
if (!loaded_result.has_value()) FAIL() << loaded_result.error();
|
||||
EXPECT_EQ(loaded_result->exterior_coordinate, nullptr);
|
||||
|
||||
std::error_code error;
|
||||
std::filesystem::remove(path, error);
|
||||
EXPECT_FALSE(error);
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, ExteriorCoordinate_IsReconstructedWhenLoadingLegacyFiles) {
|
||||
const auto cfg_ptr = LoadConfigFromRepo("configs/test_volume_with_external.toml");
|
||||
const auto& cfg = *cfg_ptr;
|
||||
|
||||
stroid::StroidMesh original;
|
||||
ASSERT_NO_THROW(original = stroid::GenerateMesh(cfg));
|
||||
ASSERT_NE(original.exterior_coordinate, nullptr);
|
||||
|
||||
const std::filesystem::path path = std::filesystem::temp_directory_path() / "stroid_legacy_exterior_coordinate.smesh";
|
||||
ASSERT_NO_THROW(stroid::IO::SaveStroidMesh(original, path.string(), "Legacy exterior-coordinate reconstruction test"));
|
||||
|
||||
std::ifstream input(path);
|
||||
ASSERT_TRUE(input.is_open());
|
||||
std::string contents((std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());
|
||||
|
||||
constexpr std::string_view begin_marker = "BEGIN BLOCK EXTERIOR_COORDINATE";
|
||||
constexpr std::string_view end_marker = "END BLOCK EXTERIOR_COORDINATE";
|
||||
const size_t begin = contents.find(begin_marker);
|
||||
const size_t end_begin = contents.find(end_marker);
|
||||
ASSERT_NE(begin, std::string::npos);
|
||||
ASSERT_NE(end_begin, std::string::npos);
|
||||
|
||||
size_t end = end_begin + end_marker.size();
|
||||
if (end < contents.size() && contents[end] == '\n') ++end;
|
||||
contents.erase(begin, end - begin);
|
||||
|
||||
std::istringstream legacy_stream(contents);
|
||||
auto loaded_result = stroid::IO::ParseStroidMesh(legacy_stream);
|
||||
if (!loaded_result.has_value()) FAIL() << loaded_result.error();
|
||||
|
||||
stroid::StroidMesh loaded = std::move(*loaded_result);
|
||||
ASSERT_NE(loaded.exterior_coordinate, nullptr);
|
||||
ASSERT_EQ(loaded.exterior_coordinate->values->Size(), original.exterior_coordinate->values->Size());
|
||||
|
||||
for (int dof = 0; dof < original.exterior_coordinate->values->Size(); ++dof) {
|
||||
EXPECT_NEAR((*loaded.exterior_coordinate->values)(dof), (*original.exterior_coordinate->values)(dof), 1.0e-12);
|
||||
}
|
||||
|
||||
ExpectExteriorCoordinateRange(loaded);
|
||||
ExpectExteriorCoordinateBoundaryTraces(loaded);
|
||||
|
||||
std::error_code error;
|
||||
std::filesystem::remove(path, error);
|
||||
EXPECT_FALSE(error);
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, Stats_ComputeStats) {
|
||||
const auto cfg_ptr = LoadConfigFromRepo("configs/test_volume_with_external.toml");
|
||||
const auto& cfg = *cfg_ptr;
|
||||
@@ -1055,4 +1274,360 @@ TEST_F(stroidTest, Stats_ComputeStats) {
|
||||
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::unique_ptr<Config> MultiBlockConfiguration(int order, int refinement, bool external, double flattening = 0.0) {
|
||||
auto cfg = std::make_unique<Config>();
|
||||
cfg->mutate([&](stroid::config::MeshConfig& value) {
|
||||
value.core_mapping = "multi_block";
|
||||
value.order = order;
|
||||
value.refinement_levels = refinement;
|
||||
value.include_external_domain = external;
|
||||
value.flattening = flattening;
|
||||
value.optimization_methods = stroid::config::OptimizationMethods{false, false};
|
||||
});
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// Unlike CollectConditioningStats, this uses the signed determinant, actual
|
||||
// singular values, and a closed sample grid including vertices/edges/faces.
|
||||
// Column-length ratios and open quadrature points miss the old core-corner defect.
|
||||
void ExpectClosedGridCoreConditioning(mfem::Mesh& mesh, int coreAttribute, double maximumCondition = 10.0) {
|
||||
int coreElements = 0;
|
||||
double largestCondition = 0.0;
|
||||
double smallestDeterminant = std::numeric_limits<double>::infinity();
|
||||
for (int element = 0; element < mesh.GetNE(); ++element) {
|
||||
if (mesh.GetAttribute(element) != coreAttribute) continue;
|
||||
++coreElements;
|
||||
auto* transformation = mesh.GetElementTransformation(element);
|
||||
ASSERT_EQ(transformation->GetGeometryType(), mfem::Geometry::CUBE);
|
||||
for (double x : {0.0, 0.01, 0.5, 0.99, 1.0}) {
|
||||
for (double y : {0.0, 0.01, 0.5, 0.99, 1.0}) {
|
||||
for (double z : {0.0, 0.01, 0.5, 0.99, 1.0}) {
|
||||
mfem::IntegrationPoint point;
|
||||
point.Set3(x, y, z);
|
||||
transformation->SetIntPoint(&point);
|
||||
const auto& jacobian = transformation->Jacobian();
|
||||
const double determinant = jacobian.Det();
|
||||
const double minimumSingular = jacobian.CalcSingularvalue(2);
|
||||
const double maximumSingular = jacobian.CalcSingularvalue(0);
|
||||
ASSERT_TRUE(std::isfinite(determinant));
|
||||
ASSERT_GT(determinant, 0.0) << "element=" << element << " point=" << x << ',' << y << ',' << z;
|
||||
ASSERT_TRUE(std::isfinite(minimumSingular));
|
||||
ASSERT_GT(minimumSingular, 0.0) << "element=" << element;
|
||||
const double condition = maximumSingular / minimumSingular;
|
||||
ASSERT_TRUE(std::isfinite(condition));
|
||||
ASSERT_LT(condition, maximumCondition)
|
||||
<< "element=" << element << " point=" << x << ',' << y << ',' << z;
|
||||
smallestDeterminant = std::min(smallestDeterminant, determinant);
|
||||
largestCondition = std::max(largestCondition, condition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPECT_GT(coreElements, 0);
|
||||
EXPECT_GT(smallestDeterminant, 0.0);
|
||||
EXPECT_LT(largestCondition, maximumCondition);
|
||||
}
|
||||
|
||||
void ExpectCoreFaceContinuity(mfem::Mesh& mesh, int coreAttribute) {
|
||||
int faces = 0;
|
||||
mfem::Vector left(3), right(3);
|
||||
for (int face = 0; face < mesh.GetNumFaces(); ++face) {
|
||||
auto* transformation = mesh.GetFaceElementTransformations(face);
|
||||
if (transformation == nullptr || transformation->Elem1 == nullptr || transformation->Elem2 == nullptr) continue;
|
||||
if (transformation->Elem1->Attribute != coreAttribute && transformation->Elem2->Attribute != coreAttribute) continue;
|
||||
++faces;
|
||||
for (double x : {0.0, 0.25, 0.5, 0.75, 1.0}) {
|
||||
for (double y : {0.0, 0.25, 0.5, 0.75, 1.0}) {
|
||||
mfem::IntegrationPoint point;
|
||||
point.Set2(x, y);
|
||||
transformation->SetAllIntPoints(&point);
|
||||
transformation->Elem1->Transform(transformation->Elem1->GetIntPoint(), left);
|
||||
transformation->Elem2->Transform(transformation->Elem2->GetIntPoint(), right);
|
||||
left -= right;
|
||||
EXPECT_LT(left.Norml2(), 2.0e-12) << "face=" << face;
|
||||
}
|
||||
}
|
||||
}
|
||||
EXPECT_GT(faces, 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_F(stroidTest, MultiBlockCore_TopologyCountsAndAttributesAreOptIn) {
|
||||
EXPECT_EQ(stroid::config::MeshConfig{}.core_mapping.value(), "spherified");
|
||||
for (const bool external : {false, true}) {
|
||||
SCOPED_TRACE(external);
|
||||
auto cfg = MultiBlockConfiguration(2, 0, external);
|
||||
auto mesh = stroid::topology::BuildSkeleton(*cfg);
|
||||
ASSERT_NE(mesh, nullptr);
|
||||
EXPECT_EQ(mesh->GetNV(), external ? 32 : 24);
|
||||
EXPECT_EQ(mesh->GetNE(), external ? 19 : 13);
|
||||
EXPECT_EQ(mesh->GetNBE(), external ? 12 : 6);
|
||||
const auto volumes = CountVolumeAttributes(*mesh);
|
||||
EXPECT_EQ(volumes.at(1), 7);
|
||||
EXPECT_EQ(volumes.at(2), 6);
|
||||
EXPECT_EQ(volumes.contains(3), external);
|
||||
if (external) EXPECT_EQ(volumes.at(3), 6);
|
||||
const auto boundaries = CountBoundaryAttributes(*mesh);
|
||||
EXPECT_EQ(boundaries.at(1), 6);
|
||||
EXPECT_EQ(boundaries.contains(2), external);
|
||||
if (external) EXPECT_EQ(boundaries.at(2), 6);
|
||||
|
||||
cfg->mutate([](stroid::config::MeshConfig& value) { value.core_mapping = "spherified"; });
|
||||
auto legacy = stroid::topology::BuildSkeleton(*cfg);
|
||||
EXPECT_EQ(legacy->GetNE(), external ? 13 : 7);
|
||||
EXPECT_EQ(CountVolumeAttributes(*legacy).at(1), 1);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, MultiBlockCore_RejectsUnknownMappingAndInvalidGeometryConfiguration) {
|
||||
auto cfg = MultiBlockConfiguration(2, 0, true);
|
||||
cfg->mutate([](stroid::config::MeshConfig& value) { value.core_mapping = "not_a_core_mapping"; });
|
||||
EXPECT_THROW(stroid::topology::BuildSkeleton(*cfg), std::invalid_argument);
|
||||
cfg = MultiBlockConfiguration(2, 0, true);
|
||||
cfg->mutate([](stroid::config::MeshConfig& value) { value.r_core = value.r_star; });
|
||||
EXPECT_THROW(stroid::topology::BuildSkeleton(*cfg), std::invalid_argument);
|
||||
cfg = MultiBlockConfiguration(2, 0, true);
|
||||
cfg->mutate([](stroid::config::MeshConfig& value) { value.r_infinity = value.r_star; });
|
||||
EXPECT_THROW(stroid::topology::BuildSkeleton(*cfg), std::invalid_argument);
|
||||
cfg = MultiBlockConfiguration(2, 0, true);
|
||||
cfg->mutate([](stroid::config::MeshConfig& value) { value.flattening = 1.0; });
|
||||
EXPECT_THROW(stroid::topology::BuildSkeleton(*cfg), std::invalid_argument);
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, MultiBlockCore_MapHasAffineInnerCubeAndContinuousSphericalInterface) {
|
||||
auto cfg = MultiBlockConfiguration(4, 0, true);
|
||||
const double radius = (*cfg)->r_core.value();
|
||||
for (int axis = 0; axis < 3; ++axis) {
|
||||
for (double sign : {-1.0, 1.0}) {
|
||||
for (double a : {-1.0, -0.4, 0.0, 0.6, 1.0}) {
|
||||
for (double b : {-1.0, -0.3, 0.0, 0.7, 1.0}) {
|
||||
mfem::Vector direction(3);
|
||||
direction(axis) = sign;
|
||||
direction((axis + 1) % 3) = a;
|
||||
direction((axis + 2) % 3) = b;
|
||||
mfem::Vector inner(direction);
|
||||
inner *= radius / 2.0;
|
||||
mfem::Vector expected(inner);
|
||||
expected /= std::sqrt(3.0);
|
||||
mfem::Vector mapped = TransformCopy(inner, *cfg, 1);
|
||||
mapped -= expected;
|
||||
EXPECT_LT(mapped.Norml2(), 2.0e-14);
|
||||
for (double interfaceRadius : {radius / 2.0, radius}) {
|
||||
mfem::Vector inside(direction), outside(direction);
|
||||
inside *= interfaceRadius * (1.0 - 1.0e-8);
|
||||
outside *= interfaceRadius * (1.0 + 1.0e-8);
|
||||
mapped = TransformCopy(inside, *cfg, 1);
|
||||
mapped -= TransformCopy(outside, *cfg, interfaceRadius == radius ? 2 : 1);
|
||||
EXPECT_LT(mapped.Norml2(), 1.0e-7 * radius);
|
||||
}
|
||||
mfem::Vector coreInterface(direction);
|
||||
coreInterface *= radius;
|
||||
EXPECT_NEAR(TransformCopy(coreInterface, *cfg, 1).Norml2(), radius, 2.0e-14);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
auto mesh = stroid::GenerateMesh(*cfg);
|
||||
ASSERT_NE(mesh.mesh, nullptr);
|
||||
ExpectCoreFaceContinuity(*mesh.mesh, 1);
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, MultiBlockCore_ClosedGridSignedJacobiansAndSvdAcrossOrdersAndRefinements) {
|
||||
for (int order = 1; order <= 6; ++order) {
|
||||
for (int refinement = 0; refinement <= 2; ++refinement) {
|
||||
SCOPED_TRACE("order=" + std::to_string(order) + " refinement=" + std::to_string(refinement));
|
||||
auto cfg = MultiBlockConfiguration(order, refinement, false);
|
||||
auto mesh = stroid::GenerateMesh(*cfg);
|
||||
ASSERT_NE(mesh.mesh, nullptr);
|
||||
const int factor = 1 << (3 * refinement);
|
||||
EXPECT_EQ(mesh.mesh->GetNE(), 13 * factor);
|
||||
EXPECT_EQ(CountVolumeAttributes(*mesh.mesh).at(1), 7 * factor);
|
||||
ExpectClosedGridCoreConditioning(*mesh.mesh, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, MultiBlockCore_MapIsScaleInvariantBelowLegacyRadiusCutoff) {
|
||||
constexpr double scale = 1.0e-15;
|
||||
auto reference = MultiBlockConfiguration(2, 0, true);
|
||||
reference->mutate([](stroid::config::MeshConfig& value) { value.r_infinity = 5.0; });
|
||||
auto scaled = MultiBlockConfiguration(2, 0, true);
|
||||
scaled->mutate([](stroid::config::MeshConfig& value) {
|
||||
value.r_core = 2.5e-16;
|
||||
value.r_star = 1.0e-15;
|
||||
value.r_infinity = 5.0e-15;
|
||||
});
|
||||
const std::array<std::array<double, 3>, 10> points{{
|
||||
{{0.0, 0.0, 0.0}},
|
||||
{{0.05, -0.04, 0.1}},
|
||||
{{0.125, 0.08, -0.02}},
|
||||
{{0.18, -0.09, 0.12}},
|
||||
{{-0.2, -0.2, -0.2}},
|
||||
{{0.25, 0.12, -0.2}},
|
||||
{{0.6, -0.2, 0.4}},
|
||||
{{1.0, 0.7, -0.3}},
|
||||
{{3.0, -1.3, 0.4}},
|
||||
{{-5.0, 2.1, -1.0}}
|
||||
}};
|
||||
for (const auto& coordinates : points) {
|
||||
mfem::Vector point(3);
|
||||
for (int component = 0; component < 3; ++component) point(component) = coordinates[component];
|
||||
const double logicalRadius = std::max({std::abs(point(0)), std::abs(point(1)), std::abs(point(2))});
|
||||
const int attribute = logicalRadius <= 0.25 ? 1 : logicalRadius <= 1.0 ? 2 : 3;
|
||||
const auto expected = TransformCopy(point, *reference, attribute);
|
||||
point *= scale;
|
||||
auto actual = TransformCopy(point, *scaled, attribute);
|
||||
actual /= scale;
|
||||
for (int component = 0; component < 3; ++component) {
|
||||
EXPECT_NEAR(actual(component), expected(component), 2.0e-13)
|
||||
<< "logical radius=" << logicalRadius << " component=" << component;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, MultiBlockCore_FlatteningCustomIdsAndExteriorCoordinateRemainConsistent) {
|
||||
auto cfg = MultiBlockConfiguration(3, 1, true, 0.2);
|
||||
cfg->mutate([](stroid::config::MeshConfig& value) {
|
||||
value.core_id = 11;
|
||||
value.envelope_id = 17;
|
||||
value.vacuum_id = 23;
|
||||
value.surface_bdr_id = 31;
|
||||
value.inf_bdr_id = 37;
|
||||
});
|
||||
auto mesh = stroid::GenerateMesh(*cfg);
|
||||
ASSERT_NE(mesh.mesh, nullptr);
|
||||
const auto volume = CountVolumeAttributes(*mesh.mesh);
|
||||
EXPECT_EQ(volume.at(11), 7 * 8);
|
||||
EXPECT_EQ(volume.at(17), 6 * 8);
|
||||
EXPECT_EQ(volume.at(23), 6 * 8);
|
||||
const auto boundary = CountBoundaryAttributes(*mesh.mesh);
|
||||
EXPECT_EQ(boundary.at(31), 6 * 4);
|
||||
EXPECT_EQ(boundary.at(37), 6 * 4);
|
||||
ExpectClosedGridCoreConditioning(*mesh.mesh, 11);
|
||||
ExpectCoreFaceContinuity(*mesh.mesh, 11);
|
||||
ExpectExteriorCoordinateRange(mesh);
|
||||
ExpectExteriorCoordinateBoundaryTraces(mesh);
|
||||
mfem::Vector point(3);
|
||||
point(0) = 0.25;
|
||||
point(1) = 0.25;
|
||||
point(2) = 0.25;
|
||||
auto mapped = TransformCopy(point, *cfg, 11);
|
||||
mapped(2) /= 0.8;
|
||||
EXPECT_NEAR(mapped.Norml2(), 0.25, 2.0e-14);
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, MultiBlockCore_OuterMappingAndSignedStellarVolumeMatchLegacy) {
|
||||
auto cfg = MultiBlockConfiguration(3, 1, true);
|
||||
auto legacyCfg = MultiBlockConfiguration(3, 1, true);
|
||||
legacyCfg->mutate([](stroid::config::MeshConfig& value) { value.core_mapping = "spherified"; });
|
||||
const double coreRadius = (*cfg)->r_core.value();
|
||||
const double stellarRadius = (*cfg)->r_star.value();
|
||||
const double infinityRadius = (*cfg)->r_infinity.value();
|
||||
for (int axis = 0; axis < 3; ++axis) {
|
||||
for (double sign : {-1.0, 1.0}) {
|
||||
for (double a : {-1.0, -0.3, 0.0, 0.8, 1.0}) {
|
||||
for (double b : {-1.0, 0.0, 0.4, 1.0}) {
|
||||
mfem::Vector direction(3);
|
||||
direction(axis) = sign;
|
||||
direction((axis + 1) % 3) = a;
|
||||
direction((axis + 2) % 3) = b;
|
||||
for (double radius : {coreRadius, (coreRadius + stellarRadius) / 2.0, stellarRadius,
|
||||
(stellarRadius + infinityRadius) / 2.0, infinityRadius}) {
|
||||
mfem::Vector point(direction);
|
||||
point *= radius;
|
||||
const int attribute = radius <= stellarRadius ? 2 : 3;
|
||||
auto difference = TransformCopy(point, *cfg, attribute);
|
||||
difference -= TransformCopy(point, *legacyCfg, attribute);
|
||||
EXPECT_LT(difference.Norml2(), 2.0e-14 * infinityRadius);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
auto mesh = stroid::GenerateMesh(*cfg);
|
||||
auto legacy = stroid::GenerateMesh(*legacyCfg);
|
||||
const auto signedStellarVolume = [](mfem::Mesh& candidate) {
|
||||
double volume = 0.0;
|
||||
for (int element = 0; element < candidate.GetNE(); ++element) {
|
||||
if (candidate.GetAttribute(element) == 3) continue;
|
||||
auto* transformation = candidate.GetElementTransformation(element);
|
||||
const auto& rule = mfem::IntRules.Get(transformation->GetGeometryType(), 3 * transformation->Order() + 2);
|
||||
for (int q = 0; q < rule.GetNPoints(); ++q) {
|
||||
const auto& point = rule.IntPoint(q);
|
||||
transformation->SetIntPoint(&point);
|
||||
volume += point.weight * transformation->Jacobian().Det();
|
||||
}
|
||||
}
|
||||
return volume;
|
||||
};
|
||||
const double newVolume = signedStellarVolume(*mesh.mesh);
|
||||
const double oldVolume = signedStellarVolume(*legacy.mesh);
|
||||
EXPECT_GT(newVolume, 0.0);
|
||||
EXPECT_NEAR(newVolume, oldVolume, 2.0e-11 * oldVolume);
|
||||
}
|
||||
|
||||
TEST_F(stroidTest, MultiBlockCore_SaveLoadConfigAndRefinementPreserveContracts) {
|
||||
for (const bool external : {false, true}) {
|
||||
SCOPED_TRACE(external);
|
||||
auto cfg = MultiBlockConfiguration(3, 0, external);
|
||||
auto original = stroid::GenerateMesh(*cfg);
|
||||
EXPECT_EQ(original.type, stroid::MFEM_MESH_TYPE::SERIAL);
|
||||
const auto path = std::filesystem::temp_directory_path() /
|
||||
(external ? "stroid_multiblock_external_round_trip.smesh" : "stroid_multiblock_stellar_round_trip.smesh");
|
||||
stroid::IO::SaveStroidMesh(original, path.string(), "Multi-block core regression");
|
||||
auto result = stroid::IO::LoadStroidMesh(path.string());
|
||||
ASSERT_TRUE(result.has_value()) << result.error();
|
||||
auto loaded = std::move(*result);
|
||||
EXPECT_EQ(loaded.type, stroid::MFEM_MESH_TYPE::SERIAL);
|
||||
ASSERT_NE(loaded.mesh, nullptr);
|
||||
ASSERT_NE(loaded.reference_mesh, nullptr);
|
||||
EXPECT_EQ(loaded.config.core_mapping.value(), "multi_block");
|
||||
EXPECT_EQ(loaded.config.include_external_domain.value(), external);
|
||||
EXPECT_EQ(loaded.mesh->GetNE(), original.mesh->GetNE());
|
||||
ASSERT_EQ(loaded.mesh->GetNodes()->Size(), original.mesh->GetNodes()->Size());
|
||||
for (int dof = 0; dof < original.mesh->GetNodes()->Size(); ++dof) {
|
||||
EXPECT_NEAR((*loaded.mesh->GetNodes())(dof), (*original.mesh->GetNodes())(dof), 2.0e-14);
|
||||
}
|
||||
stroid::refinement::UniformRefinement(loaded, 1);
|
||||
EXPECT_EQ(loaded.refinement_levels, 1);
|
||||
EXPECT_EQ(loaded.mesh->GetNE(), original.mesh->GetNE() * 8);
|
||||
EXPECT_EQ(CountVolumeAttributes(*loaded.mesh).at(1), 7 * 8);
|
||||
ExpectClosedGridCoreConditioning(*loaded.mesh, 1);
|
||||
ExpectCoreFaceContinuity(*loaded.mesh, 1);
|
||||
if (external) {
|
||||
ExpectExteriorCoordinateRange(loaded);
|
||||
ExpectExteriorCoordinateBoundaryTraces(loaded);
|
||||
} else {
|
||||
EXPECT_EQ(loaded.exterior_coordinate, nullptr);
|
||||
}
|
||||
std::error_code error;
|
||||
std::filesystem::remove(path, error);
|
||||
EXPECT_FALSE(error);
|
||||
}
|
||||
|
||||
auto legacyCfg = MultiBlockConfiguration(2, 0, false);
|
||||
legacyCfg->mutate([](stroid::config::MeshConfig& value) { value.core_mapping = "spherified"; });
|
||||
auto legacy = stroid::GenerateMesh(*legacyCfg);
|
||||
EXPECT_EQ(legacy.type, stroid::MFEM_MESH_TYPE::SERIAL);
|
||||
const auto path = std::filesystem::temp_directory_path() / "stroid_core_mapping_legacy_round_trip.smesh";
|
||||
stroid::IO::SaveStroidMesh(legacy, path.string(), "Legacy core mapping default regression");
|
||||
std::ifstream input(path);
|
||||
std::string contents((std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());
|
||||
const auto marker = contents.find("\ncore_mapping:");
|
||||
ASSERT_NE(marker, std::string::npos);
|
||||
const auto fieldStart = marker + 1;
|
||||
const auto newline = contents.find('\n', fieldStart);
|
||||
ASSERT_NE(newline, std::string::npos);
|
||||
contents.erase(fieldStart, newline - fieldStart + 1);
|
||||
std::istringstream legacyStream(contents);
|
||||
auto restored = stroid::IO::ParseStroidMesh(legacyStream);
|
||||
ASSERT_TRUE(restored.has_value()) << restored.error();
|
||||
EXPECT_EQ(restored->config.core_mapping.value(), "spherified");
|
||||
EXPECT_EQ(CountVolumeAttributes(*restored->mesh).at(1), 1);
|
||||
std::error_code error;
|
||||
std::filesystem::remove(path, error);
|
||||
EXPECT_FALSE(error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user