diff --git a/core/src/main/java/fr/sncf/osrd/api/ConflictDetectionEndpoint.java b/core/src/main/java/fr/sncf/osrd/api/ConflictDetectionEndpoint.java index 1c282f57e07..ab51b012e91 100644 --- a/core/src/main/java/fr/sncf/osrd/api/ConflictDetectionEndpoint.java +++ b/core/src/main/java/fr/sncf/osrd/api/ConflictDetectionEndpoint.java @@ -90,11 +90,31 @@ public enum ConflictType { @Json(name = "conflict_type") public final ConflictType conflictType; - public Conflict(Collection trainIds, double startTime, double endTime, ConflictType conflictType) { + public final transient Collection requirements; + + public Conflict( + Collection trainIds, + double startTime, + double endTime, + ConflictType conflictType, + Collection requirements) { this.trainIds = trainIds; this.startTime = startTime; this.endTime = endTime; this.conflictType = conflictType; + this.requirements = requirements; + } + } + + public static class ConflictRequirement { + public final String zone; + public final double startTime; + public final double endTime; + + public ConflictRequirement(String zone, double startTime, double endTime) { + this.zone = zone; + this.startTime = startTime; + this.endTime = endTime; } } diff --git a/core/src/main/java/fr/sncf/osrd/conflicts/Conflicts.kt b/core/src/main/java/fr/sncf/osrd/conflicts/Conflicts.kt index 0aaa80d5394..cdc43190667 100644 --- a/core/src/main/java/fr/sncf/osrd/conflicts/Conflicts.kt +++ b/core/src/main/java/fr/sncf/osrd/conflicts/Conflicts.kt @@ -4,6 +4,7 @@ import com.carrotsearch.hppc.IntArrayList import com.squareup.moshi.Json import fr.sncf.osrd.api.ConflictDetectionEndpoint.ConflictDetectionResult.Conflict import fr.sncf.osrd.api.ConflictDetectionEndpoint.ConflictDetectionResult.Conflict.ConflictType +import fr.sncf.osrd.api.ConflictDetectionEndpoint.ConflictDetectionResult.ConflictRequirement import fr.sncf.osrd.standalone_sim.result.ResultTrain.RoutingRequirement import fr.sncf.osrd.standalone_sim.result.ResultTrain.SpacingRequirement import kotlin.math.max @@ -171,12 +172,15 @@ class IncrementalConflictDetectorImpl(trainRequirements: List // look for requirement times overlaps. // as spacing requirements are exclusive, any overlap is a conflict val res = mutableListOf() - for (requirements in spacingZoneRequirements.values) { - for (conflictGroup in detectRequirementConflicts(requirements) { _, _ -> true }) { + for (entry in spacingZoneRequirements) { + for (conflictGroup in detectRequirementConflicts(entry.value) { _, _ -> true }) { val trains = conflictGroup.map { it.trainId } val beginTime = conflictGroup.minBy { it.beginTime }.beginTime val endTime = conflictGroup.maxBy { it.endTime }.endTime - res.add(Conflict(trains, beginTime, endTime, ConflictType.SPACING)) + val conflictReq = ConflictRequirement(entry.key, beginTime, endTime) + res.add( + Conflict(trains, beginTime, endTime, ConflictType.SPACING, listOf(conflictReq)) + ) } } return res @@ -185,13 +189,16 @@ class IncrementalConflictDetectorImpl(trainRequirements: List private fun detectRoutingConflicts(): List { // for each zone, check compatibility of overlapping requirements val res = mutableListOf() - for (requirements in routingZoneRequirements.values) { + for (entry in routingZoneRequirements) { for (conflictGroup in - detectRequirementConflicts(requirements) { a, b -> a.config != b.config }) { + detectRequirementConflicts(entry.value) { a, b -> a.config != b.config }) { val trains = conflictGroup.map { it.trainId } val beginTime = conflictGroup.minBy { it.beginTime }.beginTime val endTime = conflictGroup.maxBy { it.endTime }.endTime - res.add(Conflict(trains, beginTime, endTime, ConflictType.ROUTING)) + val conflictReq = ConflictRequirement(entry.key, beginTime, endTime) + res.add( + Conflict(trains, beginTime, endTime, ConflictType.ROUTING, listOf(conflictReq)) + ) } } return res @@ -218,9 +225,16 @@ class IncrementalConflictDetectorImpl(trainRequirements: List for (otherReq in requirements) { val beginTime = max(req.beginTime, otherReq.beginTime) val endTime = min(req.endTime, otherReq.endTime) + val conflictReq = ConflictRequirement(req.zone, beginTime, endTime) if (beginTime < endTime) res.add( - Conflict(listOf(otherReq.trainId), beginTime, endTime, ConflictType.SPACING) + Conflict( + listOf(otherReq.trainId), + beginTime, + endTime, + ConflictType.SPACING, + listOf(conflictReq) + ) ) } @@ -238,9 +252,16 @@ class IncrementalConflictDetectorImpl(trainRequirements: List if (otherReq.config == zoneReqConfig) continue val beginTime = max(req.beginTime, otherReq.beginTime) val endTime = min(zoneReq.endTime, otherReq.endTime) + val conflictReq = ConflictRequirement(zoneReq.zone, beginTime, endTime) if (beginTime < endTime) res.add( - Conflict(listOf(otherReq.trainId), beginTime, endTime, ConflictType.ROUTING) + Conflict( + listOf(otherReq.trainId), + beginTime, + endTime, + ConflictType.ROUTING, + listOf(conflictReq) + ) ) } } @@ -421,7 +442,11 @@ enum class EventType { END } -class Event(val eventType: EventType, val time: Double) : Comparable { +class Event( + val eventType: EventType, + val time: Double, + val requirements: Collection +) : Comparable { override fun compareTo(other: Event): Int { val timeDelta = this.time.compareTo(other.time) if (timeDelta != 0) return timeDelta @@ -443,28 +468,32 @@ fun mergeMap( // create an event list and sort it val events = mutableListOf() for (conflict in conflicts) { - events.add(Event(EventType.BEGIN, conflict.startTime)) - events.add(Event(EventType.END, conflict.endTime)) + events.add(Event(EventType.BEGIN, conflict.startTime, conflict.requirements)) + events.add(Event(EventType.END, conflict.endTime, conflict.requirements)) } events.sort() var eventCount = 0 var eventBeginning = 0.0 + var conflictReqs = mutableListOf() for (event in events) { when (event.eventType) { EventType.BEGIN -> { if (++eventCount == 1) eventBeginning = event.time + conflictReqs.addAll(event.requirements) } EventType.END -> { - if (--eventCount == 0) - newConflicts.add( - Conflict( - trainIds.toMutableList(), - eventBeginning, - event.time, - conflictType - ) + if (--eventCount > 0) continue + newConflicts.add( + Conflict( + trainIds.toMutableList(), + eventBeginning, + event.time, + conflictType, + conflictReqs ) + ) + conflictReqs = mutableListOf() } } } diff --git a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/conflicts/ConflictDetectionEndpointV2.kt b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/conflicts/ConflictDetectionEndpointV2.kt index b0cd4276e6f..004434b0a39 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/conflicts/ConflictDetectionEndpointV2.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/conflicts/ConflictDetectionEndpointV2.kt @@ -52,7 +52,14 @@ private fun makeConflictDetectionResponse( it.trainIds, startTime.plus(Duration.ofMillis((it.startTime * 1000).toLong())), startTime.plus(Duration.ofMillis((it.endTime * 1000).toLong())), - it.conflictType + it.conflictType, + it.requirements.map { + ConflictRequirement( + it.zone, + startTime.plus(Duration.ofMillis((it.startTime * 1000).toLong())), + startTime.plus(Duration.ofMillis((it.endTime * 1000).toLong())), + ) + } ) } ) diff --git a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/conflicts/ConflictDetectionResponse.kt b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/conflicts/ConflictDetectionResponse.kt index ee93f927c89..80442b21b64 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/conflicts/ConflictDetectionResponse.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/conflicts/ConflictDetectionResponse.kt @@ -18,6 +18,13 @@ class Conflict( @Json(name = "end_time") val endTime: ZonedDateTime, @Json(name = "conflict_type") val conflictType: ConflictDetectionEndpoint.ConflictDetectionResult.Conflict.ConflictType, + @Json(name = "requirements") val requirements: Collection, +) + +class ConflictRequirement( + @Json(name = "zone") val zone: String, + @Json(name = "start_time") val startTime: ZonedDateTime, + @Json(name = "end_time") val endTime: ZonedDateTime, ) val conflictResponseAdapter: JsonAdapter = diff --git a/core/src/test/java/fr/sncf/osrd/standalone_sim/ConflictDetectionTest.java b/core/src/test/java/fr/sncf/osrd/standalone_sim/ConflictDetectionTest.java index 0c682ce5571..1f864e9d473 100644 --- a/core/src/test/java/fr/sncf/osrd/standalone_sim/ConflictDetectionTest.java +++ b/core/src/test/java/fr/sncf/osrd/standalone_sim/ConflictDetectionTest.java @@ -10,10 +10,13 @@ import static fr.sncf.osrd.utils.Helpers.fullInfraFromRJS; import static fr.sncf.osrd.utils.units.Distance.fromMeters; import static fr.sncf.osrd.utils.units.Distance.toMeters; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertIterableEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import fr.sncf.osrd.api.ConflictDetectionEndpoint.ConflictDetectionResult.Conflict; +import fr.sncf.osrd.api.ConflictDetectionEndpoint.ConflictDetectionResult.ConflictRequirement; import fr.sncf.osrd.api.FullInfra; import fr.sncf.osrd.conflicts.ConflictsKt; import fr.sncf.osrd.conflicts.TrainRequirements; @@ -31,6 +34,7 @@ import fr.sncf.osrd.utils.Helpers; import java.util.ArrayList; import java.util.List; +import java.util.stream.LongStream; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -438,6 +442,38 @@ public void conflictDetectionForOvertakeInStation( assert (hasSpacingConflict == conflicts.stream().anyMatch((conflict) -> conflict.conflictType == SPACING)); } + /* + Test that merging conflicts groups entries by time as expected. The first two + conflicts overlap (zones "A" and "B"), the last one is isolated (zone "C"). + */ + @Test + public void testConflictMerge() { + var reqs = List.of( + new TrainRequirements(0, List.of(new ResultTrain.SpacingRequirement("A", 10, 20, true)), List.of()), + new TrainRequirements(0, List.of(new ResultTrain.SpacingRequirement("B", 20, 30, true)), List.of()), + new TrainRequirements(0, List.of(new ResultTrain.SpacingRequirement("C", 40, 50, true)), List.of()), + new TrainRequirements(1, List.of(new ResultTrain.SpacingRequirement("A", 15, 25, true)), List.of()), + new TrainRequirements(1, List.of(new ResultTrain.SpacingRequirement("B", 25, 35, true)), List.of()), + new TrainRequirements(1, List.of(new ResultTrain.SpacingRequirement("C", 45, 55, true)), List.of())); + + var conflicts = ConflictsKt.detectConflicts(reqs); + + var expectedConflicts = List.of( + new Conflict( + LongStream.of(0, 1).boxed().toList(), + 10, + 35, + SPACING, + List.of(new ConflictRequirement("A", 10, 25), new ConflictRequirement("B", 20, 35))), + new Conflict( + LongStream.of(0, 1).boxed().toList(), + 40, + 55, + SPACING, + List.of(new ConflictRequirement("C", 40, 55)))); + assertThat(conflicts).usingRecursiveComparison().isEqualTo(expectedConflicts); + } + private static TrainRequirements convertRequirements(long trainId, double offset, ResultTrain train) { var spacingRequirements = new ArrayList(); for (var req : train.spacingRequirements) diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index a8c7fc4b63d..92f938e32b0 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -2953,6 +2953,7 @@ components: - start_time - end_time - conflict_type + - requirements properties: conflict_type: type: string @@ -2963,6 +2964,11 @@ components: type: string format: date-time description: Datetime of the end of the conflict + requirements: + type: array + items: + $ref: '#/components/schemas/ConflictRequirement' + description: List of requirements causing the conflict start_time: type: string format: date-time @@ -2987,6 +2993,7 @@ components: - start_time - end_time - conflict_type + - requirements properties: conflict_type: type: string @@ -2997,6 +3004,11 @@ components: type: string format: date-time description: Datetime of the end of the conflict + requirements: + type: array + items: + $ref: '#/components/schemas/ConflictRequirement' + description: List of requirements causing the conflict start_time: type: string format: date-time @@ -3008,6 +3020,26 @@ components: format: int64 description: List of train ids involved in the conflict description: List of conflicts detected + ConflictRequirement: + type: object + description: |- + Unmet requirement causing a conflict. + + The start and end time describe the conflicting time span (not the full + requirement's time span). + required: + - zone + - start_time + - end_time + properties: + end_time: + type: string + format: date-time + start_time: + type: string + format: date-time + zone: + type: string CopyOperation: type: object description: JSON Patch 'copy' operation representation diff --git a/editoast/src/core/conflict_detection.rs b/editoast/src/core/conflict_detection.rs index 067acbe814c..9f340abd5b1 100644 --- a/editoast/src/core/conflict_detection.rs +++ b/editoast/src/core/conflict_detection.rs @@ -13,6 +13,7 @@ use super::simulation::SpacingRequirement; editoast_common::schemas! { ConflictDetectionResponse, Conflict, + ConflictRequirement, } #[derive(Debug, Serialize)] @@ -47,6 +48,19 @@ pub struct Conflict { /// Type of the conflict #[schema(inline)] pub conflict_type: ConflictType, + /// List of requirements causing the conflict + pub requirements: Vec, +} + +/// Unmet requirement causing a conflict. +/// +/// The start and end time describe the conflicting time span (not the full +/// requirement's time span). +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] +pub struct ConflictRequirement { + pub zone: String, + pub start_time: DateTime, + pub end_time: DateTime, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)] diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index 5304f714c09..a9e7542a670 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -2744,10 +2744,17 @@ export type TimetableDetailedResult = { timetable_id: number; train_ids: number[]; }; +export type ConflictRequirement = { + end_time: string; + start_time: string; + zone: string; +}; export type Conflict = { conflict_type: 'Spacing' | 'Routing'; /** Datetime of the end of the conflict */ end_time: string; + /** List of requirements causing the conflict */ + requirements: ConflictRequirement[]; /** Datetime of the start of the conflict */ start_time: string; /** List of train ids involved in the conflict */