diff --git a/PLAN.md b/PLAN.md index 23809d2..16d3677 100644 --- a/PLAN.md +++ b/PLAN.md @@ -13,7 +13,7 @@ Every new agent session must read this file together with `PROGRESS.md` before p - If an item becomes obsolete, move it to `PROGRESS.md` with a short reason instead of silently deleting it. ## Current Objective -Continue the new Phase 1 rebaseline plan in `phases/1-linear-static-mitc4-rebaseline`, starting with P1R-06 results model and displacement CSV comparator foundation. The old `phases/1-linear-static-mitc4` path is historical and superseded by the paper-based MITC4 formulation reset. +Continue the new Phase 1 rebaseline plan in `phases/1-linear-static-mitc4-rebaseline`, starting with P1R-07 MITC4 geometry, node order, tying points, directors, and local bases. The old `phases/1-linear-static-mitc4` path is historical and superseded by the paper-based MITC4 formulation reset. ## Required Reading For New Agents 1. `AGENTS.md` @@ -36,7 +36,7 @@ Continue the new Phase 1 rebaseline plan in `phases/1-linear-static-mitc4-rebase ## Active Phase Files - Active phase directory: `phases/1-linear-static-mitc4-rebaseline` - Execute with: `python scripts/execute.py 1-linear-static-mitc4-rebaseline` -- Step numbering is zero-based. `step0.md` is complete and recorded in `phases/1-linear-static-mitc4-rebaseline/step0-audit.md`; `step1.md` is complete and created the `quad_02_phase1.inp` normalized reference path; `step2.md` is complete and revalidated core harness guardrails; `step3.md` is complete and revalidated the Phase 1 parser/domain subset; `step4.md` is complete and strengthened validation/singular diagnostics; `step5.md` is complete and revalidated the DofManager/reaction foundation; `step15.md` is the independent evaluator closeout. +- Step numbering is zero-based. `step0.md` is complete and recorded in `phases/1-linear-static-mitc4-rebaseline/step0-audit.md`; `step1.md` is complete and created the `quad_02_phase1.inp` normalized reference path; `step2.md` is complete and revalidated core harness guardrails; `step3.md` is complete and revalidated the Phase 1 parser/domain subset; `step4.md` is complete and strengthened validation/singular diagnostics; `step5.md` is complete and revalidated the DofManager/reaction foundation; `step6.md` is complete and revalidated the minimum result model plus displacement CSV comparator; `step15.md` is the independent evaluator closeout. - Every step file contains a sprint contract with objective, required reading, scope, allowed files, explicit non-goals, tests to write first, reference artifacts, acceptance command, evaluator checklist, and handoff requirements. - Historical phase directory: `phases/1-linear-static-mitc4` - Historical phase status: blocked/superseded. Do not resume the old P1-15/P1-16 path unless the user explicitly requests recovery of that exact phase. @@ -75,7 +75,7 @@ Each gate should be satisfied before moving to the next implementation band unle | G0 - Planning readiness | partial | Readiness task R-011 is resolved by `quad_02_phase1.inp`; R-010 and R-013 remain open. | Updated docs, PLAN.md, PROGRESS.md | | G1 - Build and validation | satisfied | Build system, test framework, and `scripts/validate_workspace.py` run real checks. | Validation command output | | G2 - Parser and domain | satisfied | Parser subset revalidated in step 3; validation and singular diagnostics revalidated in step 4. | Parser acceptance/rejection tests, validation negative tests, and validation output | -| G3 - DOF/math/results infrastructure | partial | Core aliases, DOF mapping, validation harness, model diagnostic context, DofManager, sparse-connectivity inputs, and full-vector reaction formula were revalidated in steps 2 and 5; results and assembly remain for steps 6 and 12. | P1R-02 and P1R-05 validation output | +| G3 - DOF/math/results infrastructure | partial | Core aliases, DOF mapping, validation harness, model diagnostic context, DofManager, sparse-connectivity inputs, full-vector reaction formula, result model metadata, and displacement CSV comparator were revalidated in steps 2, 5, and 6; assembly remains for step 12. | P1R-02, P1R-05, and P1R-06 validation output | | G4 - MITC4 element readiness | reopened | MITC4 formulation was rewritten from local papers; element implementation must be rebuilt or revalidated through steps 7 through 11. | Revised `docs/MITC4_FORMULATION.md`, future element tests | | G5 - End-to-end solver | reopened | Linear static path must be revalidated through steps 13 and 14 after the MITC4 rebuild and `quad_02` compatibility path. | Future integration/reference regression output | @@ -87,7 +87,7 @@ All milestones are intended to become one or more self-contained sprint contract | P1R-03 | completed | parser generator | Revalidate Phase 1 parser and immutable Domain subset. | none | Supported keywords accepted; unsupported features rejected | | P1R-04 | completed | validation generator | Rebuild validation and singular diagnostic coverage. | P1R-03 | Missing-reference and singular-prone negative tests | | P1R-05 | completed | DOF generator | Rebuild six-DOF DofManager, constrained/free mapping, equation numbering, and full-vector reconstruction. | none | DOF mapping and reaction foundation tests | -| P1R-06 | pending | results generator | Rebuild minimum results model and displacement CSV comparator. | none | U/RF schema tests and CSV comparator tests | +| P1R-06 | completed | results generator | Rebuild minimum results model and displacement CSV comparator. | none | U/RF schema tests and CSV comparator tests | | P1R-07 | pending | MITC4 generator | Implement MITC4 geometry, node order, tying points, directors, and local bases. | none | Shape/basis/diagnostic tests | | P1R-08 | pending | MITC4 generator | Implement degenerated-continuum displacement, covariant strain rows, and MITC shear tying. | P1R-07 | Finite-difference and tying interpolation tests | | P1R-09 | pending | MITC4 generator | Implement material matrix, transform, and `2 x 2 x 2` integration scaffolding. | P1R-08 | Material/integration tests | diff --git a/PROGRESS.md b/PROGRESS.md index 72c0094..326afc7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,10 +13,37 @@ Every new agent session must read this file together with `PLAN.md` before plann - Do not remove history unless the user explicitly asks for archival cleanup. ## Current Status -Phase 1 has a new rebaseline phase definition in `phases/1-linear-static-mitc4-rebaseline`. Steps 0 through 5 are complete. `quad_02_phase1.inp` is now the normalized Phase 1-compatible input path for the stored `quad_02` S4 reference pair, while the original `quad_02.inp` remains preserved unsupported provenance. Core numeric aliases, DOF mapping, validation harness, model diagnostic context, the Phase 1 parser/domain subset, validation/singular diagnostics, and DofManager/reaction foundation have been revalidated. The old `phases/1-linear-static-mitc4` path is historical and superseded after the MITC4 formulation reset. +Phase 1 has a new rebaseline phase definition in `phases/1-linear-static-mitc4-rebaseline`. Steps 0 through 6 are complete. `quad_02_phase1.inp` is now the normalized Phase 1-compatible input path for the stored `quad_02` S4 reference pair, while the original `quad_02.inp` remains preserved unsupported provenance. Core numeric aliases, DOF mapping, validation harness, model diagnostic context, the Phase 1 parser/domain subset, validation/singular diagnostics, DofManager/reaction foundation, minimum result model metadata, and displacement CSV comparator foundation have been revalidated. The old `phases/1-linear-static-mitc4` path is historical and superseded after the MITC4 formulation reset. ## Completed Work +### 2026-05-04 - P1R-06 results model and displacement CSV comparator completed +Author: Codex + +Changed files: +- `include/fesa/fesa.hpp` +- `tests/test_main.cpp` +- `docs/RESULTS_SCHEMA.md` +- `docs/VERIFICATION_PLAN.md` +- `phases/1-linear-static-mitc4-rebaseline/index.json` +- `PLAN.md` +- `PROGRESS.md` + +Summary: +- Added result model tests for Phase 1 root metadata, frame `0` metadata, and mandatory `U`/`RF` field labels, positions, entity type, and global basis. +- Extended the in-memory result model with solver/schema metadata, linear-static frame metadata, nodal field metadata, and mandatory `U`/`RF` descriptions while keeping `S`, `E`, and `SF` optional. +- Added string/stream-based Abaqus displacement CSV loading so tests can cover required headers, duplicate node labels, missing/non-numeric node labels, and nonnumeric component values without temporary files. +- Strengthened displacement comparison so it matches by node id, verifies exact FESA `U` component labels and nodal/global metadata, rejects duplicate actual node ids, reports missing FESA nodes, and uses combined absolute/relative tolerances. + +Verification: +- First ran `python scripts/validate_workspace.py` after adding Step 6 tests; it failed as expected because the result metadata fields and string CSV loader did not exist yet. +- After implementation, `python scripts/validate_workspace.py` configured CMake, built `fesa_core` and `fesa_tests`, and ran CTest successfully. +- CTest result: 1 test executable passed. + +Follow-up: +- Continue with P1R-07 MITC4 geometry, node order, tying points, directors, and local bases. +- Keep RF reference CSV availability open; current RF verification remains internal full-vector equilibrium until a stored `*_reactions.csv` artifact is provided. + ### 2026-05-04 - P1R-05 DofManager and reaction foundation completed Author: Codex diff --git a/docs/RESULTS_SCHEMA.md b/docs/RESULTS_SCHEMA.md index d6519ea..ce163ef 100644 --- a/docs/RESULTS_SCHEMA.md +++ b/docs/RESULTS_SCHEMA.md @@ -64,6 +64,8 @@ Attach these attributes to `/`: | `unit_system_note` | string | no | User-provided self-consistent unit note | | `dof_convention` | string | yes | `UX,UY,UZ,RX,RY,RZ` | | `sign_convention` | string | yes | `Abaqus-compatible` | +| `precision` | string | yes for Phase 1 in-memory result model | `double` | +| `index_type` | string | yes for Phase 1 in-memory result model | `int64` | ## `/metadata` Recommended datasets or attributes: @@ -189,7 +191,7 @@ The final component labels should be cross-checked with the accepted Abaqus refe ## Phase 1 Mandatory Outputs The first complete linear static solver path must write: -- root metadata attributes: `schema_name`, `schema_version`, `solver_name`, `dof_convention`, `sign_convention`. +- root metadata attributes: `schema_name`, `schema_version`, `solver_name`, `dof_convention`, `sign_convention`, `precision`, and `index_type`. - `/model/nodes/ids` - `/model/nodes/coordinates` - `/model/elements/ids` @@ -293,6 +295,8 @@ Required CSV columns: Rules: - CSV files are reference inputs for tests, not the primary FESA result storage format. - The comparator must preserve node-label matching and must not rely on row order alone. +- The comparator must require FESA `U` component labels `UX`, `UY`, `UZ`, `RX`, `RY`, `RZ` with `position = NODAL`, `entity_type = node`, and `basis = GLOBAL`. +- Duplicate FESA output node ids, duplicate CSV node labels, missing FESA nodes, missing CSV columns, and nonnumeric CSV values are comparison failures. - The comparison report may be stored under `/referenceComparison`. - Reaction CSV, stress CSV, or section force CSV formats must be documented before automated use. diff --git a/docs/VERIFICATION_PLAN.md b/docs/VERIFICATION_PLAN.md index b373ea2..5cb1805 100644 --- a/docs/VERIFICATION_PLAN.md +++ b/docs/VERIFICATION_PLAN.md @@ -133,7 +133,8 @@ Rules: - `Node Label` is parsed as int64. - All displacement and rotation values are parsed as `double`. - The comparator must match rows by node id, not by row order alone. -- Missing nodes in FESA output, duplicate CSV node labels, missing columns, or nonnumeric values are reference artifact errors. +- Missing or nonnumeric `Node Label` values, duplicate CSV node labels, missing columns, or nonnumeric component values are reference artifact errors. +- Missing nodes in FESA output, duplicate FESA output node ids, wrong FESA `U` component labels, or wrong nodal/global field metadata are comparison errors. - CSV displacement comparison maps to `/results/steps//frames//fieldOutputs/U`. - If the `.inp` has multiple steps or frames, the case metadata must state which step/frame the CSV represents. diff --git a/include/fesa/fesa.hpp b/include/fesa/fesa.hpp index 3357c93..849fbbb 100644 --- a/include/fesa/fesa.hpp +++ b/include/fesa/fesa.hpp @@ -1463,6 +1463,10 @@ inline AssemblyResult assembleSystem(const Domain& domain, const DofManager& dof struct FieldOutput { std::string name; + std::string position = "NODAL"; + std::string entity_type = "node"; + std::string basis = "GLOBAL"; + std::string description; std::vector entity_ids; std::vector component_labels; std::vector> values; @@ -1470,8 +1474,12 @@ struct FieldOutput { struct ResultFrame { LocalIndex frame_id = 0; + LocalIndex increment = 1; + LocalIndex iteration = 0; Real step_time = 1.0; Real total_time = 1.0; + bool converged = true; + std::string description = "Phase 1 linear static frame"; std::map field_outputs; }; @@ -1483,6 +1491,11 @@ struct ResultStep { struct ResultFile { std::string schema_name = "FESA_RESULTS"; LocalIndex schema_version = 1; + std::string solver_name = "FESA"; + std::string dof_convention = "UX,UY,UZ,RX,RY,RZ"; + std::string sign_convention = "Abaqus-compatible"; + std::string precision = "double"; + std::string index_type = "int64"; std::vector node_ids; std::vector coordinates; std::vector element_ids; @@ -1506,8 +1519,8 @@ class InMemoryResultsWriter { step.name = domain.steps.empty() ? "Step-1" : domain.steps.front().name; ResultFrame frame; frame.frame_id = 0; - frame.field_outputs["U"] = buildNodalField("U", displacementComponentLabels(), domain, dofs, u_full); - frame.field_outputs["RF"] = buildNodalField("RF", reactionComponentLabels(), domain, dofs, rf_full); + frame.field_outputs["U"] = buildNodalField("U", displacementComponentLabels(), "Nodal displacement and rotation", domain, dofs, u_full); + frame.field_outputs["RF"] = buildNodalField("RF", reactionComponentLabels(), "Nodal reaction force and moment", domain, dofs, rf_full); step.frames.push_back(frame); result_.steps.push_back(step); } @@ -1517,10 +1530,14 @@ class InMemoryResultsWriter { } private: - static FieldOutput buildNodalField(const std::string& name, const std::vector& labels, const Domain& domain, - const DofManager& dofs, const std::vector& full_values) { + static FieldOutput buildNodalField(const std::string& name, const std::vector& labels, const std::string& description, + const Domain& domain, const DofManager& dofs, const std::vector& full_values) { FieldOutput field; field.name = name; + field.position = "NODAL"; + field.entity_type = "node"; + field.basis = "GLOBAL"; + field.description = description; field.component_labels = labels; for (const auto& [node_id, node] : domain.nodes) { (void)node; @@ -1625,19 +1642,18 @@ struct CsvDisplacementTable { std::vector diagnostics; }; -inline CsvDisplacementTable loadDisplacementCsv(const std::string& path) { +inline std::vector displacementCsvRequiredColumns() { + return {"Node Label", "U-U1", "U-U2", "U-U3", "UR-UR1", "UR-UR2", "UR-UR3"}; +} + +inline CsvDisplacementTable loadDisplacementCsvFromStream(std::istream& input, const std::string& source_name) { CsvDisplacementTable table; - std::ifstream input(path); - if (!input.good()) { - table.diagnostics.push_back({Severity::Error, "FESA-CSV-READ", "Could not read displacement CSV", {path, 0, ""}}); - return table; - } std::string line; if (!std::getline(input, line)) { - table.diagnostics.push_back({Severity::Error, "FESA-CSV-EMPTY", "Displacement CSV is empty", {path, 1, ""}}); + table.diagnostics.push_back({Severity::Error, "FESA-CSV-EMPTY", "Displacement CSV is empty", {source_name, 1, ""}}); return table; } - const std::vector required = {"Node Label", "U-U1", "U-U2", "U-U3", "UR-UR1", "UR-UR2", "UR-UR3"}; + const std::vector required = displacementCsvRequiredColumns(); std::vector headers = splitCsv(line); std::map column; for (std::size_t i = 0; i < headers.size(); ++i) { @@ -1645,7 +1661,7 @@ inline CsvDisplacementTable loadDisplacementCsv(const std::string& path) { } for (const std::string& name : required) { if (column.count(name) == 0) { - table.diagnostics.push_back({Severity::Error, "FESA-CSV-MISSING-COLUMN", "Missing CSV column: " + name, {path, 1, ""}}); + table.diagnostics.push_back({Severity::Error, "FESA-CSV-MISSING-COLUMN", "Missing CSV column: " + name, {source_name, 1, ""}}); } } if (hasError(table.diagnostics)) { @@ -1664,11 +1680,11 @@ inline CsvDisplacementTable loadDisplacementCsv(const std::string& path) { }; auto node_id = parseInt64(get("Node Label")); if (!node_id) { - table.diagnostics.push_back({Severity::Error, "FESA-CSV-NODE", "Invalid node label", {path, line_number, ""}}); + table.diagnostics.push_back({Severity::Error, "FESA-CSV-NODE", "Invalid node label", {source_name, line_number, ""}}); continue; } if (table.rows.count(*node_id) != 0) { - table.diagnostics.push_back({Severity::Error, "FESA-CSV-DUPLICATE-NODE", "Duplicate node label", {path, line_number, ""}}); + table.diagnostics.push_back({Severity::Error, "FESA-CSV-DUPLICATE-NODE", "Duplicate node label", {source_name, line_number, ""}}); continue; } CsvDisplacementRow row; @@ -1676,7 +1692,7 @@ inline CsvDisplacementTable loadDisplacementCsv(const std::string& path) { for (std::size_t i = 0; i < 6; ++i) { auto value = parseReal(get(required[i + 1])); if (!value) { - table.diagnostics.push_back({Severity::Error, "FESA-CSV-NUMERIC", "Invalid displacement value", {path, line_number, ""}}); + table.diagnostics.push_back({Severity::Error, "FESA-CSV-NUMERIC", "Invalid displacement value", {source_name, line_number, ""}}); value = 0.0; } row.values[i] = *value; @@ -1686,6 +1702,21 @@ inline CsvDisplacementTable loadDisplacementCsv(const std::string& path) { return table; } +inline CsvDisplacementTable loadDisplacementCsvFromString(const std::string& text, const std::string& source_name = "") { + std::istringstream input(text); + return loadDisplacementCsvFromStream(input, source_name); +} + +inline CsvDisplacementTable loadDisplacementCsv(const std::string& path) { + std::ifstream input(path); + if (!input.good()) { + CsvDisplacementTable table; + table.diagnostics.push_back({Severity::Error, "FESA-CSV-READ", "Could not read displacement CSV", {path, 0, ""}}); + return table; + } + return loadDisplacementCsvFromStream(input, path); +} + struct ComparisonOptions { Real abs_tol = 1.0e-12; Real rel_tol = 1.0e-5; @@ -1705,25 +1736,46 @@ inline ComparisonResult compareDisplacements(const FieldOutput& actual, const Cs if (hasError(result.diagnostics)) { return result; } + if (actual.name != "U") { + result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-FIELD-NAME", "Expected FESA displacement field named U", {}}); + } + if (actual.component_labels != displacementComponentLabels()) { + result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-COMPONENT-LABELS", "FESA U field component labels must be UX,UY,UZ,RX,RY,RZ", {}}); + } + if (actual.position != "NODAL" || actual.entity_type != "node" || actual.basis != "GLOBAL") { + result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-FIELD-METADATA", "FESA U field must be nodal values in the global basis", {}}); + } + if (actual.entity_ids.size() != actual.values.size()) { + result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-FIELD-SIZE", "FESA U field entity/value counts differ", {}}); + } std::map> actual_by_node; - for (std::size_t i = 0; i < actual.entity_ids.size(); ++i) { + const std::size_t actual_count = std::min(actual.entity_ids.size(), actual.values.size()); + for (std::size_t i = 0; i < actual_count; ++i) { + if (actual_by_node.count(actual.entity_ids[i]) != 0) { + result.diagnostics.push_back( + {Severity::Error, "FESA-COMPARE-DUPLICATE-ACTUAL", "FESA U field contains duplicate node " + std::to_string(actual.entity_ids[i]), {}}); + continue; + } actual_by_node[actual.entity_ids[i]] = actual.values[i]; } for (const auto& [node_id, row] : expected.rows) { auto actual_it = actual_by_node.find(node_id); if (actual_it == actual_by_node.end()) { - result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-MISSING-NODE", "FESA output is missing node " + std::to_string(node_id), {}}); + result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-MISSING-ACTUAL", "FESA U field is missing node " + std::to_string(node_id), {}}); continue; } for (std::size_t component = 0; component < 6; ++component) { const Real expected_value = row.values[component]; const Real actual_value = actual_it->second[component]; const Real abs_error = std::fabs(actual_value - expected_value); - const Real rel_error = abs_error / std::max(std::fabs(expected_value), options.reference_scale); + const Real scale = std::max(std::fabs(expected_value), std::fabs(options.reference_scale)); + const Real rel_error = scale > 0.0 ? abs_error / scale : (abs_error == 0.0 ? 0.0 : std::numeric_limits::infinity()); result.max_abs_error = std::max(result.max_abs_error, abs_error); result.max_rel_error = std::max(result.max_rel_error, rel_error); if (!(abs_error <= options.abs_tol || rel_error <= options.rel_tol)) { - result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-TOLERANCE", "Displacement comparison failed at node " + std::to_string(node_id), {}}); + const std::string component_label = displacementComponentLabels()[component]; + result.diagnostics.push_back({Severity::Error, "FESA-COMPARE-TOLERANCE", + "Displacement comparison failed at node " + std::to_string(node_id) + " component " + component_label, {}}); } } } diff --git a/phases/1-linear-static-mitc4-rebaseline/index.json b/phases/1-linear-static-mitc4-rebaseline/index.json index ff7ed83..4c64c6e 100644 --- a/phases/1-linear-static-mitc4-rebaseline/index.json +++ b/phases/1-linear-static-mitc4-rebaseline/index.json @@ -8,7 +8,7 @@ { "step": 3, "name": "parser-domain-subset", "status": "completed" }, { "step": 4, "name": "validation-singular-diagnostics", "status": "completed" }, { "step": 5, "name": "dof-manager-reaction-foundation", "status": "completed" }, - { "step": 6, "name": "results-comparator-foundation", "status": "pending" }, + { "step": 6, "name": "results-comparator-foundation", "status": "completed" }, { "step": 7, "name": "mitc4-geometry-directors", "status": "pending" }, { "step": 8, "name": "mitc4-covariant-strain-tying", "status": "pending" }, { "step": 9, "name": "mitc4-material-integration", "status": "pending" }, diff --git a/tests/test_main.cpp b/tests/test_main.cpp index dc32a24..857967e 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -652,11 +652,40 @@ FESA_TEST(results_writer_uses_step_frame_fields_for_u_and_rf) { writer.writeLinearStatic(domain, dofs, u, rf); const auto& result = writer.result(); FESA_CHECK(result.schema_name == "FESA_RESULTS"); + FESA_CHECK(result.schema_version == 1); + FESA_CHECK(result.solver_name == "FESA"); + FESA_CHECK(result.dof_convention == "UX,UY,UZ,RX,RY,RZ"); + FESA_CHECK(result.sign_convention == "Abaqus-compatible"); + FESA_CHECK(result.precision == "double"); + FESA_CHECK(result.index_type == "int64"); FESA_CHECK(result.steps.size() == 1); - FESA_CHECK(result.steps[0].frames[0].field_outputs.count("U") == 1); - FESA_CHECK(result.steps[0].frames[0].field_outputs.count("RF") == 1); - FESA_CHECK(result.steps[0].frames[0].field_outputs.at("U").component_labels[2] == "UZ"); - FESA_CHECK(result.steps[0].frames[0].field_outputs.at("RF").component_labels[2] == "RFZ"); + FESA_CHECK(result.steps[0].name == "Step-1"); + FESA_CHECK(result.steps[0].frames.size() == 1); + const auto& frame = result.steps[0].frames[0]; + FESA_CHECK(frame.frame_id == 0); + FESA_CHECK(frame.increment == 1); + FESA_CHECK(frame.iteration == 0); + FESA_CHECK(frame.converged); + FESA_CHECK_NEAR(frame.step_time, 1.0, 1.0e-15); + FESA_CHECK_NEAR(frame.total_time, 1.0, 1.0e-15); + FESA_CHECK(frame.field_outputs.count("U") == 1); + FESA_CHECK(frame.field_outputs.count("RF") == 1); + const auto& u_field = frame.field_outputs.at("U"); + const auto& rf_field = frame.field_outputs.at("RF"); + FESA_CHECK(u_field.name == "U"); + FESA_CHECK(u_field.position == "NODAL"); + FESA_CHECK(u_field.entity_type == "node"); + FESA_CHECK(u_field.basis == "GLOBAL"); + FESA_CHECK(u_field.component_labels == fesa::displacementComponentLabels()); + FESA_CHECK(rf_field.name == "RF"); + FESA_CHECK(rf_field.position == "NODAL"); + FESA_CHECK(rf_field.entity_type == "node"); + FESA_CHECK(rf_field.basis == "GLOBAL"); + FESA_CHECK(rf_field.component_labels == fesa::reactionComponentLabels()); + FESA_CHECK(u_field.component_labels[2] == "UZ"); + FESA_CHECK(rf_field.component_labels[2] == "RFZ"); + FESA_CHECK(u_field.entity_ids.size() == domain.nodes.size()); + FESA_CHECK(rf_field.entity_ids.size() == domain.nodes.size()); } FESA_TEST(displacement_csv_loader_accepts_quad01_format) { @@ -674,9 +703,37 @@ FESA_TEST(displacement_csv_loader_accepts_quad02_format) { FESA_CHECK(table.rows.at(2).values[2] < 0.0); } +FESA_TEST(displacement_csv_loader_reports_required_header_errors) { + auto table = fesa::loadDisplacementCsvFromString("Node Label,U-U1,U-U2,U-U3,UR-UR1,UR-UR2\n" + "1,0,0,0,0,0\n", + "missing-header.csv"); + FESA_CHECK(fesa::containsDiagnostic(table.diagnostics, "FESA-CSV-MISSING-COLUMN")); +} + +FESA_TEST(displacement_csv_loader_reports_duplicate_node_rows) { + auto table = fesa::loadDisplacementCsvFromString("Node Label,U-U1,U-U2,U-U3,UR-UR1,UR-UR2,UR-UR3\n" + "1,0,0,0,0,0,0\n" + "1,0,0,0,0,0,0\n", + "duplicate-node.csv"); + FESA_CHECK(fesa::containsDiagnostic(table.diagnostics, "FESA-CSV-DUPLICATE-NODE")); +} + +FESA_TEST(displacement_csv_loader_reports_missing_and_non_numeric_node_rows) { + auto table = fesa::loadDisplacementCsvFromString("Node Label,U-U1,U-U2,U-U3,UR-UR1,UR-UR2,UR-UR3\n" + ",0,0,0,0,0,0\n" + "two,0,0,0,0,0,0\n" + "3,0,not-a-number,0,0,0,0\n", + "invalid-node-row.csv"); + FESA_CHECK(diagnosticCount(table.diagnostics, "FESA-CSV-NODE") == 2); + FESA_CHECK(fesa::containsDiagnostic(table.diagnostics, "FESA-CSV-NUMERIC")); +} + FESA_TEST(displacement_comparator_matches_by_node_id_not_row_order) { fesa::FieldOutput actual; actual.name = "U"; + actual.position = "NODAL"; + actual.entity_type = "node"; + actual.basis = "GLOBAL"; actual.entity_ids = {2, 1}; actual.component_labels = fesa::displacementComponentLabels(); actual.values = {{{2, 0, 0, 0, 0, 0}}, {{1, 0, 0, 0, 0, 0}}}; @@ -687,6 +744,62 @@ FESA_TEST(displacement_comparator_matches_by_node_id_not_row_order) { FESA_CHECK(compared.pass); } +FESA_TEST(displacement_comparator_uses_absolute_and_relative_tolerances) { + fesa::FieldOutput actual; + actual.name = "U"; + actual.position = "NODAL"; + actual.entity_type = "node"; + actual.basis = "GLOBAL"; + actual.entity_ids = {10}; + actual.component_labels = fesa::displacementComponentLabels(); + actual.values = {{{5.0e-7, 100.0005, 0, 0, 0, 0}}}; + fesa::CsvDisplacementTable expected; + expected.rows[10] = {10, {0.0, 100.0, 0, 0, 0, 0}}; + + auto loose = fesa::compareDisplacements(actual, expected, {1.0e-6, 1.0e-5, 1.0}); + FESA_CHECK(loose.pass); + FESA_CHECK_NEAR(loose.max_abs_error, 5.0e-4, 1.0e-12); + + auto strict = fesa::compareDisplacements(actual, expected, {1.0e-8, 1.0e-8, 1.0}); + FESA_CHECK(!strict.pass); + FESA_CHECK(fesa::containsDiagnostic(strict.diagnostics, "FESA-COMPARE-TOLERANCE")); +} + +FESA_TEST(displacement_comparator_rejects_wrong_component_labels_and_missing_nodes) { + fesa::FieldOutput actual; + actual.name = "U"; + actual.position = "NODAL"; + actual.entity_type = "node"; + actual.basis = "GLOBAL"; + actual.entity_ids = {1}; + actual.component_labels = fesa::reactionComponentLabels(); + actual.values = {{{0, 0, 0, 0, 0, 0}}}; + fesa::CsvDisplacementTable expected; + expected.rows[2] = {2, {0, 0, 0, 0, 0, 0}}; + + auto compared = fesa::compareDisplacements(actual, expected, {1.0e-12, 1.0e-12, 1.0}); + FESA_CHECK(!compared.pass); + FESA_CHECK(fesa::containsDiagnostic(compared.diagnostics, "FESA-COMPARE-COMPONENT-LABELS")); + FESA_CHECK(fesa::containsDiagnostic(compared.diagnostics, "FESA-COMPARE-MISSING-ACTUAL")); +} + +FESA_TEST(displacement_comparator_reports_duplicate_actual_nodes) { + fesa::FieldOutput actual; + actual.name = "U"; + actual.position = "NODAL"; + actual.entity_type = "node"; + actual.basis = "GLOBAL"; + actual.entity_ids = {1, 1}; + actual.component_labels = fesa::displacementComponentLabels(); + actual.values = {{{0, 0, 0, 0, 0, 0}}, {{0, 0, 0, 0, 0, 0}}}; + fesa::CsvDisplacementTable expected; + expected.rows[1] = {1, {0, 0, 0, 0, 0, 0}}; + + auto compared = fesa::compareDisplacements(actual, expected, {1.0e-12, 1.0e-12, 1.0}); + FESA_CHECK(!compared.pass); + FESA_CHECK(fesa::containsDiagnostic(compared.diagnostics, "FESA-COMPARE-DUPLICATE-ACTUAL")); +} + FESA_TEST(mitc4_shape_functions_and_stiffness_baseline) { auto shape = fesa::shapeFunctions(0.25, -0.5); const fesa::Real sum = shape.n[0] + shape.n[1] + shape.n[2] + shape.n[3];