diff --git a/src/application/ResultArtifactsCodec.cpp b/src/application/ResultArtifactsCodec.cpp index cff00ff..c5958f3 100644 --- a/src/application/ResultArtifactsCodec.cpp +++ b/src/application/ResultArtifactsCodec.cpp @@ -1,6 +1,7 @@ #include "application/ResultArtifactsCodec.h" #include +#include #include #include @@ -9,6 +10,19 @@ #include "application/ProjectPersistenceJson.h" namespace safecrowd::application { +namespace { + +std::uint64_t unsignedIntegerFromJson(const QJsonValue& value) { + if (value.isString()) { + return value.toString().toULongLong(); + } + + const auto integer = value.toInteger(); + return integer < 0 ? 0 : static_cast(integer); +} + +} // namespace + QJsonObject simulationAgentFrameToJson(const safecrowd::domain::SimulationAgentFrame& agent) { QJsonObject object; object["id"] = QString::number(static_cast(agent.id)); @@ -22,7 +36,7 @@ QJsonObject simulationAgentFrameToJson(const safecrowd::domain::SimulationAgentF safecrowd::domain::SimulationAgentFrame simulationAgentFrameFromJson(const QJsonObject& object) { return { - .id = object.value("id").toString().toULongLong(), + .id = unsignedIntegerFromJson(object.value("id")), .position = pointFromJson(object.value("position")), .velocity = pointFromJson(object.value("velocity")), .radius = object.value("radius").toDouble(0.25), @@ -119,15 +133,126 @@ safecrowd::domain::ScenarioBottleneckMetric bottleneckFromJson(const QJsonObject return bottleneck; } +QJsonObject pressureHotspotToJson(const safecrowd::domain::ScenarioPressureHotspot& hotspot) { + QJsonObject object; + object["center"] = pointArray(hotspot.center); + object["cellMin"] = pointArray(hotspot.cellMin); + object["cellMax"] = pointArray(hotspot.cellMax); + object["floorId"] = QString::fromStdString(hotspot.floorId); + object["agentCount"] = static_cast(hotspot.agentCount); + object["intrudingPairCount"] = static_cast(hotspot.intrudingPairCount); + object["densityPeoplePerSquareMeter"] = hotspot.densityPeoplePerSquareMeter; + object["pressureScore"] = hotspot.pressureScore; + object["detectedAtSeconds"] = optionalDoubleToJson(hotspot.detectedAtSeconds); + if (hotspot.detectionFrame.has_value()) { + object["detectionFrame"] = simulationFrameToJson(*hotspot.detectionFrame); + } + return object; +} + +safecrowd::domain::ScenarioPressureHotspot pressureHotspotFromJson(const QJsonObject& object) { + safecrowd::domain::ScenarioPressureHotspot hotspot{ + .center = pointFromJson(object.value("center")), + .cellMin = pointFromJson(object.value("cellMin")), + .cellMax = pointFromJson(object.value("cellMax")), + .floorId = object.value("floorId").toString().toStdString(), + .agentCount = static_cast(object.value("agentCount").toInteger()), + .intrudingPairCount = static_cast(object.value("intrudingPairCount").toInteger()), + .densityPeoplePerSquareMeter = object.value("densityPeoplePerSquareMeter").toDouble(), + .pressureScore = object.value("pressureScore").toDouble(), + }; + hotspot.detectedAtSeconds = optionalDoubleFromJson(object.value("detectedAtSeconds")); + if (object.value("detectionFrame").isObject()) { + hotspot.detectionFrame = simulationFrameFromJson(object.value("detectionFrame").toObject()); + } + return hotspot; +} + +QJsonObject pressureAgentMetricToJson(const safecrowd::domain::ScenarioPressureAgentMetric& agent) { + QJsonObject object; + object["agentId"] = QString::number(static_cast(agent.agentId)); + object["position"] = pointArray(agent.position); + object["floorId"] = QString::fromStdString(agent.floorId); + object["compressionForce"] = agent.compressionForce; + object["exposureSeconds"] = agent.exposureSeconds; + object["critical"] = agent.critical; + return object; +} + +safecrowd::domain::ScenarioPressureAgentMetric pressureAgentMetricFromJson(const QJsonObject& object) { + safecrowd::domain::ScenarioPressureAgentMetric agent; + agent.agentId = unsignedIntegerFromJson(object.value("agentId")); + agent.position = pointFromJson(object.value("position")); + agent.floorId = object.value("floorId").toString().toStdString(); + agent.compressionForce = object.value("compressionForce").toDouble(); + agent.exposureSeconds = object.value("exposureSeconds").toDouble(); + agent.critical = object.value("critical").toBool(false); + return agent; +} + +QJsonObject criticalPressureEventToJson(const safecrowd::domain::ScenarioCriticalPressureEvent& event) { + QJsonObject object; + object["center"] = pointArray(event.center); + object["cellMin"] = pointArray(event.cellMin); + object["cellMax"] = pointArray(event.cellMax); + object["floorId"] = QString::fromStdString(event.floorId); + object["exposedAgentCount"] = static_cast(event.exposedAgentCount); + object["criticalAgentCount"] = static_cast(event.criticalAgentCount); + object["pressureScore"] = event.pressureScore; + object["startedAtSeconds"] = event.startedAtSeconds; + object["durationSeconds"] = event.durationSeconds; + object["detectedAtSeconds"] = optionalDoubleToJson(event.detectedAtSeconds); + if (event.detectionFrame.has_value()) { + object["detectionFrame"] = simulationFrameToJson(*event.detectionFrame); + } + return object; +} + +safecrowd::domain::ScenarioCriticalPressureEvent criticalPressureEventFromJson(const QJsonObject& object) { + safecrowd::domain::ScenarioCriticalPressureEvent event{ + .center = pointFromJson(object.value("center")), + .cellMin = pointFromJson(object.value("cellMin")), + .cellMax = pointFromJson(object.value("cellMax")), + .floorId = object.value("floorId").toString().toStdString(), + .exposedAgentCount = static_cast(object.value("exposedAgentCount").toInteger()), + .criticalAgentCount = static_cast(object.value("criticalAgentCount").toInteger()), + .pressureScore = object.value("pressureScore").toDouble(), + .startedAtSeconds = object.value("startedAtSeconds").toDouble(), + .durationSeconds = object.value("durationSeconds").toDouble(), + }; + event.detectedAtSeconds = optionalDoubleFromJson(object.value("detectedAtSeconds")); + if (object.value("detectionFrame").isObject()) { + event.detectionFrame = simulationFrameFromJson(object.value("detectionFrame").toObject()); + } + return event; +} + QJsonObject riskSnapshotToJson(const safecrowd::domain::ScenarioRiskSnapshot& risk) { QJsonObject object; object["completionRisk"] = static_cast(risk.completionRisk); object["stalledAgentCount"] = static_cast(risk.stalledAgentCount); + object["pressureExposedAgentCount"] = static_cast(risk.pressureExposedAgentCount); + object["criticalPressureAgentCount"] = static_cast(risk.criticalPressureAgentCount); QJsonArray hotspots; for (const auto& hotspot : risk.hotspots) { hotspots.append(hotspotToJson(hotspot)); } object["hotspots"] = hotspots; + QJsonArray pressureHotspots; + for (const auto& hotspot : risk.pressureHotspots) { + pressureHotspots.append(pressureHotspotToJson(hotspot)); + } + object["pressureHotspots"] = pressureHotspots; + QJsonArray pressureAgents; + for (const auto& agent : risk.pressureAgents) { + pressureAgents.append(pressureAgentMetricToJson(agent)); + } + object["pressureAgents"] = pressureAgents; + QJsonArray criticalPressureEvents; + for (const auto& event : risk.criticalPressureEvents) { + criticalPressureEvents.append(criticalPressureEventToJson(event)); + } + object["criticalPressureEvents"] = criticalPressureEvents; QJsonArray bottlenecks; for (const auto& bottleneck : risk.bottlenecks) { bottlenecks.append(bottleneckToJson(bottleneck)); @@ -140,9 +265,22 @@ safecrowd::domain::ScenarioRiskSnapshot riskSnapshotFromJson(const QJsonObject& safecrowd::domain::ScenarioRiskSnapshot risk; risk.completionRisk = static_cast(object.value("completionRisk").toInt()); risk.stalledAgentCount = static_cast(object.value("stalledAgentCount").toInteger()); + risk.pressureExposedAgentCount = + static_cast(object.value("pressureExposedAgentCount").toInteger()); + risk.criticalPressureAgentCount = + static_cast(object.value("criticalPressureAgentCount").toInteger()); for (const auto& value : object.value("hotspots").toArray()) { risk.hotspots.push_back(hotspotFromJson(value.toObject())); } + for (const auto& value : object.value("pressureHotspots").toArray()) { + risk.pressureHotspots.push_back(pressureHotspotFromJson(value.toObject())); + } + for (const auto& value : object.value("pressureAgents").toArray()) { + risk.pressureAgents.push_back(pressureAgentMetricFromJson(value.toObject())); + } + for (const auto& value : object.value("criticalPressureEvents").toArray()) { + risk.criticalPressureEvents.push_back(criticalPressureEventFromJson(value.toObject())); + } for (const auto& value : object.value("bottlenecks").toArray()) { risk.bottlenecks.push_back(bottleneckFromJson(value.toObject())); } @@ -234,6 +372,129 @@ safecrowd::domain::DensitySummary densitySummaryFromJson(const QJsonObject& obje return summary; } +QJsonObject pressureCellMetricToJson(const safecrowd::domain::PressureCellMetric& cell) { + QJsonObject object; + object["center"] = pointArray(cell.center); + object["cellMin"] = pointArray(cell.cellMin); + object["cellMax"] = pointArray(cell.cellMax); + object["floorId"] = QString::fromStdString(cell.floorId); + object["agentCount"] = static_cast(cell.agentCount); + object["intrudingPairCount"] = static_cast(cell.intrudingPairCount); + object["densityPeoplePerSquareMeter"] = cell.densityPeoplePerSquareMeter; + object["pressureScore"] = cell.pressureScore; + return object; +} + +safecrowd::domain::PressureCellMetric pressureCellMetricFromJson(const QJsonObject& object) { + return { + .center = pointFromJson(object.value("center")), + .cellMin = pointFromJson(object.value("cellMin")), + .cellMax = pointFromJson(object.value("cellMax")), + .floorId = object.value("floorId").toString().toStdString(), + .agentCount = static_cast(object.value("agentCount").toInteger()), + .intrudingPairCount = static_cast(object.value("intrudingPairCount").toInteger()), + .densityPeoplePerSquareMeter = object.value("densityPeoplePerSquareMeter").toDouble(), + .pressureScore = object.value("pressureScore").toDouble(), + }; +} + +QJsonObject pressureFieldSnapshotToJson(const safecrowd::domain::PressureFieldSnapshot& snapshot) { + QJsonObject object; + object["timeSeconds"] = snapshot.timeSeconds; + object["cellSizeMeters"] = snapshot.cellSizeMeters; + QJsonArray cells; + for (const auto& cell : snapshot.cells) { + cells.append(pressureCellMetricToJson(cell)); + } + object["cells"] = cells; + return object; +} + +safecrowd::domain::PressureFieldSnapshot pressureFieldSnapshotFromJson(const QJsonObject& object) { + safecrowd::domain::PressureFieldSnapshot snapshot; + snapshot.timeSeconds = object.value("timeSeconds").toDouble(); + snapshot.cellSizeMeters = object.value("cellSizeMeters").toDouble(); + for (const auto& value : object.value("cells").toArray()) { + snapshot.cells.push_back(pressureCellMetricFromJson(value.toObject())); + } + return snapshot; +} + +QJsonObject pressureSummaryToJson(const safecrowd::domain::PressureSummary& summary) { + QJsonObject object; + object["cellSizeMeters"] = summary.cellSizeMeters; + object["hotspotScoreThreshold"] = summary.hotspotScoreThreshold; + object["criticalCompressionForceThreshold"] = summary.criticalCompressionForceThreshold; + object["criticalExposureThresholdSeconds"] = summary.criticalExposureThresholdSeconds; + object["criticalEventDurationThresholdSeconds"] = summary.criticalEventDurationThresholdSeconds; + object["criticalEventAgentThreshold"] = static_cast(summary.criticalEventAgentThreshold); + object["peakPressureScore"] = summary.peakPressureScore; + object["peakAtSeconds"] = optionalDoubleToJson(summary.peakAtSeconds); + if (summary.peakCell.has_value()) { + object["peakCell"] = pressureCellMetricToJson(*summary.peakCell); + } + object["peakExposedAgentCount"] = static_cast(summary.peakExposedAgentCount); + object["peakCriticalAgentCount"] = static_cast(summary.peakCriticalAgentCount); + QJsonArray peakCells; + for (const auto& cell : summary.peakCells) { + peakCells.append(pressureCellMetricToJson(cell)); + } + object["peakCells"] = peakCells; + object["peakField"] = pressureFieldSnapshotToJson(summary.peakField); + QJsonArray peakHotspots; + for (const auto& hotspot : summary.peakHotspots) { + peakHotspots.append(pressureHotspotToJson(hotspot)); + } + object["peakHotspots"] = peakHotspots; + QJsonArray peakAgents; + for (const auto& agent : summary.peakAgents) { + peakAgents.append(pressureAgentMetricToJson(agent)); + } + object["peakAgents"] = peakAgents; + QJsonArray criticalEvents; + for (const auto& event : summary.criticalEvents) { + criticalEvents.append(criticalPressureEventToJson(event)); + } + object["criticalEvents"] = criticalEvents; + return object; +} + +safecrowd::domain::PressureSummary pressureSummaryFromJson(const QJsonObject& object) { + safecrowd::domain::PressureSummary summary; + summary.cellSizeMeters = object.value("cellSizeMeters").toDouble(); + summary.hotspotScoreThreshold = object.value("hotspotScoreThreshold").toDouble(); + summary.criticalCompressionForceThreshold = object.value("criticalCompressionForceThreshold").toDouble(); + summary.criticalExposureThresholdSeconds = object.value("criticalExposureThresholdSeconds").toDouble(); + summary.criticalEventDurationThresholdSeconds = object.value("criticalEventDurationThresholdSeconds").toDouble(); + summary.criticalEventAgentThreshold = + static_cast(object.value("criticalEventAgentThreshold").toInteger()); + summary.peakPressureScore = object.value("peakPressureScore").toDouble(); + summary.peakAtSeconds = optionalDoubleFromJson(object.value("peakAtSeconds")); + if (object.value("peakCell").isObject()) { + summary.peakCell = pressureCellMetricFromJson(object.value("peakCell").toObject()); + } + summary.peakExposedAgentCount = + static_cast(object.value("peakExposedAgentCount").toInteger()); + summary.peakCriticalAgentCount = + static_cast(object.value("peakCriticalAgentCount").toInteger()); + for (const auto& value : object.value("peakCells").toArray()) { + summary.peakCells.push_back(pressureCellMetricFromJson(value.toObject())); + } + if (object.value("peakField").isObject()) { + summary.peakField = pressureFieldSnapshotFromJson(object.value("peakField").toObject()); + } + for (const auto& value : object.value("peakHotspots").toArray()) { + summary.peakHotspots.push_back(pressureHotspotFromJson(value.toObject())); + } + for (const auto& value : object.value("peakAgents").toArray()) { + summary.peakAgents.push_back(pressureAgentMetricFromJson(value.toObject())); + } + for (const auto& value : object.value("criticalEvents").toArray()) { + summary.criticalEvents.push_back(criticalPressureEventFromJson(value.toObject())); + } + return summary; +} + QJsonObject exitUsageMetricToJson(const safecrowd::domain::ExitUsageMetric& exit) { QJsonObject object; object["exitZoneId"] = QString::fromStdString(exit.exitZoneId); @@ -453,6 +714,7 @@ QJsonObject resultArtifactsToJson(const safecrowd::domain::ScenarioResultArtifac object["timingSummary"] = timing; object["densitySummary"] = densitySummaryToJson(artifacts.densitySummary); + object["pressureSummary"] = pressureSummaryToJson(artifacts.pressureSummary); object["hazardExposureSummary"] = hazardExposureSummaryToJson(artifacts.hazardExposureSummary); QJsonArray exitUsage; @@ -508,6 +770,9 @@ safecrowd::domain::ScenarioResultArtifacts resultArtifactsFromJson(const QJsonOb if (object.value("densitySummary").isObject()) { artifacts.densitySummary = densitySummaryFromJson(object.value("densitySummary").toObject()); } + if (object.value("pressureSummary").isObject()) { + artifacts.pressureSummary = pressureSummaryFromJson(object.value("pressureSummary").toObject()); + } if (object.value("hazardExposureSummary").isObject()) { artifacts.hazardExposureSummary = hazardExposureSummaryFromJson(object.value("hazardExposureSummary").toObject()); diff --git a/tests/ProjectPersistenceTests.cpp b/tests/ProjectPersistenceTests.cpp index 2f0e8dc..1e01f89 100644 --- a/tests/ProjectPersistenceTests.cpp +++ b/tests/ProjectPersistenceTests.cpp @@ -99,6 +99,308 @@ SC_TEST(ProjectPersistence_preservesRunningScenarioIndex) { SC_EXPECT_EQ(loaded.runningScenarios.front().execution.repeatCount, std::uint32_t{3}); } +SC_TEST(ProjectPersistence_preservesPressureResultArtifactsAndRiskSnapshot) { + QTemporaryDir projectDir; + SC_EXPECT_TRUE(projectDir.isValid()); + + ScenarioDraft scenario; + scenario.scenarioId = "pressure-result"; + scenario.name = "Pressure Result"; + + SimulationFrame detectionFrame; + detectionFrame.elapsedSeconds = 7.5; + detectionFrame.complete = false; + detectionFrame.totalAgentCount = 8; + detectionFrame.evacuatedAgentCount = 2; + detectionFrame.agents.push_back({ + .id = 42, + .position = {.x = 4.0, .y = 5.0}, + .velocity = {.x = 0.1, .y = 0.2}, + .radius = 0.3, + .floorId = "L1", + .stalled = true, + }); + + PressureCellMetric peakCell{ + .center = {.x = 4.5, .y = 5.5}, + .cellMin = {.x = 4.0, .y = 5.0}, + .cellMax = {.x = 5.0, .y = 6.0}, + .floorId = "L1", + .agentCount = 6, + .intrudingPairCount = 3, + .densityPeoplePerSquareMeter = 3.8, + .pressureScore = 8.7, + }; + + PressureCellMetric fieldCell{ + .center = {.x = 6.5, .y = 7.5}, + .cellMin = {.x = 6.0, .y = 7.0}, + .cellMax = {.x = 7.0, .y = 8.0}, + .floorId = "L2", + .agentCount = 4, + .intrudingPairCount = 2, + .densityPeoplePerSquareMeter = 2.9, + .pressureScore = 5.4, + }; + + ScenarioPressureHotspot hotspot{ + .center = {.x = 4.5, .y = 5.5}, + .cellMin = {.x = 4.0, .y = 5.0}, + .cellMax = {.x = 5.0, .y = 6.0}, + .floorId = "L1", + .agentCount = 6, + .intrudingPairCount = 3, + .densityPeoplePerSquareMeter = 3.8, + .pressureScore = 8.7, + .detectedAtSeconds = 7.5, + .detectionFrame = detectionFrame, + }; + + ScenarioPressureAgentMetric agent{ + .agentId = 42, + .position = {.x = 4.2, .y = 5.2}, + .floorId = "L1", + .compressionForce = 1.2, + .exposureSeconds = 2.5, + .critical = true, + }; + + ScenarioCriticalPressureEvent event{ + .center = {.x = 4.5, .y = 5.5}, + .cellMin = {.x = 4.0, .y = 5.0}, + .cellMax = {.x = 5.0, .y = 6.0}, + .floorId = "L1", + .exposedAgentCount = 5, + .criticalAgentCount = 2, + .pressureScore = 9.1, + .startedAtSeconds = 6.0, + .durationSeconds = 1.5, + .detectedAtSeconds = 7.5, + .detectionFrame = detectionFrame, + }; + + ProjectWorkspaceState workspace; + workspace.activeView = ProjectWorkspaceView::ScenarioResult; + workspace.result = SavedScenarioResultState{ + .scenario = scenario, + .risk = { + .completionRisk = ScenarioRiskLevel::High, + .stalledAgentCount = 3, + .pressureExposedAgentCount = 5, + .criticalPressureAgentCount = 2, + .pressureHotspots = {hotspot}, + .pressureAgents = {agent}, + .criticalPressureEvents = {event}, + }, + .artifacts = { + .pressureSummary = { + .cellSizeMeters = 1.5, + .hotspotScoreThreshold = 4.0, + .criticalCompressionForceThreshold = 1.0, + .criticalExposureThresholdSeconds = 2.0, + .criticalEventDurationThresholdSeconds = 1.0, + .criticalEventAgentThreshold = 2, + .peakPressureScore = 8.7, + .peakAtSeconds = 7.5, + .peakCell = peakCell, + .peakExposedAgentCount = 5, + .peakCriticalAgentCount = 2, + .peakCells = {peakCell}, + .peakField = { + .timeSeconds = 7.5, + .cellSizeMeters = 1.5, + .cells = {fieldCell}, + }, + .peakHotspots = {hotspot}, + .peakAgents = {agent}, + .criticalEvents = {event}, + }, + }, + }; + + const ProjectMetadata metadata{ + .name = "Pressure Persistence Test", + .folderPath = projectDir.path(), + }; + + QString errorMessage; + SC_EXPECT_TRUE(ProjectPersistence::saveProjectWorkspace(metadata, workspace, &errorMessage)); + + ProjectWorkspaceState loaded; + SC_EXPECT_TRUE(ProjectPersistence::loadProjectWorkspace(metadata, &loaded)); + SC_EXPECT_TRUE(loaded.result.has_value()); + + const auto& loadedRisk = loaded.result->risk; + SC_EXPECT_TRUE(loadedRisk.completionRisk == ScenarioRiskLevel::High); + SC_EXPECT_EQ(loadedRisk.pressureExposedAgentCount, std::size_t{5}); + SC_EXPECT_EQ(loadedRisk.criticalPressureAgentCount, std::size_t{2}); + SC_EXPECT_EQ(loadedRisk.pressureHotspots.size(), std::size_t{1}); + SC_EXPECT_EQ(loadedRisk.pressureAgents.size(), std::size_t{1}); + SC_EXPECT_EQ(loadedRisk.criticalPressureEvents.size(), std::size_t{1}); + SC_EXPECT_NEAR(loadedRisk.pressureHotspots.front().pressureScore, 8.7, 1e-9); + SC_EXPECT_TRUE(loadedRisk.pressureHotspots.front().detectionFrame.has_value()); + SC_EXPECT_EQ(loadedRisk.pressureAgents.front().agentId, std::uint64_t{42}); + SC_EXPECT_TRUE(loadedRisk.pressureAgents.front().critical); + SC_EXPECT_NEAR(loadedRisk.pressureAgents.front().position.x, 4.2, 1e-9); + SC_EXPECT_NEAR(loadedRisk.pressureAgents.front().compressionForce, 1.2, 1e-9); + SC_EXPECT_NEAR(loadedRisk.pressureAgents.front().exposureSeconds, 2.5, 1e-9); + SC_EXPECT_NEAR(loadedRisk.criticalPressureEvents.front().durationSeconds, 1.5, 1e-9); + SC_EXPECT_TRUE(loadedRisk.criticalPressureEvents.front().detectionFrame.has_value()); + SC_EXPECT_EQ(loadedRisk.criticalPressureEvents.front().detectionFrame->agents.size(), std::size_t{1}); + SC_EXPECT_EQ(loadedRisk.criticalPressureEvents.front().detectionFrame->agents.front().id, std::uint64_t{42}); + + const auto& loadedSummary = loaded.result->artifacts.pressureSummary; + SC_EXPECT_NEAR(loadedSummary.cellSizeMeters, 1.5, 1e-9); + SC_EXPECT_NEAR(loadedSummary.hotspotScoreThreshold, 4.0, 1e-9); + SC_EXPECT_NEAR(loadedSummary.criticalCompressionForceThreshold, 1.0, 1e-9); + SC_EXPECT_NEAR(loadedSummary.criticalExposureThresholdSeconds, 2.0, 1e-9); + SC_EXPECT_NEAR(loadedSummary.criticalEventDurationThresholdSeconds, 1.0, 1e-9); + SC_EXPECT_EQ(loadedSummary.criticalEventAgentThreshold, std::size_t{2}); + SC_EXPECT_NEAR(loadedSummary.peakPressureScore, 8.7, 1e-9); + SC_EXPECT_TRUE(loadedSummary.peakAtSeconds.has_value()); + SC_EXPECT_NEAR(*loadedSummary.peakAtSeconds, 7.5, 1e-9); + SC_EXPECT_TRUE(loadedSummary.peakCell.has_value()); + SC_EXPECT_NEAR(loadedSummary.peakCell->center.x, 4.5, 1e-9); + SC_EXPECT_NEAR(loadedSummary.peakCell->densityPeoplePerSquareMeter, 3.8, 1e-9); + SC_EXPECT_EQ(loadedSummary.peakCell->intrudingPairCount, std::size_t{3}); + SC_EXPECT_EQ(loadedSummary.peakExposedAgentCount, std::size_t{5}); + SC_EXPECT_EQ(loadedSummary.peakCriticalAgentCount, std::size_t{2}); + SC_EXPECT_EQ(loadedSummary.peakCells.size(), std::size_t{1}); + SC_EXPECT_EQ(loadedSummary.peakField.cells.size(), std::size_t{1}); + SC_EXPECT_NEAR(loadedSummary.peakField.timeSeconds, 7.5, 1e-9); + SC_EXPECT_NEAR(loadedSummary.peakField.cells.front().pressureScore, 5.4, 1e-9); + SC_EXPECT_EQ(loadedSummary.peakHotspots.size(), std::size_t{1}); + SC_EXPECT_EQ(loadedSummary.peakAgents.size(), std::size_t{1}); + SC_EXPECT_EQ(loadedSummary.criticalEvents.size(), std::size_t{1}); + SC_EXPECT_TRUE(loadedSummary.criticalEvents.front().detectionFrame.has_value()); + SC_EXPECT_NEAR(loadedSummary.criticalEvents.front().detectionFrame->elapsedSeconds, 7.5, 1e-9); +} + +SC_TEST(ProjectPersistence_preservesBatchPressureResultArtifacts) { + QTemporaryDir projectDir; + SC_EXPECT_TRUE(projectDir.isValid()); + + SavedScenarioResultState first; + first.scenario.scenarioId = "pressure-batch-low"; + first.scenario.name = "Pressure Batch Low"; + first.risk.pressureExposedAgentCount = 1; + first.artifacts.pressureSummary.peakPressureScore = 2.5; + first.artifacts.pressureSummary.peakExposedAgentCount = 1; + + ScenarioCriticalPressureEvent event{ + .center = {.x = 8.0, .y = 9.0}, + .cellMin = {.x = 7.5, .y = 8.5}, + .cellMax = {.x = 8.5, .y = 9.5}, + .floorId = "L3", + .exposedAgentCount = 7, + .criticalAgentCount = 3, + .pressureScore = 11.0, + .startedAtSeconds = 12.0, + .durationSeconds = 2.0, + .detectedAtSeconds = 14.0, + }; + + SavedScenarioResultState second; + second.scenario.scenarioId = "pressure-batch-critical"; + second.scenario.name = "Pressure Batch Critical"; + second.risk.completionRisk = ScenarioRiskLevel::High; + second.risk.pressureExposedAgentCount = 7; + second.risk.criticalPressureAgentCount = 3; + second.risk.criticalPressureEvents = {event}; + second.artifacts.pressureSummary.peakPressureScore = 11.0; + second.artifacts.pressureSummary.peakExposedAgentCount = 7; + second.artifacts.pressureSummary.peakCriticalAgentCount = 3; + second.artifacts.pressureSummary.criticalEvents = {event}; + + ProjectWorkspaceState workspace; + workspace.activeView = ProjectWorkspaceView::ScenarioResult; + workspace.batchResult = SavedScenarioBatchResultState{ + .results = {first, second}, + .currentResultIndex = 1, + }; + + const ProjectMetadata metadata{ + .name = "Pressure Batch Persistence Test", + .folderPath = projectDir.path(), + }; + + QString errorMessage; + SC_EXPECT_TRUE(ProjectPersistence::saveProjectWorkspace(metadata, workspace, &errorMessage)); + + ProjectWorkspaceState loaded; + SC_EXPECT_TRUE(ProjectPersistence::loadProjectWorkspace(metadata, &loaded)); + SC_EXPECT_TRUE(loaded.batchResult.has_value()); + SC_EXPECT_EQ(loaded.batchResult->results.size(), std::size_t{2}); + SC_EXPECT_EQ(loaded.batchResult->currentResultIndex, 1); + + const auto& loadedFirst = loaded.batchResult->results.front(); + SC_EXPECT_EQ(loadedFirst.scenario.scenarioId, std::string{"pressure-batch-low"}); + SC_EXPECT_EQ(loadedFirst.risk.pressureExposedAgentCount, std::size_t{1}); + SC_EXPECT_NEAR(loadedFirst.artifacts.pressureSummary.peakPressureScore, 2.5, 1e-9); + + const auto& loadedSecond = loaded.batchResult->results.back(); + SC_EXPECT_EQ(loadedSecond.scenario.scenarioId, std::string{"pressure-batch-critical"}); + SC_EXPECT_TRUE(loadedSecond.risk.completionRisk == ScenarioRiskLevel::High); + SC_EXPECT_EQ(loadedSecond.risk.criticalPressureAgentCount, std::size_t{3}); + SC_EXPECT_EQ(loadedSecond.risk.criticalPressureEvents.size(), std::size_t{1}); + SC_EXPECT_NEAR(loadedSecond.risk.criticalPressureEvents.front().pressureScore, 11.0, 1e-9); + SC_EXPECT_EQ(loadedSecond.artifacts.pressureSummary.peakCriticalAgentCount, std::size_t{3}); + SC_EXPECT_EQ(loadedSecond.artifacts.pressureSummary.criticalEvents.size(), std::size_t{1}); + SC_EXPECT_NEAR(loadedSecond.artifacts.pressureSummary.criticalEvents.front().durationSeconds, 2.0, 1e-9); +} + +SC_TEST(ResultArtifactsCodec_readsNumericAgentIdsForLegacyJson) { + const auto pointJson = [](double x, double y) { + QJsonArray point; + point.append(x); + point.append(y); + return point; + }; + + QJsonObject frameAgent; + frameAgent["id"] = 77; + frameAgent["position"] = pointJson(1.0, 2.0); + frameAgent["velocity"] = pointJson(0.1, 0.2); + frameAgent["radius"] = 0.25; + frameAgent["floorId"] = "L1"; + frameAgent["stalled"] = false; + + QJsonArray frameAgents; + frameAgents.append(frameAgent); + + QJsonObject frameObject; + frameObject["elapsedSeconds"] = 1.0; + frameObject["complete"] = false; + frameObject["totalAgentCount"] = 1; + frameObject["evacuatedAgentCount"] = 0; + frameObject["agents"] = frameAgents; + + const auto frame = simulationFrameFromJson(frameObject); + SC_EXPECT_EQ(frame.agents.size(), std::size_t{1}); + SC_EXPECT_EQ(frame.agents.front().id, std::uint64_t{77}); + + QJsonObject pressureAgent; + pressureAgent["agentId"] = 88; + pressureAgent["position"] = pointJson(3.0, 4.0); + pressureAgent["floorId"] = "L2"; + pressureAgent["compressionForce"] = 1.25; + pressureAgent["exposureSeconds"] = 2.5; + pressureAgent["critical"] = true; + + QJsonArray pressureAgents; + pressureAgents.append(pressureAgent); + + QJsonObject riskObject; + riskObject["completionRisk"] = static_cast(ScenarioRiskLevel::Medium); + riskObject["stalledAgentCount"] = 0; + riskObject["pressureAgents"] = pressureAgents; + + const auto risk = riskSnapshotFromJson(riskObject); + SC_EXPECT_EQ(risk.pressureAgents.size(), std::size_t{1}); + SC_EXPECT_EQ(risk.pressureAgents.front().agentId, std::uint64_t{88}); + SC_EXPECT_TRUE(risk.pressureAgents.front().critical); +} + SC_TEST(ProjectPersistence_preservesHazardExposureResultArtifacts) { QTemporaryDir projectDir; SC_EXPECT_TRUE(projectDir.isValid());