From ca9eb3c5b731bd9881557d6859d06fd63e4c0495 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 17:14:50 +0000 Subject: [PATCH] Optimize BuildCandidateRoutes by pre-computing active producers and consumers --- .jules/bolt.md | 3 + patch_bolt.diff | 48 + patch_bolt2.diff | 25 + .../Services/MixedRouting.cs | 17 +- .../TemporalNetworkSimulationEngine.cs | 16 +- .../TemporalNetworkSimulationEngine.cs.orig | 2933 +++++++++++++++++ 6 files changed, 3032 insertions(+), 10 deletions(-) create mode 100644 patch_bolt.diff create mode 100644 patch_bolt2.diff create mode 100644 src/MedWNetworkSim.App/Services/TemporalNetworkSimulationEngine.cs.orig diff --git a/.jules/bolt.md b/.jules/bolt.md index 41d3553..f242a56 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -13,3 +13,6 @@ ## 2026-05-14 - O(N^2) LINQ Evaluation inside Nested Routing Loop **Learning:** `BuildCandidateRoutes` runs repeatedly during capacity bidding (inside a `while (true)` loop). In its original form, it performed `context.Demand.Where(...).Select(...)` within the body of an outer `foreach` loop over `context.Supply`. This led to the `Demand` dictionary being repeatedly iterated and filtered on *every* producer iteration, causing severe O(P * C) allocation and evaluation bottlenecks. **Action:** When a method processes combinations from two collections using nested loops, always pre-compute the filtered/projected sequences (e.g., `ToList()`) *outside* of the outer loop. +## 2026-05-17 - O(N*M) LINQ Evaluation in `BuildCandidateRoutes` Inner Loops +**Learning:** In routing services (`TemporalNetworkSimulationEngine.cs` and `MixedRouting.cs`), `BuildCandidateRoutes` used LINQ filtering (`Where`, `Select`) on dictionaries (`context.Supply`, `context.Demand`) directly within the inner scopes of nested `foreach` loops. Because the outer loop iterated over producing nodes and the inner over consuming nodes, this led to extreme O(P*C) enumerator allocations and redundant LINQ evaluations. +**Action:** Always pre-compute and materialize `.ToList()` filtered source collections outside of outer iteration loops when processing combinatorics (like routes between producers and consumers). diff --git a/patch_bolt.diff b/patch_bolt.diff new file mode 100644 index 0000000..732f6cc --- /dev/null +++ b/patch_bolt.diff @@ -0,0 +1,48 @@ +--- src/MedWNetworkSim.App/Services/TemporalNetworkSimulationEngine.cs ++++ src/MedWNetworkSim.App/Services/TemporalNetworkSimulationEngine.cs +@@ -1941,10 +1941,16 @@ + { + var routes = new List(); + +- foreach (var producerNodeId in context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key)) ++ // ⚡ Bolt: Pre-compute active producers and consumers outside the nested loops ++ // to avoid O(P * C) redundant LINQ evaluations and allocations during routing. ++ var activeProducers = context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key).ToList(); ++ var activeConsumers = context.Demand ++ .Where(pair => pair.Value > Epsilon) ++ .Select(pair => pair.Key) ++ .Where(nodeId => context.MeetingDemandEligibleNodeIds.Contains(nodeId)) ++ .ToList(); ++ ++ foreach (var producerNodeId in activeProducers) + { +- foreach (var consumerNodeId in context.Demand +- .Where(pair => pair.Value > Epsilon) +- .Select(pair => pair.Key) +- .Where(nodeId => context.MeetingDemandEligibleNodeIds.Contains(nodeId))) ++ foreach (var consumerNodeId in activeConsumers) + { +--- src/MedWNetworkSim.App/Services/MixedRouting.cs ++++ src/MedWNetworkSim.App/Services/MixedRouting.cs +@@ -825,10 +825,16 @@ + { + var routes = new List(); +- foreach (var producerNodeId in context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key)) ++ ++ // ⚡ Bolt: Pre-compute active producers and consumers outside the nested loops ++ // to avoid O(P * C) redundant LINQ evaluations and allocations during routing. ++ var activeProducers = context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key).ToList(); ++ var activeConsumers = context.Demand ++ .Where(pair => pair.Value > Epsilon) ++ .Select(pair => pair.Key) ++ .Where(nodeId => context.MeetingDemandEligibleNodeIds.Contains(nodeId)) ++ .ToList(); ++ ++ foreach (var producerNodeId in activeProducers) + { +- foreach (var consumerNodeId in context.Demand +- .Where(pair => pair.Value > Epsilon) +- .Select(pair => pair.Key) +- .Where(nodeId => context.MeetingDemandEligibleNodeIds.Contains(nodeId))) ++ foreach (var consumerNodeId in activeConsumers) + { diff --git a/patch_bolt2.diff b/patch_bolt2.diff new file mode 100644 index 0000000..e52d249 --- /dev/null +++ b/patch_bolt2.diff @@ -0,0 +1,25 @@ +--- src/MedWNetworkSim.App/Services/MixedRouting.cs ++++ src/MedWNetworkSim.App/Services/MixedRouting.cs +@@ -825,12 +825,18 @@ + { + var routes = new List(); +- foreach (var producerNodeId in context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key)) ++ ++ // ⚡ Bolt: Pre-compute active producers and consumers outside the nested loops ++ // to avoid O(P * C) redundant LINQ evaluations and allocations during routing. ++ var activeProducers = context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key).ToList(); ++ var activeConsumers = context.Demand ++ .Where(pair => pair.Value > Epsilon) ++ .Select(pair => pair.Key) ++ .Where(nodeId => context.MeetingDemandEligibleNodeIds.Contains(nodeId)) ++ .ToList(); ++ ++ foreach (var producerNodeId in activeProducers) + { +- foreach (var consumerNodeId in context.Demand +- .Where(pair => pair.Value > Epsilon) +- .Select(pair => pair.Key) +- .Where(nodeId => context.MeetingDemandEligibleNodeIds.Contains(nodeId))) ++ foreach (var consumerNodeId in activeConsumers) + { + if (Comparer.Equals(producerNodeId, consumerNodeId)) diff --git a/src/MedWNetworkSim.App/Services/MixedRouting.cs b/src/MedWNetworkSim.App/Services/MixedRouting.cs index 38cc454..d61d297 100644 --- a/src/MedWNetworkSim.App/Services/MixedRouting.cs +++ b/src/MedWNetworkSim.App/Services/MixedRouting.cs @@ -825,12 +825,19 @@ public static void ApplyLocalAllocations(RoutingTrafficContext context, NetworkM private static List BuildCandidateRoutes(RoutingTrafficContext context, NetworkState state, AllocationContext allocationContext) { var routes = new List(); - foreach (var producerNodeId in context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key)) + + // ⚡ Bolt: Pre-compute active producers and consumers outside the nested loops + // to avoid O(P * C) redundant LINQ evaluations and allocations during routing. + var activeProducers = context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key).ToList(); + var activeConsumers = context.Demand + .Where(pair => pair.Value > Epsilon) + .Select(pair => pair.Key) + .Where(nodeId => context.MeetingDemandEligibleNodeIds.Contains(nodeId)) + .ToList(); + + foreach (var producerNodeId in activeProducers) { - foreach (var consumerNodeId in context.Demand - .Where(pair => pair.Value > Epsilon) - .Select(pair => pair.Key) - .Where(nodeId => context.MeetingDemandEligibleNodeIds.Contains(nodeId))) + foreach (var consumerNodeId in activeConsumers) { if (Comparer.Equals(producerNodeId, consumerNodeId)) { diff --git a/src/MedWNetworkSim.App/Services/TemporalNetworkSimulationEngine.cs b/src/MedWNetworkSim.App/Services/TemporalNetworkSimulationEngine.cs index c0c6884..5a7c75d 100644 --- a/src/MedWNetworkSim.App/Services/TemporalNetworkSimulationEngine.cs +++ b/src/MedWNetworkSim.App/Services/TemporalNetworkSimulationEngine.cs @@ -1941,12 +1941,18 @@ private static List BuildCandidateRoutes( { var routes = new List(); - foreach (var producerNodeId in context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key)) + // ⚡ Bolt: Pre-compute active producers and consumers outside the nested loops + // to avoid O(P * C) redundant LINQ evaluations and allocations during routing. + var activeProducers = context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key).ToList(); + var activeConsumers = context.Demand + .Where(pair => pair.Value > Epsilon) + .Select(pair => pair.Key) + .Where(nodeId => context.MeetingDemandEligibleNodeIds.Contains(nodeId)) + .ToList(); + + foreach (var producerNodeId in activeProducers) { - foreach (var consumerNodeId in context.Demand - .Where(pair => pair.Value > Epsilon) - .Select(pair => pair.Key) - .Where(nodeId => context.MeetingDemandEligibleNodeIds.Contains(nodeId))) + foreach (var consumerNodeId in activeConsumers) { if (Comparer.Equals(producerNodeId, consumerNodeId)) { diff --git a/src/MedWNetworkSim.App/Services/TemporalNetworkSimulationEngine.cs.orig b/src/MedWNetworkSim.App/Services/TemporalNetworkSimulationEngine.cs.orig new file mode 100644 index 0000000..c0c6884 --- /dev/null +++ b/src/MedWNetworkSim.App/Services/TemporalNetworkSimulationEngine.cs.orig @@ -0,0 +1,2933 @@ +using MedWNetworkSim.App.Models; +using System.Collections.Frozen; + +namespace MedWNetworkSim.App.Services; + +/// +/// An advanced simulation engine orchestrating dynamic, multi-period network operations over time. +/// While the standard resolves immediate routing, this temporal engine manages chronological events, +/// long-running processes (in-flight movements), storage backlogs, scenario progression across multiple time windows, +/// and adaptive routing strategies. +/// +public sealed class TemporalNetworkSimulationEngine +{ + private readonly SimulationClock clock = new(); + private readonly ISimulationEventQueue eventQueue = new SimulationEventQueue(); + private readonly SimulationExecutionCache executionCache = new(); + private readonly TrafficEconomicSettlementService settlementService = new(); + /// + /// Gets or sets the clock. + /// + + public SimulationClock Clock => clock; + /// + /// Gets or sets the event queue. + /// + + public ISimulationEventQueue EventQueue => eventQueue; + private const double Epsilon = 0.000001d; + private const double PerishabilityPriorityBidFactor = 100d; + private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase; + /// + /// Executes the initialize operation. + /// + + public TemporalSimulationState Initialize(NetworkModel network) + { + ArgumentNullException.ThrowIfNull(network); + network = executionCache.GetStaticContext(network).EffectiveNetwork; + + var state = new TemporalSimulationState(); + foreach (var node in network.Nodes) + { + foreach (var profile in node.TrafficProfiles) + { + state.GetOrCreateNodeTrafficState(node.Id, profile.TrafficType); + } + } + + return state; + } + /// + /// Executes the advance operation. + /// + + public TemporalSimulationStepResult Advance(NetworkModel network, TemporalSimulationState? currentState) + { + return Advance(network, currentState, new SimulationRunOptions()); + } + /// + /// Executes the advance operation. + /// + + public TemporalSimulationStepResult Advance(NetworkModel network, TemporalSimulationState? currentState, SimulationRunOptions options) + { + ArgumentNullException.ThrowIfNull(network); + options ??= new SimulationRunOptions(); + clock.DeltaTime = options.DeltaTime > 0d ? options.DeltaTime : 1d; + var state = currentState ?? Initialize(network); + var context = new SimulationContext + { + Network = network, + TemporalState = state, + Options = options + }; + + foreach (var scheduled in eventQueue.DequeueDueEvents(clock.CurrentTime)) + { + scheduled.Execute(context); + } + var nextPeriod = state.CurrentPeriod + 1; + var effectivePeriod = GetEffectivePeriod(nextPeriod, network.TimelineLoopLength); + var compiledContext = executionCache.GetTemporalContext(network, effectivePeriod); + var effectiveNetwork = compiledContext.EffectiveNetwork; + var copyStateBeforeAdvance = options.CopyStateBeforeAdvance; + var nodeStates = copyStateBeforeAdvance + ? state.NodeStates.ToDictionary(pair => pair.Key, pair => pair.Value.Clone(), TemporalNodeTrafficKey.Comparer) + : state.NodeStates; + var movements = copyStateBeforeAdvance + ? state.InFlightMovements.Select(movement => movement.Clone()).ToList() + : state.InFlightMovements; + var newlyAllocatedMovements = new List(); + var nodeLookup = compiledContext.NodesById; + var edgeLookup = compiledContext.EdgesById; + var definitionsByTraffic = compiledContext.TrafficDefinitionsByName; + var occupiedEdgeCapacity = copyStateBeforeAdvance + ? state.OccupiedEdgeCapacity.ToDictionary(pair => pair.Key, pair => pair.Value, Comparer) + : state.OccupiedEdgeCapacity; + var occupiedEdgeTrafficCapacity = copyStateBeforeAdvance + ? state.OccupiedEdgeTrafficCapacity.ToDictionary(pair => pair.Key, pair => pair.Value, EdgeTrafficResourceKey.Comparer) + : state.OccupiedEdgeTrafficCapacity; + var occupiedTranshipmentCapacity = copyStateBeforeAdvance + ? state.OccupiedTranshipmentCapacity.ToDictionary(pair => pair.Key, pair => pair.Value, Comparer) + : state.OccupiedTranshipmentCapacity; + // Pressure is a per-period derived metric, not a persisted simulation state variable. + // Each Advance(...) call starts with fresh accumulators and only records current-step adverse conditions. + var nodePressure = new Dictionary(Comparer); + var edgePressure = new Dictionary(Comparer); + var pressureEvents = new List(); + + ExpireNodeTraffic(nodeStates, nodePressure, pressureEvents, nextPeriod); + ExpireInFlightMovements(movements, occupiedEdgeCapacity, occupiedEdgeTrafficCapacity, occupiedTranshipmentCapacity, edgePressure, nodePressure, pressureEvents, nextPeriod); + + if (options.EnableInvariantValidation) + { + ValidateResourceOccupancy(edgeLookup, nodeLookup, occupiedEdgeCapacity, occupiedTranshipmentCapacity); + ValidateMovementResourceClaims(effectiveNetwork, edgeLookup, movements, occupiedEdgeCapacity, occupiedEdgeTrafficCapacity, occupiedTranshipmentCapacity); + } + + AddScheduledNodeChanges(effectiveNetwork, definitionsByTraffic, nodeStates, effectivePeriod); + + var availableResources = BuildAvailableResourceCapacity(effectiveNetwork, movements, occupiedEdgeCapacity, occupiedTranshipmentCapacity); + var plannedAllocations = PlanNewAllocations( + compiledContext, + nodeStates, + nextPeriod, + effectivePeriod, + availableResources.EdgeCapacityById, + availableResources.TranshipmentCapacityByNodeId, + occupiedEdgeTrafficCapacity); + + var edgeFlowById = new Dictionary(Comparer); + var nodeFlowById = new Dictionary(Comparer); + + var newlyStartedMovements = new List(); + + foreach (var allocation in plannedAllocations) + { + var perishabilityPeriods = GetPerishabilityPeriods(definitionsByTraffic, allocation.TrafficType); + + var movement = new TemporalInFlightMovement + { + TrafficType = allocation.TrafficType, + Quantity = allocation.Quantity, + PathNodeIds = allocation.PathNodeIds.ToList(), + PathNodeNames = allocation.PathNodeNames.ToList(), + PathEdgeIds = allocation.PathEdgeIds.ToList(), + SourceUnitCostPerUnit = allocation.SourceUnitCostPerUnit, + LandedUnitCostPerUnit = allocation.DeliveredCostPerUnit, + CurrentEdgeIndex = 0, + RemainingPeriodsOnCurrentEdge = GetEdgePeriods(edgeLookup[allocation.PathEdgeIds[0]]), + RemainingShelfLifePeriods = perishabilityPeriods + }; + + ClaimCurrentMovementResources(effectiveNetwork, edgeLookup, nodeLookup, movement, occupiedEdgeCapacity, occupiedEdgeTrafficCapacity, occupiedTranshipmentCapacity); + newlyStartedMovements.Add(movement); + } + + + + for (var movementIndex = movements.Count - 1; movementIndex >= 0; movementIndex--) + { + var movement = movements[movementIndex]; + if (movement.PathEdgeIds.Count == 0 || movement.CurrentEdgeIndex >= movement.PathEdgeIds.Count) + { + continue; + } + + if (movement.IsWaitingBetweenEdges) + { + if (!TryMoveMovementToNextEdge(effectiveNetwork, edgeLookup, nodeLookup, movement, occupiedEdgeCapacity, occupiedEdgeTrafficCapacity, occupiedTranshipmentCapacity)) + { + continue; + } + } + + var edgeId = movement.PathEdgeIds[movement.CurrentEdgeIndex]; + if (!edgeLookup.TryGetValue(edgeId, out var edge)) + { + continue; + } + + AddEdgeFlow(edgeFlowById, edge, movement); + AddNodeDeparture(nodeFlowById, movement.PathNodeIds[movement.CurrentEdgeIndex], movement.Quantity); + movement.RemainingPeriodsOnCurrentEdge -= 1; + + if (movement.RemainingPeriodsOnCurrentEdge > 0) + { + continue; + } + + var arrivalNodeId = movement.PathNodeIds[movement.CurrentEdgeIndex + 1]; + AddNodeArrival(nodeFlowById, arrivalNodeId, movement.Quantity); + + if (movement.CurrentEdgeIndex == movement.PathEdgeIds.Count - 1) + { + ReleaseCurrentMovementResources(movement, occupiedEdgeCapacity, occupiedEdgeTrafficCapacity, occupiedTranshipmentCapacity); + CompleteArrival(nodeStates, nodeLookup, definitionsByTraffic, movement); + movements.RemoveAt(movementIndex); + continue; + } + + TryMoveMovementToNextEdge(effectiveNetwork, edgeLookup, nodeLookup, movement, occupiedEdgeCapacity, occupiedEdgeTrafficCapacity, occupiedTranshipmentCapacity); + } + + movements.AddRange(newlyStartedMovements); + + movements.AddRange(newlyAllocatedMovements); + + if (options.EnableInvariantValidation) + { + ValidateResourceOccupancy(edgeLookup, nodeLookup, occupiedEdgeCapacity, occupiedTranshipmentCapacity); + ValidateMovementResourceClaims(effectiveNetwork, edgeLookup, movements, occupiedEdgeCapacity, occupiedEdgeTrafficCapacity, occupiedTranshipmentCapacity); + } + + var edgeOccupancySnapshot = SnapshotResourceOccupancy(occupiedEdgeCapacity); + var transhipmentOccupancySnapshot = SnapshotResourceOccupancy(occupiedTranshipmentCapacity); + ApplyCapacityPressure(effectiveNetwork, edgeOccupancySnapshot, transhipmentOccupancySnapshot, edgePressure, nodePressure, pressureEvents, nextPeriod); + + foreach (var pair in nodeStates) + { + if (pair.Value.DemandBacklog <= Epsilon) + { + continue; + } + + AddNodePressure(nodePressure, pair.Key.NodeId, pair.Key.TrafficType, PressureCauseKind.DemandBacklog, pair.Value.DemandBacklog, pressureEvents, nextPeriod); + } + + var allocationTargets = plannedAllocations + .Where(allocation => allocation.PathNodeIds.Count > 0) + .Select(allocation => allocation.PathNodeIds[^1]) + .ToHashSet(Comparer); + + foreach (var pair in nodeStates.Where(pair => pair.Value.DemandBacklog > Epsilon)) + { + if (allocationTargets.Contains(pair.Key.NodeId)) + { + continue; + } + + AddNodePressure(nodePressure, pair.Key.NodeId, pair.Key.TrafficType, PressureCauseKind.RouteUnavailable, pair.Value.DemandBacklog, pressureEvents, nextPeriod, weight: 1.2d); + } + + // Scores can decrease between periods when adverse causes shrink in later steps. + // Relief is currently modeled indirectly (fewer future causes), not via explicit negative deltas. + var nodePressureSnapshot = nodePressure.ToDictionary( + pair => pair.Key, + pair => pair.Value.ToNodeSnapshot(), + Comparer); + var edgePressureSnapshot = edgePressure.ToDictionary( + pair => pair.Key, + pair => pair.Value.ToEdgeSnapshot(), + Comparer); + + state.CurrentPeriod = nextPeriod; + if (copyStateBeforeAdvance) + { + state.NodeStates.Clear(); + foreach (var pair in nodeStates) + { + state.NodeStates[pair.Key] = pair.Value; + } + + state.InFlightMovements.Clear(); + state.InFlightMovements.AddRange(movements); + ReplacePositiveEntries(state.OccupiedEdgeCapacity, occupiedEdgeCapacity); + ReplacePositiveEntries(state.OccupiedEdgeTrafficCapacity, occupiedEdgeTrafficCapacity); + ReplacePositiveEntries(state.OccupiedTranshipmentCapacity, occupiedTranshipmentCapacity); + } + else + { + RemoveNonPositiveEntries(occupiedEdgeCapacity); + RemoveNonPositiveEntries(occupiedEdgeTrafficCapacity); + RemoveNonPositiveEntries(occupiedTranshipmentCapacity); + } + + var nodeSnapshots = nodeStates.ToDictionary( + pair => pair.Key, + pair => new TemporalNodeStateSnapshot(pair.Value.AvailableSupply, pair.Value.DemandBacklog, pair.Value.StoreInventory), + TemporalNodeTrafficKey.Comparer); + + clock.Advance(options.DeltaTime); + var settledAllocations = SettleAllocations(effectiveNetwork, plannedAllocations); + + return new TemporalSimulationStepResult( + nextPeriod, + settledAllocations, + edgeFlowById, + nodeFlowById, + nodeSnapshots, + edgeOccupancySnapshot, + transhipmentOccupancySnapshot, + effectivePeriod, + movements.Count, + nodePressureSnapshot, + edgePressureSnapshot, + pressureEvents); + } + + private IReadOnlyList SettleAllocations(NetworkModel network, IReadOnlyList allocations) + { + if (allocations.Count == 0) + { + return allocations; + } + + var outcomes = allocations + .GroupBy(allocation => allocation.TrafficType, Comparer) + .Select(group => + { + var definition = network.TrafficTypes.FirstOrDefault(candidate => Comparer.Equals(candidate.Name, group.Key)); + var trafficAllocations = group.ToList(); + return new TrafficSimulationOutcome + { + TrafficType = group.Key, + RoutingPreference = definition?.RoutingPreference ?? trafficAllocations[0].RoutingPreference, + AllocationMode = definition?.AllocationMode ?? trafficAllocations[0].AllocationMode, + TotalDelivered = trafficAllocations.Sum(allocation => allocation.Quantity), + Allocations = trafficAllocations + }; + }) + .ToList(); + + return settlementService + .Settle(network, outcomes) + .Outcomes + .SelectMany(outcome => outcome.Allocations) + .ToList(); + } + + private static void ExpireNodeTraffic( + IDictionary nodeStates, + IDictionary nodePressure, + ICollection pressureEvents, + int period) + { + foreach (var pair in nodeStates) + { + var delta = pair.Value.AdvancePerishability(); + if (delta.ExpiredAvailableSupply + delta.ExpiredStoreInventory <= Epsilon) + { + continue; + } + + AddNodePressure( + nodePressure, + pair.Key.NodeId, + pair.Key.TrafficType, + PressureCauseKind.PerishedInNodeInventory, + delta.ExpiredAvailableSupply + delta.ExpiredStoreInventory, + pressureEvents, + period, + weight: 1.4d); + } + } + + private static void ExpireInFlightMovements( + IList movements, + IDictionary occupiedEdgeCapacity, + IDictionary occupiedEdgeTrafficCapacity, + IDictionary occupiedTranshipmentCapacity, + IDictionary edgePressure, + IDictionary nodePressure, + ICollection pressureEvents, + int period) + { + for (var index = movements.Count - 1; index >= 0; index--) + { + var movement = movements[index]; + if (!movement.RemainingShelfLifePeriods.HasValue) + { + continue; + } + + movement.RemainingShelfLifePeriods -= 1; + if (movement.RemainingShelfLifePeriods.Value > 0) + { + continue; + } + + if (!movement.IsWaitingBetweenEdges) + { + ReleaseCurrentMovementResources(movement, occupiedEdgeCapacity, occupiedEdgeTrafficCapacity, occupiedTranshipmentCapacity); + if (movement.CurrentEdgeIndex >= 0 && movement.CurrentEdgeIndex < movement.PathEdgeIds.Count) + { + AddEdgePressure( + edgePressure, + movement.PathEdgeIds[movement.CurrentEdgeIndex], + movement.TrafficType, + PressureCauseKind.PerishedInTransit, + movement.Quantity, + pressureEvents, + period, + weight: 1.6d); + } + } + else if (movement.CurrentEdgeIndex + 1 < movement.PathNodeIds.Count) + { + AddNodePressure( + nodePressure, + movement.PathNodeIds[movement.CurrentEdgeIndex + 1], + movement.TrafficType, + PressureCauseKind.PerishedInTransit, + movement.Quantity, + pressureEvents, + period, + weight: 1.6d); + } + + movements.RemoveAt(index); + } + } + + private static void AddScheduledNodeChanges( + NetworkModel network, + IReadOnlyDictionary definitionsByTraffic, + IDictionary nodeStates, + int period) + { + var profilesByNodeAndTraffic = network.Nodes.ToDictionary( + node => node.Id, + node => node.TrafficProfiles.ToDictionary(profile => profile.TrafficType, profile => profile, Comparer), + Comparer); + + foreach (var node in network.Nodes) + { + foreach (var profile in node.TrafficProfiles) + { + var key = new TemporalNodeTrafficKey(node.Id, profile.TrafficType); + if (!nodeStates.TryGetValue(key, out var state)) + { + state = new TemporalNodeTrafficState(); + nodeStates[key] = state; + } + + if (profile.Production > Epsilon && IsProductionActive(profile, period)) + { + AddImplicitRecipeDemand( + node.Id, + profile, + nodeStates, + profilesByNodeAndTraffic.GetValueOrDefault(node.Id) ?? new Dictionary(Comparer)); + var production = CalculateAndConsumeProductionInputs( + node.Id, + profile, + definitionsByTraffic.GetValueOrDefault(profile.TrafficType), + nodeStates, + profilesByNodeAndTraffic.GetValueOrDefault(node.Id) ?? new Dictionary(Comparer)); + + state.BlendAvailableSupply( + production.OutputQuantity, + production.InheritedUnitCost, + GetPerishabilityPeriods(definitionsByTraffic, profile.TrafficType)); + } + + if (profile.Consumption > Epsilon && IsConsumptionActive(profile, period)) + { + if (profile.IsStore) + { + var consumedFromStore = Math.Min(state.StoreInventory, profile.Consumption); + state.ConsumeStoreInventory(consumedFromStore); + var unmetConsumption = profile.Consumption - consumedFromStore; + if (unmetConsumption > Epsilon) + { + state.DemandBacklog += unmetConsumption; + } + } + else + { + state.DemandBacklog += profile.Consumption; + } + } + } + } + } + + private static NetworkModel ApplyTimelineEventOverlay(NetworkModel network, int effectivePeriod) + { + var activeEvents = network.TimelineEvents + .Where(timelineEvent => IsTimelineEventActive(timelineEvent, effectivePeriod)) + .ToList(); + + if (activeEvents.Count == 0) + { + return network; + } + + var overlay = CloneNetworkForTimelineEvents(network); + foreach (var timelineEvent in activeEvents) + { + foreach (var effect in timelineEvent.Effects) + { + ApplyTimelineEventEffect(overlay, effect); + } + } + + return overlay; + } + + private static bool IsTimelineEventActive(TimelineEventModel timelineEvent, int effectivePeriod) + { + if (timelineEvent.StartPeriod.HasValue && effectivePeriod < timelineEvent.StartPeriod.Value) + { + return false; + } + + if (timelineEvent.EndPeriod.HasValue && effectivePeriod > timelineEvent.EndPeriod.Value) + { + return false; + } + + return true; + } + + private static void ApplyTimelineEventEffect(NetworkModel network, TimelineEventEffectModel effect) + { + switch (effect.EffectType) + { + case TimelineEventEffectType.ProductionMultiplier: + ApplyNodeTrafficMultiplier(network, effect, profile => profile.Production *= effect.Multiplier); + break; + + case TimelineEventEffectType.ConsumptionMultiplier: + ApplyNodeTrafficMultiplier(network, effect, profile => profile.Consumption *= effect.Multiplier); + break; + + case TimelineEventEffectType.RouteCostMultiplier: + ApplyEdgeMultiplier(network, effect); + break; + } + } + + private static void ApplyNodeTrafficMultiplier( + NetworkModel network, + TimelineEventEffectModel effect, + Action apply) + { + if (effect.NodeId is null || effect.TrafficType is null) + { + return; + } + + var node = network.Nodes.FirstOrDefault(candidate => Comparer.Equals(candidate.Id, effect.NodeId)); + var profile = node?.TrafficProfiles.FirstOrDefault(candidate => Comparer.Equals(candidate.TrafficType, effect.TrafficType)); + if (profile is not null) + { + apply(profile); + } + } + + private static void ApplyEdgeMultiplier(NetworkModel network, TimelineEventEffectModel effect) + { + if (effect.EdgeId is null) + { + return; + } + + var edge = network.Edges.FirstOrDefault(candidate => Comparer.Equals(candidate.Id, effect.EdgeId)); + if (edge is not null) + { + edge.Cost *= effect.Multiplier; + } + } + + private static double GetRequiredInputPerOutputUnit( + string nodeId, + string outputTrafficType, + ProductionInputRequirement requirement) + { + var inputQuantity = requirement.InputQuantity; + var outputQuantity = requirement.OutputQuantity; + + if (inputQuantity <= Epsilon && + requirement.QuantityPerOutputUnit.HasValue && + requirement.QuantityPerOutputUnit.Value > Epsilon) + { + inputQuantity = requirement.QuantityPerOutputUnit.Value; + outputQuantity = 1d; + } + + if (double.IsNaN(inputQuantity) || double.IsInfinity(inputQuantity) || inputQuantity <= Epsilon || + double.IsNaN(outputQuantity) || double.IsInfinity(outputQuantity) || outputQuantity <= Epsilon) + { + throw new InvalidOperationException( + $"Node '{nodeId}' has an invalid recipe ratio for output traffic '{outputTrafficType}' and input traffic '{requirement.TrafficType}'."); + } + + return inputQuantity / outputQuantity; + } + + private static NetworkModel CloneNetworkForTimelineEvents(NetworkModel network) + { + return new NetworkModel + { + Name = network.Name, + Description = network.Description, + TimelineLoopLength = network.TimelineLoopLength, + DefaultAllocationMode = network.DefaultAllocationMode, + SimulationSeed = network.SimulationSeed, + TrafficTypes = network.TrafficTypes.Select(CloneTrafficTypeDefinition).ToList(), + TimelineEvents = network.TimelineEvents, + RouteTaxRules = network.RouteTaxRules.Select(CloneRouteTaxRule).ToList(), + Nodes = network.Nodes.Select(CloneNode).ToList(), + Edges = network.Edges.Select(CloneEdge).ToList() + }; + } + + private static RouteTaxRule CloneRouteTaxRule(RouteTaxRule rule) + { + return new RouteTaxRule + { + EdgeId = rule.EdgeId, + TrafficType = rule.TrafficType, + TaxRate = rule.TaxRate, + TaxAuthorityActorId = rule.TaxAuthorityActorId, + IsActive = rule.IsActive + }; + } + + private static TrafficTypeDefinition CloneTrafficTypeDefinition(TrafficTypeDefinition definition) + { + return new TrafficTypeDefinition + { + Name = definition.Name, + Description = definition.Description, + RoutingPreference = definition.RoutingPreference, + AllocationMode = definition.AllocationMode, + RouteChoiceModel = definition.RouteChoiceModel, + FlowSplitPolicy = definition.FlowSplitPolicy, + RouteChoiceSettings = new RouteChoiceSettings + { + MaxCandidateRoutes = definition.RouteChoiceSettings.MaxCandidateRoutes, + Priority = definition.RouteChoiceSettings.Priority, + InformationAccuracy = definition.RouteChoiceSettings.InformationAccuracy, + RouteDiversity = definition.RouteChoiceSettings.RouteDiversity, + CongestionSensitivity = definition.RouteChoiceSettings.CongestionSensitivity, + RerouteThreshold = definition.RouteChoiceSettings.RerouteThreshold, + Stickiness = definition.RouteChoiceSettings.Stickiness, + IterationCount = definition.RouteChoiceSettings.IterationCount, + InternalizeCongestion = definition.RouteChoiceSettings.InternalizeCongestion + }, + CapacityBidPerUnit = definition.CapacityBidPerUnit, + DefaultUnitSalePrice = definition.DefaultUnitSalePrice, + DefaultUnitProductionCost = definition.DefaultUnitProductionCost, + SalesTaxRate = definition.SalesTaxRate, + RouteTaxRate = definition.RouteTaxRate, + PerishabilityPeriods = definition.PerishabilityPeriods + }; + } + + private static NodeModel CloneNode(NodeModel node) + { + return new NodeModel + { + Id = node.Id, + Name = node.Name, + Shape = node.Shape, + X = node.X, + Y = node.Y, + TranshipmentCapacity = node.TranshipmentCapacity, + PlaceType = node.PlaceType, + LoreDescription = node.LoreDescription, + Tags = node.Tags.ToList(), + TemplateId = node.TemplateId, + TrafficProfiles = node.TrafficProfiles.Select(CloneProfile).ToList() + }; + } + + private static NodeTrafficProfile CloneProfile(NodeTrafficProfile profile) + { + return new NodeTrafficProfile + { + TrafficType = profile.TrafficType, + Production = profile.Production, + Consumption = profile.Consumption, + ConsumerPremiumPerUnit = profile.ConsumerPremiumPerUnit, + CanTransship = profile.CanTransship, + ProductionStartPeriod = profile.ProductionStartPeriod, + ProductionEndPeriod = profile.ProductionEndPeriod, + ConsumptionStartPeriod = profile.ConsumptionStartPeriod, + ConsumptionEndPeriod = profile.ConsumptionEndPeriod, + ProductionWindows = profile.ProductionWindows.Select(CloneWindow).ToList(), + ConsumptionWindows = profile.ConsumptionWindows.Select(CloneWindow).ToList(), + InputRequirements = profile.InputRequirements.Select(CloneInputRequirement).ToList(), + IsStore = profile.IsStore, + StoreCapacity = profile.StoreCapacity, + UnitPrice = profile.UnitPrice, + ProductionCostPerUnit = profile.ProductionCostPerUnit, + SalesTaxRate = profile.SalesTaxRate, + HoldingCostPerTime = profile.HoldingCostPerTime, + Revenue = profile.Revenue, + Profit = profile.Profit, + ShortagePenalty = profile.ShortagePenalty + }; + } + + private static PeriodWindow CloneWindow(PeriodWindow window) + { + return new PeriodWindow + { + StartPeriod = window.StartPeriod, + EndPeriod = window.EndPeriod + }; + } + + private static ProductionInputRequirement CloneInputRequirement(ProductionInputRequirement requirement) + { + return new ProductionInputRequirement + { + TrafficType = requirement.TrafficType, + InputQuantity = requirement.InputQuantity, + OutputQuantity = requirement.OutputQuantity, + QuantityPerOutputUnit = requirement.QuantityPerOutputUnit + }; + } + + private static EdgeModel CloneEdge(EdgeModel edge) + { + return new EdgeModel + { + Id = edge.Id, + FromNodeId = edge.FromNodeId, + ToNodeId = edge.ToNodeId, + Time = edge.Time, + Cost = edge.Cost, + Capacity = edge.Capacity, + IsBidirectional = edge.IsBidirectional, + RouteType = edge.RouteType, + AccessNotes = edge.AccessNotes, + SeasonalRisk = edge.SeasonalRisk, + TollNotes = edge.TollNotes, + SecurityNotes = edge.SecurityNotes + }; + } + + private static List PlanNewAllocations( + CompiledNetworkSimulationContext compiledContext, + IDictionary nodeStates, + int period, + int effectivePeriod, + IReadOnlyDictionary availableCapacityByEdgeId, + IReadOnlyDictionary availableTranshipmentCapacityByNodeId, + IReadOnlyDictionary occupiedEdgeTrafficCapacity) + { + var network = compiledContext.EffectiveNetwork; + var definitionsByTraffic = compiledContext.TrafficDefinitionsByName; + var remainingCapacityByEdgeId = availableCapacityByEdgeId.ToDictionary(pair => pair.Key, pair => pair.Value, Comparer); + var remainingTranshipmentCapacityByNodeId = availableTranshipmentCapacityByNodeId.ToDictionary(pair => pair.Key, pair => pair.Value, Comparer); + var contexts = new List(compiledContext.OrderedTrafficNames.Length); + for (var index = 0; index < compiledContext.OrderedTrafficNames.Length; index++) + { + var trafficType = compiledContext.OrderedTrafficNames[index]; + definitionsByTraffic.TryGetValue(trafficType, out var definition); + contexts.Add(BuildTemporalContext( + compiledContext, + definition ?? new TrafficTypeDefinition { Name = trafficType, RoutingPreference = RoutingPreference.TotalCost }, + nodeStates, + period, + effectivePeriod, + network.SimulationSeed + (index * 997))); + } + + foreach (var context in contexts) + { + ApplyLocalAllocations(context, compiledContext, nodeStates); + } + + var routingContexts = new List(contexts.Count); + for (var index = 0; index < contexts.Count; index++) + { + routingContexts.Add(ToRoutingContext(contexts[index])); + } + + MixedRoutingAllocator.Allocate( + network, + routingContexts, + remainingCapacityByEdgeId, + remainingTranshipmentCapacityByNodeId, + occupiedEdgeTrafficByKey: occupiedEdgeTrafficCapacity, + period: period, + compiledContext: compiledContext); + for (var index = 0; index < contexts.Count; index++) + { + contexts[index].Allocations.AddRange(routingContexts[index].Allocations); + CopyCommittedQuantities(routingContexts[index].CommittedSupply, contexts[index].CommittedSupply); + CopyCommittedQuantities(routingContexts[index].CommittedDemand, contexts[index].CommittedDemand); + } + + foreach (var context in contexts) + { + ApplyCommittedState(context, nodeStates); + } + + return contexts.SelectMany(context => context.Allocations).ToList(); + } + + private static TemporalTrafficContext BuildTemporalContext( + CompiledNetworkSimulationContext compiledContext, + TrafficTypeDefinition definition, + IDictionary nodeStates, + int period, + int effectivePeriod, + int seed) + { + var network = compiledContext.EffectiveNetwork; + var profilesByNodeId = compiledContext.NodeProfilesByTrafficType.TryGetValue(definition.Name, out var profiles) + ? profiles + : FrozenDictionary.Empty; + var nodesById = compiledContext.NodesById; + var supply = new Dictionary(Comparer); + var supplyUnitCosts = new Dictionary(Comparer); + var demand = new Dictionary(Comparer); + var committedSupply = new Dictionary(Comparer); + var committedDemand = new Dictionary(Comparer); + var storeSupplyNodes = new HashSet(Comparer); + var storeDemandNodes = new HashSet(Comparer); + var recipeInputDemandNodes = new HashSet(Comparer); + + var permittedSellerNodeIds = compiledContext.PermittedSellerNodeIdsByTrafficType.TryGetValue(definition.Name, out var permittedSellers) + ? permittedSellers + : FrozenSet.Empty; + var enforceSellLocal = LocalTrafficPermissionResolver.IsEnforced(network); + + foreach (var node in compiledContext.NodesByIndex) + { + profilesByNodeId.TryGetValue(node.Id, out var profile); + var key = new TemporalNodeTrafficKey(node.Id, definition.Name); + nodeStates.TryGetValue(key, out var nodeState); + nodeState ??= new TemporalNodeTrafficState(); + + var availableSupply = nodeState.AvailableSupply; + if (profile?.IsStore == true && + profile.Production > Epsilon && + IsProductionActive(profile, effectivePeriod)) + { + availableSupply += Math.Min(nodeState.StoreInventory, profile.Production); + storeSupplyNodes.Add(node.Id); + } + + if (availableSupply > Epsilon && (!enforceSellLocal || permittedSellerNodeIds.Contains(node.Id))) + { + supply[node.Id] = availableSupply; + var supplyUnitCost = nodeState.AvailableSupplyUnitCostPerUnit; + if (profile?.IsStore == true && + profile.Production > Epsilon && + IsProductionActive(profile, effectivePeriod)) + { + var storeQuantity = Math.Min(nodeState.StoreInventory, profile.Production); + supplyUnitCost = CalculateBlendedUnitCost( + nodeState.AvailableSupply, + nodeState.AvailableSupplyUnitCostPerUnit, + storeQuantity, + nodeState.StoreInventoryUnitCostPerUnit); + } + + supplyUnitCosts[node.Id] = supplyUnitCost; + committedSupply[node.Id] = 0d; + } + + var availableDemand = 0d; + if (profile?.IsStore == true && + profile.Consumption > Epsilon && + IsConsumptionActive(profile, effectivePeriod)) + { + var spareCapacity = profile.StoreCapacity.HasValue + ? Math.Max(0d, profile.StoreCapacity.Value - nodeState.StoreInventory - nodeState.ReservedStoreReceipts) + : double.PositiveInfinity; + if (spareCapacity > Epsilon) + { + var targetDemand = profile.Consumption + nodeState.DemandBacklog; + availableDemand = Math.Min(targetDemand, spareCapacity); + storeDemandNodes.Add(node.Id); + } + } + else if (nodeState.DemandBacklog > Epsilon) + { + availableDemand = nodeState.DemandBacklog; + } + + if (availableDemand > Epsilon) + { + demand[node.Id] = availableDemand; + committedDemand[node.Id] = 0d; + if (IsRecipeInputTraffic(node, definition.Name)) + { + recipeInputDemandNodes.Add(node.Id); + } + } + } + + return new TemporalTrafficContext( + definition.Name, + definition.RoutingPreference, + definition.AllocationMode, + definition.RouteChoiceModel, + definition.FlowSplitPolicy, + definition.RouteChoiceSettings, + GetCapacityBidPerUnit(definition), + seed, + nodesById, + profilesByNodeId, + compiledContext.MeetingDemandEligibleNodeIdsByTrafficType.TryGetValue(definition.Name, out var eligibleNodeIds) + ? eligibleNodeIds + : FrozenSet.Empty, + supply, + supplyUnitCosts, + demand, + committedSupply, + committedDemand, + storeSupplyNodes, + storeDemandNodes, + recipeInputDemandNodes, + []); + } + + private static void ApplyLocalAllocations( + TemporalTrafficContext context, + CompiledNetworkSimulationContext compiledContext, + IDictionary nodeStates) + { + foreach (var nodeId in context.Supply.Keys.Intersect(context.Demand.Keys, Comparer).ToList()) + { + if (!context.MeetingDemandEligibleNodeIds.Contains(nodeId)) + { + continue; + } + + if (context.StoreSupplyNodes.Contains(nodeId) || + context.StoreDemandNodes.Contains(nodeId) || + context.RecipeInputDemandNodes.Contains(nodeId)) + { + continue; + } + + var quantity = Math.Min(context.Supply[nodeId], context.Demand[nodeId]); + if (quantity <= Epsilon) + { + continue; + } + + context.Supply[nodeId] -= quantity; + context.Demand[nodeId] -= quantity; + + var state = nodeStates[new TemporalNodeTrafficKey(nodeId, context.TrafficType)]; + state.ConsumeAvailableSupply(quantity); + state.DemandBacklog = Math.Max(0d, state.DemandBacklog - quantity); + } + } + + private static RoutingTrafficContext ToRoutingContext(TemporalTrafficContext context) + { + return new RoutingTrafficContext + { + TrafficType = context.TrafficType, + RoutingPreference = context.RoutingPreference, + AllocationMode = context.AllocationMode, + RouteChoiceModel = context.RouteChoiceModel, + FlowSplitPolicy = context.FlowSplitPolicy, + RouteChoiceSettings = context.RouteChoiceSettings, + CapacityBidPerUnit = context.CapacityBidPerUnit, + Seed = context.Seed, + NodesById = context.NodesById, + ProfilesByNodeId = context.ProfilesByNodeId, + Supply = context.Supply.ToDictionary(pair => pair.Key, pair => pair.Value, Comparer), + SupplyUnitCosts = context.SupplyUnitCosts.ToDictionary(pair => pair.Key, pair => pair.Value, Comparer), + Demand = context.Demand.ToDictionary(pair => pair.Key, pair => pair.Value, Comparer), + MeetingDemandEligibleNodeIds = context.MeetingDemandEligibleNodeIds + }; + } + + private static void CopyCommittedQuantities( + IReadOnlyDictionary source, + IDictionary target) + { + foreach (var pair in source) + { + target[pair.Key] = (target.TryGetValue(pair.Key, out var existing) ? existing : 0d) + pair.Value; + } + } + + private static void ReplacePositiveEntries( + IDictionary target, + IReadOnlyDictionary source) + where TKey : notnull + { + target.Clear(); + foreach (var pair in source) + { + if (pair.Value > Epsilon) + { + target[pair.Key] = pair.Value; + } + } + } + + private static void RemoveNonPositiveEntries(IDictionary values) + where TKey : notnull + { + var keysToRemove = new List(); + foreach (var pair in values) + { + if (pair.Value <= Epsilon) + { + keysToRemove.Add(pair.Key); + } + } + + for (var index = 0; index < keysToRemove.Count; index++) + { + values.Remove(keysToRemove[index]); + } + } + + private static void ApplyCommittedState( + TemporalTrafficContext context, + IDictionary nodeStates) + { + foreach (var pair in context.CommittedSupply) + { + if (pair.Value <= Epsilon) + { + continue; + } + + var state = nodeStates[new TemporalNodeTrafficKey(pair.Key, context.TrafficType)]; + if (context.StoreSupplyNodes.Contains(pair.Key)) + { + ConsumeLocalSupply(state, pair.Value, includeStoreInventory: true); + } + else + { + ConsumeLocalSupply(state, pair.Value, includeStoreInventory: false); + } + } + + foreach (var pair in context.CommittedDemand) + { + if (pair.Value <= Epsilon) + { + continue; + } + + var key = new TemporalNodeTrafficKey(pair.Key, context.TrafficType); + if (!nodeStates.TryGetValue(key, out var state)) + { + state = new TemporalNodeTrafficState(); + nodeStates[key] = state; + } + + if (context.StoreDemandNodes.Contains(pair.Key)) + { + state.ReservedStoreReceipts += pair.Value; + state.DemandBacklog = Math.Max(0d, state.DemandBacklog - pair.Value); + } + else + { + state.DemandBacklog = Math.Max(0d, state.DemandBacklog - pair.Value); + } + } + } + + private static void CompleteArrival( + IDictionary nodeStates, + IReadOnlyDictionary nodeLookup, + IReadOnlyDictionary definitionsByTraffic, + TemporalInFlightMovement movement) + { + var finalNodeId = movement.PathNodeIds[^1]; + var key = new TemporalNodeTrafficKey(finalNodeId, movement.TrafficType); + if (!nodeStates.TryGetValue(key, out var nodeState)) + { + nodeState = new TemporalNodeTrafficState(); + nodeStates[key] = nodeState; + } + + var profile = nodeLookup[finalNodeId].TrafficProfiles + .FirstOrDefault(candidate => Comparer.Equals(candidate.TrafficType, movement.TrafficType)); + + if (profile?.IsStore == true) + { + nodeState.BlendStoreInventory( + movement.Quantity, + movement.LandedUnitCostPerUnit, + movement.RemainingShelfLifePeriods); + nodeState.ReservedStoreReceipts = Math.Max(0d, nodeState.ReservedStoreReceipts - movement.Quantity); + return; + } + + if (IsRecipeInputTraffic(nodeLookup[finalNodeId], movement.TrafficType)) + { + nodeState.BlendAvailableSupply( + movement.Quantity, + movement.LandedUnitCostPerUnit, + movement.RemainingShelfLifePeriods); + } + } + + private static bool IsRecipeInputTraffic(NodeModel node, string trafficType) + { + return node.TrafficProfiles + .Where(profile => profile.Production > Epsilon) + .SelectMany(profile => profile.InputRequirements) + .Any(requirement => Comparer.Equals(requirement.TrafficType, trafficType)); + } + + private static ProductionResult CalculateAndConsumeProductionInputs( + string nodeId, + NodeTrafficProfile outputProfile, + TrafficTypeDefinition? definition, + IDictionary nodeStates, + IReadOnlyDictionary profilesByTrafficType) + { + var outputQuantity = outputProfile.Production; + var baseProductionCost = ResolveBaseProductionCost(outputProfile, definition); + if (outputProfile.InputRequirements.Count == 0) + { + return new ProductionResult(outputQuantity, baseProductionCost); + } + + foreach (var requirement in outputProfile.InputRequirements) + { + var inputPerOutputUnit = GetRequiredInputPerOutputUnit(nodeId, outputProfile.TrafficType, requirement); + var availableInput = GetLocalInputQuantity(nodeId, requirement.TrafficType, nodeStates, profilesByTrafficType); + outputQuantity = Math.Min(outputQuantity, availableInput / inputPerOutputUnit); + } + + if (outputQuantity < Epsilon) + { + return new ProductionResult(0d, 0d); + } + + var inheritedUnitCost = 0d; + foreach (var requirement in outputProfile.InputRequirements) + { + var inputPerOutputUnit = GetRequiredInputPerOutputUnit(nodeId, outputProfile.TrafficType, requirement); + var consumedUnitCost = ConsumeLocalInputQuantity( + nodeId, + requirement.TrafficType, + outputQuantity * inputPerOutputUnit, + nodeStates, + profilesByTrafficType); + + inheritedUnitCost += consumedUnitCost * inputPerOutputUnit; + } + + return new ProductionResult(outputQuantity, inheritedUnitCost + baseProductionCost); + } + + private static void AddImplicitRecipeDemand( + string nodeId, + NodeTrafficProfile outputProfile, + IDictionary nodeStates, + IReadOnlyDictionary profilesByTrafficType) + { + if (outputProfile.InputRequirements.Count == 0) + { + return; + } + + foreach (var requirement in outputProfile.InputRequirements) + { + var inputPerOutputUnit = GetRequiredInputPerOutputUnit(nodeId, outputProfile.TrafficType, requirement); + var requiredInput = outputProfile.Production * inputPerOutputUnit; + var availableInput = GetLocalInputQuantity(nodeId, requirement.TrafficType, nodeStates, profilesByTrafficType); + var unmetInput = Math.Max(0d, requiredInput - availableInput); + if (unmetInput <= Epsilon) + { + continue; + } + + var key = new TemporalNodeTrafficKey(nodeId, requirement.TrafficType); + if (!nodeStates.TryGetValue(key, out var inputState)) + { + inputState = new TemporalNodeTrafficState(); + nodeStates[key] = inputState; + } + + inputState.DemandBacklog += unmetInput; + } + } + + private static double GetLocalInputQuantity( + string nodeId, + string trafficType, + IDictionary nodeStates, + IReadOnlyDictionary profilesByTrafficType) + { + var key = new TemporalNodeTrafficKey(nodeId, trafficType); + nodeStates.TryGetValue(key, out var state); + var available = state?.AvailableSupply ?? 0d; + if (profilesByTrafficType.TryGetValue(trafficType, out var profile) && profile.IsStore) + { + available += state?.StoreInventory ?? 0d; + } + + return available; + } + + private static void ConsumeLocalSupply( + TemporalNodeTrafficState state, + double quantity, + bool includeStoreInventory) + { + var remaining = quantity; + var supplyConsumed = Math.Min(state.AvailableSupply, remaining); + state.ConsumeAvailableSupply(supplyConsumed); + remaining -= supplyConsumed; + + if (includeStoreInventory && remaining > Epsilon) + { + var storeConsumed = Math.Min(state.StoreInventory, remaining); + state.ConsumeStoreInventory(storeConsumed); + remaining -= storeConsumed; + } + + if (remaining > Epsilon) + { + throw new InvalidOperationException("Temporal supply commitment exceeded local supply."); + } + } + + private static double CalculateBlendedUnitCost( + double firstQuantity, + double firstUnitCost, + double secondQuantity, + double secondUnitCost) + { + var totalQuantity = Math.Max(0d, firstQuantity) + Math.Max(0d, secondQuantity); + if (totalQuantity <= Epsilon) + { + return 0d; + } + + return ((Math.Max(0d, firstQuantity) * firstUnitCost) + (Math.Max(0d, secondQuantity) * secondUnitCost)) / totalQuantity; + } + + private static double ConsumeLocalInputQuantity( + string nodeId, + string trafficType, + double quantity, + IDictionary nodeStates, + IReadOnlyDictionary profilesByTrafficType) + { + var key = new TemporalNodeTrafficKey(nodeId, trafficType); + if (!nodeStates.TryGetValue(key, out var state)) + { + throw new InvalidOperationException($"Node '{nodeId}' cannot consume missing precursor traffic '{trafficType}'."); + } + + var remaining = quantity; + var supplyConsumed = Math.Min(state.AvailableSupply, remaining); + var totalCost = supplyConsumed * state.ConsumeAvailableSupply(supplyConsumed); + remaining -= supplyConsumed; + + if (remaining > Epsilon && profilesByTrafficType.TryGetValue(trafficType, out var profile) && profile.IsStore) + { + var storeConsumed = Math.Min(state.StoreInventory, remaining); + totalCost += storeConsumed * state.ConsumeStoreInventory(storeConsumed); + remaining -= storeConsumed; + } + + if (remaining > Epsilon) + { + throw new InvalidOperationException($"Node '{nodeId}' would over-consume precursor traffic '{trafficType}'."); + } + + return quantity > Epsilon ? totalCost / quantity : 0d; + } + + private static AvailableResourceCapacity BuildAvailableResourceCapacity( + NetworkModel network, + IReadOnlyList movements, + IReadOnlyDictionary occupiedEdgeCapacity, + IReadOnlyDictionary occupiedTranshipmentCapacity) + { + var pendingEdgeClaims = new Dictionary(Comparer); + var pendingTranshipmentClaims = new Dictionary(Comparer); + + foreach (var movement in movements) + { + if (!movement.IsWaitingBetweenEdges && movement.RemainingPeriodsOnCurrentEdge > 1) + { + continue; + } + + var nextEdgeIndex = movement.CurrentEdgeIndex + 1; + if (nextEdgeIndex >= movement.PathEdgeIds.Count) + { + continue; + } + + var nextEdgeId = movement.PathEdgeIds[nextEdgeIndex]; + AddResourceQuantity(pendingEdgeClaims, nextEdgeId, movement.Quantity); + + var nextTranshipmentNodeId = GetTranshipmentNodeForEdgeIndex(movement, nextEdgeIndex); + if (nextTranshipmentNodeId is not null) + { + AddResourceQuantity(pendingTranshipmentClaims, nextTranshipmentNodeId, movement.Quantity); + } + } + + return new AvailableResourceCapacity( + network.Edges.ToDictionary( + edge => edge.Id, + edge => GetAvailableCapacity(edge.Capacity, edge.Id, occupiedEdgeCapacity, pendingEdgeClaims), + Comparer), + network.Nodes.ToDictionary( + node => node.Id, + node => GetAvailableCapacity(node.TranshipmentCapacity, node.Id, occupiedTranshipmentCapacity, pendingTranshipmentClaims), + Comparer)); + } + + private static double GetAvailableCapacity( + double? nominalCapacity, + string resourceId, + IReadOnlyDictionary occupiedCapacity, + IReadOnlyDictionary pendingClaims) + { + if (!nominalCapacity.HasValue) + { + return double.PositiveInfinity; + } + + var occupied = occupiedCapacity.TryGetValue(resourceId, out var occupiedValue) ? occupiedValue : 0d; + var pending = pendingClaims.TryGetValue(resourceId, out var pendingValue) ? pendingValue : 0d; + return Math.Max(0d, nominalCapacity.Value - occupied - pending); + } + + private static bool TryMoveMovementToNextEdge( + NetworkModel network, + IReadOnlyDictionary edgeLookup, + IReadOnlyDictionary nodeLookup, + TemporalInFlightMovement movement, + IDictionary occupiedEdgeCapacity, + IDictionary occupiedEdgeTrafficCapacity, + IDictionary occupiedTranshipmentCapacity) + { + var nextEdgeIndex = movement.CurrentEdgeIndex + 1; + if (nextEdgeIndex >= movement.PathEdgeIds.Count) + { + return false; + } + + var releasedEdgeId = movement.IsWaitingBetweenEdges ? null : movement.PathEdgeIds[movement.CurrentEdgeIndex]; + var releasedTranshipmentNodeId = movement.IsWaitingBetweenEdges ? null : GetCurrentTranshipmentNodeId(movement); + + if (!CanClaimMovementResourcesForEdgeIndex( + network, + edgeLookup, + nodeLookup, + movement, + nextEdgeIndex, + occupiedEdgeCapacity, + occupiedEdgeTrafficCapacity, + occupiedTranshipmentCapacity, + releasedEdgeId, + releasedTranshipmentNodeId)) + { + if (!movement.IsWaitingBetweenEdges) + { + ReleaseCurrentMovementResources(movement, occupiedEdgeCapacity, occupiedEdgeTrafficCapacity, occupiedTranshipmentCapacity); + movement.IsWaitingBetweenEdges = true; + movement.RemainingPeriodsOnCurrentEdge = 0; + } + + return false; + } + + if (!movement.IsWaitingBetweenEdges) + { + ReleaseCurrentMovementResources(movement, occupiedEdgeCapacity, occupiedEdgeTrafficCapacity, occupiedTranshipmentCapacity); + } + + movement.CurrentEdgeIndex = nextEdgeIndex; + movement.RemainingPeriodsOnCurrentEdge = GetEdgePeriods(edgeLookup[movement.PathEdgeIds[movement.CurrentEdgeIndex]]); + movement.IsWaitingBetweenEdges = false; + ClaimCurrentMovementResources(network, edgeLookup, nodeLookup, movement, occupiedEdgeCapacity, occupiedEdgeTrafficCapacity, occupiedTranshipmentCapacity); + return true; + } + + private static void ClaimCurrentMovementResources( + NetworkModel network, + IReadOnlyDictionary edgeLookup, + IReadOnlyDictionary nodeLookup, + TemporalInFlightMovement movement, + IDictionary occupiedEdgeCapacity, + IDictionary occupiedEdgeTrafficCapacity, + IDictionary occupiedTranshipmentCapacity) + { + if (movement.CurrentEdgeIndex < 0 || movement.CurrentEdgeIndex >= movement.PathEdgeIds.Count) + { + throw new InvalidOperationException("Cannot claim resources for a movement without a current edge."); + } + + var edgeId = movement.PathEdgeIds[movement.CurrentEdgeIndex]; + if (!edgeLookup.TryGetValue(edgeId, out var edge)) + { + throw new InvalidOperationException($"Movement references missing edge '{edgeId}'."); + } + + if (!CanClaimMovementResourcesForEdgeIndex( + network, + edgeLookup, + nodeLookup, + movement, + movement.CurrentEdgeIndex, + occupiedEdgeCapacity, + occupiedEdgeTrafficCapacity, + occupiedTranshipmentCapacity)) + { + throw new InvalidOperationException("Movement cannot claim current resources without exceeding capacity."); + } + + AddResourceQuantity(occupiedEdgeCapacity, edgeId, movement.Quantity); + AddResourceQuantity(occupiedEdgeTrafficCapacity, new EdgeTrafficResourceKey(edgeId, movement.TrafficType), movement.Quantity); + + var transhipmentNodeId = GetCurrentTranshipmentNodeId(movement); + if (transhipmentNodeId is not null) + { + AddResourceQuantity(occupiedTranshipmentCapacity, transhipmentNodeId, movement.Quantity); + } + } + + private static bool CanClaimMovementResourcesForEdgeIndex( + NetworkModel network, + IReadOnlyDictionary edgeLookup, + IReadOnlyDictionary nodeLookup, + TemporalInFlightMovement movement, + int edgeIndex, + IDictionary occupiedEdgeCapacity, + IDictionary occupiedEdgeTrafficCapacity, + IDictionary occupiedTranshipmentCapacity, + string? releasedEdgeId = null, + string? releasedTranshipmentNodeId = null) + { + if (edgeIndex < 0 || edgeIndex >= movement.PathEdgeIds.Count) + { + return false; + } + + var edgeId = movement.PathEdgeIds[edgeIndex]; + if (!edgeLookup.TryGetValue(edgeId, out var edge)) + { + return false; + } + + var releasedEdgeQuantity = Comparer.Equals(edgeId, releasedEdgeId) ? movement.Quantity : 0d; + if (!CanClaimResource(edge.Capacity, edgeId, movement.Quantity, occupiedEdgeCapacity, releasedEdgeQuantity)) + { + return false; + } + + if (!CanClaimResource( + GetEdgeTrafficCapacity(network, edge, movement.TrafficType), + new EdgeTrafficResourceKey(edgeId, movement.TrafficType), + movement.Quantity, + occupiedEdgeTrafficCapacity, + releasedEdgeQuantity)) + { + return false; + } + + var transhipmentNodeId = GetTranshipmentNodeForEdgeIndex(movement, edgeIndex); + if (transhipmentNodeId is null) + { + return true; + } + + if (!nodeLookup.TryGetValue(transhipmentNodeId, out var node)) + { + return false; + } + + var releasedTranshipmentQuantity = Comparer.Equals(transhipmentNodeId, releasedTranshipmentNodeId) ? movement.Quantity : 0d; + return CanClaimResource( + node.TranshipmentCapacity, + transhipmentNodeId, + movement.Quantity, + occupiedTranshipmentCapacity, + releasedTranshipmentQuantity); + } + + private static bool CanClaimResource( + double? nominalCapacity, + TKey resourceId, + double quantity, + IDictionary occupiedCapacity, + double releasedQuantity = 0d) + where TKey : notnull + { + if (!nominalCapacity.HasValue) + { + return true; + } + + var occupied = occupiedCapacity.TryGetValue(resourceId, out var occupiedValue) ? occupiedValue : 0d; + return Math.Max(0d, occupied - releasedQuantity) + quantity <= nominalCapacity.Value + Epsilon; + } + + private static void ReleaseCurrentMovementResources( + TemporalInFlightMovement movement, + IDictionary occupiedEdgeCapacity, + IDictionary occupiedEdgeTrafficCapacity, + IDictionary occupiedTranshipmentCapacity) + { + if (movement.CurrentEdgeIndex < 0 || movement.CurrentEdgeIndex >= movement.PathEdgeIds.Count) + { + return; + } + + ReleaseResourceQuantity(occupiedEdgeCapacity, movement.PathEdgeIds[movement.CurrentEdgeIndex], movement.Quantity); + ReleaseResourceQuantity( + occupiedEdgeTrafficCapacity, + new EdgeTrafficResourceKey(movement.PathEdgeIds[movement.CurrentEdgeIndex], movement.TrafficType), + movement.Quantity); + + var transhipmentNodeId = GetCurrentTranshipmentNodeId(movement); + if (transhipmentNodeId is not null) + { + ReleaseResourceQuantity(occupiedTranshipmentCapacity, transhipmentNodeId, movement.Quantity); + } + } + + private static string? GetCurrentTranshipmentNodeId(TemporalInFlightMovement movement) + { + return GetTranshipmentNodeForEdgeIndex(movement, movement.CurrentEdgeIndex); + } + + private static string? GetTranshipmentNodeForEdgeIndex(TemporalInFlightMovement movement, int edgeIndex) + { + if (edgeIndex < 0 || edgeIndex >= movement.PathEdgeIds.Count - 1 || edgeIndex + 1 >= movement.PathNodeIds.Count) + { + return null; + } + + return movement.PathNodeIds[edgeIndex + 1]; + } + + private static void AddResourceQuantity(IDictionary occupiedCapacity, TKey resourceId, double quantity) + where TKey : notnull + { + occupiedCapacity[resourceId] = (occupiedCapacity.TryGetValue(resourceId, out var existing) ? existing : 0d) + quantity; + } + + private static void ReleaseResourceQuantity(IDictionary occupiedCapacity, TKey resourceId, double quantity) + where TKey : notnull + { + if (!occupiedCapacity.TryGetValue(resourceId, out var existing)) + { + throw new InvalidOperationException($"Cannot release unclaimed resource '{resourceId}'."); + } + + var remaining = existing - quantity; + if (remaining < -Epsilon) + { + throw new InvalidOperationException($"Resource '{resourceId}' occupancy would become negative."); + } + + if (remaining <= Epsilon) + { + occupiedCapacity.Remove(resourceId); + return; + } + + occupiedCapacity[resourceId] = remaining; + } + + private static double? GetEdgeTrafficCapacity(NetworkModel network, EdgeModel edge, string trafficType) + { + var resolver = new EdgeTrafficPermissionResolver(); + var effective = resolver.Resolve(network, edge, trafficType); + var allowed = resolver.GetAllowedCapacity(edge, effective); + return double.IsPositiveInfinity(allowed) ? null : allowed; + } + + private static IReadOnlyDictionary SnapshotResourceOccupancy(IReadOnlyDictionary occupiedCapacity) + { + return occupiedCapacity + .Where(pair => pair.Value > Epsilon) + .ToDictionary(pair => pair.Key, pair => pair.Value, Comparer); + } + + private static void ValidateResourceOccupancy( + IReadOnlyDictionary edgeLookup, + IReadOnlyDictionary nodeLookup, + IEnumerable> occupiedEdgeCapacity, + IEnumerable> occupiedTranshipmentCapacity) + { + ValidateResourceOccupancy( + occupiedEdgeCapacity, + edgeLookup, + edge => edge.Capacity, + "edge"); + ValidateResourceOccupancy( + occupiedTranshipmentCapacity, + nodeLookup, + node => node.TranshipmentCapacity, + "transhipment node"); + } + + private static void ValidateResourceOccupancy( + IEnumerable> occupiedCapacity, + IReadOnlyDictionary resourcesById, + Func getCapacity, + string resourceKind) + { + foreach (var pair in occupiedCapacity) + { + if (!resourcesById.TryGetValue(pair.Key, out var resource)) + { + throw new InvalidOperationException($"Occupied {resourceKind} resource '{pair.Key}' does not exist."); + } + + var capacity = getCapacity(resource); + if (pair.Value < -Epsilon) + { + throw new InvalidOperationException($"Occupied {resourceKind} resource '{pair.Key}' cannot be negative."); + } + + if (capacity.HasValue && pair.Value > capacity.Value + Epsilon) + { + throw new InvalidOperationException( + $"Occupied {resourceKind} resource '{pair.Key}' exceeds capacity {capacity.Value} with occupancy {pair.Value}."); + } + } + } + + private static void ValidateMovementResourceClaims( + NetworkModel network, + IReadOnlyDictionary edgeLookup, + IReadOnlyList movements, + IReadOnlyDictionary occupiedEdgeCapacity, + IReadOnlyDictionary occupiedEdgeTrafficCapacity, + IReadOnlyDictionary occupiedTranshipmentCapacity) + { + var expectedEdgeCapacity = new Dictionary(Comparer); + var expectedEdgeTrafficCapacity = new Dictionary(EdgeTrafficResourceKey.Comparer); + var expectedTranshipmentCapacity = new Dictionary(Comparer); + + foreach (var movement in movements) + { + if (movement.IsWaitingBetweenEdges) + { + if (movement.CurrentEdgeIndex < 0 || movement.CurrentEdgeIndex >= movement.PathEdgeIds.Count - 1) + { + throw new InvalidOperationException("Waiting in-flight movement has no valid next edge."); + } + + if (movement.RemainingPeriodsOnCurrentEdge != 0) + { + throw new InvalidOperationException("Waiting in-flight movement cannot have remaining edge travel time."); + } + + continue; + } + + if (movement.CurrentEdgeIndex < 0 || movement.CurrentEdgeIndex >= movement.PathEdgeIds.Count) + { + throw new InvalidOperationException("In-flight movement has no valid current edge claim."); + } + + AddResourceQuantity(expectedEdgeCapacity, movement.PathEdgeIds[movement.CurrentEdgeIndex], movement.Quantity); + AddResourceQuantity( + expectedEdgeTrafficCapacity, + new EdgeTrafficResourceKey(movement.PathEdgeIds[movement.CurrentEdgeIndex], movement.TrafficType), + movement.Quantity); + + var transhipmentNodeId = GetCurrentTranshipmentNodeId(movement); + if (transhipmentNodeId is not null) + { + AddResourceQuantity(expectedTranshipmentCapacity, transhipmentNodeId, movement.Quantity); + } + } + + ValidateExpectedOccupancy(expectedEdgeCapacity, occupiedEdgeCapacity, "edge"); + ValidateExpectedOccupancy(expectedEdgeTrafficCapacity, occupiedEdgeTrafficCapacity, "edge traffic"); + ValidateExpectedOccupancy(expectedTranshipmentCapacity, occupiedTranshipmentCapacity, "transhipment node"); + ValidateEdgeTrafficOccupancy(network, edgeLookup, occupiedEdgeTrafficCapacity); + } + + private static void ValidateExpectedOccupancy( + IReadOnlyDictionary expectedCapacity, + IReadOnlyDictionary actualCapacity, + string resourceKind) + where TKey : notnull + { + foreach (var pair in expectedCapacity) + { + var actual = actualCapacity.TryGetValue(pair.Key, out var value) ? value : 0d; + if (Math.Abs(actual - pair.Value) > Epsilon) + { + throw new InvalidOperationException( + $"Occupied {resourceKind} resource '{pair.Key}' should be {pair.Value}, but was {actual}."); + } + } + + foreach (var pair in actualCapacity) + { + var expected = expectedCapacity.TryGetValue(pair.Key, out var value) ? value : 0d; + if (Math.Abs(expected - pair.Value) > Epsilon) + { + throw new InvalidOperationException( + $"Occupied {resourceKind} resource '{pair.Key}' is orphaned with occupancy {pair.Value}."); + } + } + } + + private static void ValidateEdgeTrafficOccupancy( + NetworkModel network, + IReadOnlyDictionary edgeLookup, + IReadOnlyDictionary occupiedEdgeTrafficCapacity) + { + foreach (var pair in occupiedEdgeTrafficCapacity) + { + if (!edgeLookup.TryGetValue(pair.Key.EdgeId, out var edge)) + { + throw new InvalidOperationException($"Occupied edge traffic resource '{pair.Key.EdgeId}/{pair.Key.TrafficType}' does not exist."); + } + + var allowed = GetEdgeTrafficCapacity(network, edge, pair.Key.TrafficType); + if (allowed.HasValue && pair.Value > allowed.Value + Epsilon) + { + throw new InvalidOperationException( + $"Occupied edge traffic resource '{pair.Key.EdgeId}/{pair.Key.TrafficType}' exceeds limit {allowed.Value} with occupancy {pair.Value}."); + } + } + } + + private static void AddEdgeFlow(IDictionary edgeFlowById, EdgeModel edge, TemporalInFlightMovement movement) + { + var existing = edgeFlowById.TryGetValue(edge.Id, out var summary) + ? summary + : EdgeFlowVisualSummary.Empty; + var pathFromNodeId = movement.PathNodeIds[movement.CurrentEdgeIndex]; + var pathToNodeId = movement.PathNodeIds[movement.CurrentEdgeIndex + 1]; + if (Comparer.Equals(pathFromNodeId, edge.FromNodeId) && Comparer.Equals(pathToNodeId, edge.ToNodeId)) + { + edgeFlowById[edge.Id] = existing with { ForwardQuantity = existing.ForwardQuantity + movement.Quantity }; + return; + } + + edgeFlowById[edge.Id] = existing with { ReverseQuantity = existing.ReverseQuantity + movement.Quantity }; + } + + private static void AddNodeDeparture(IDictionary nodeFlowById, string nodeId, double quantity) + { + var existing = nodeFlowById.TryGetValue(nodeId, out var summary) + ? summary + : NodeFlowVisualSummary.Empty; + nodeFlowById[nodeId] = existing with { OutboundQuantity = existing.OutboundQuantity + quantity }; + } + + private static void AddNodeArrival(IDictionary nodeFlowById, string nodeId, double quantity) + { + var existing = nodeFlowById.TryGetValue(nodeId, out var summary) + ? summary + : NodeFlowVisualSummary.Empty; + nodeFlowById[nodeId] = existing with { InboundQuantity = existing.InboundQuantity + quantity }; + } + + private static void ApplyCapacityPressure( + NetworkModel network, + IReadOnlyDictionary edgeOccupancy, + IReadOnlyDictionary transhipmentOccupancy, + IDictionary edgePressure, + IDictionary nodePressure, + ICollection pressureEvents, + int period) + { + foreach (var edge in network.Edges) + { + if (!edge.Capacity.HasValue || edge.Capacity.Value <= Epsilon) + { + continue; + } + + var occupancy = edgeOccupancy.GetValueOrDefault(edge.Id, 0d); + var utilization = occupancy / edge.Capacity.Value; + if (utilization < 0.95d || occupancy <= Epsilon) + { + continue; + } + + AddEdgePressure( + edgePressure, + edge.Id, + string.Empty, + PressureCauseKind.EdgeCapacitySaturation, + occupancy, + pressureEvents, + period); + } + + foreach (var node in network.Nodes) + { + if (!node.TranshipmentCapacity.HasValue || node.TranshipmentCapacity.Value <= Epsilon) + { + continue; + } + + var occupancy = transhipmentOccupancy.GetValueOrDefault(node.Id, 0d); + var utilization = occupancy / node.TranshipmentCapacity.Value; + if (utilization < 0.95d || occupancy <= Epsilon) + { + continue; + } + + AddNodePressure( + nodePressure, + node.Id, + string.Empty, + PressureCauseKind.TranshipmentCapacitySaturation, + occupancy, + pressureEvents, + period); + } + } + + private static void AddNodePressure( + IDictionary pressureByNodeId, + string nodeId, + string trafficType, + PressureCauseKind cause, + double quantity, + ICollection pressureEvents, + int period, + double weight = 1d) + { + if (quantity <= Epsilon || string.IsNullOrWhiteSpace(nodeId)) + { + return; + } + + if (!pressureByNodeId.TryGetValue(nodeId, out var accumulator)) + { + accumulator = new PressureAccumulator(); + pressureByNodeId[nodeId] = accumulator; + } + + accumulator.Add(cause, quantity, weight); + pressureEvents.Add(new PressureEvent(period, nodeId, IsEdge: false, trafficType, cause, quantity, quantity * weight, string.Empty)); + } + + private static void AddEdgePressure( + IDictionary pressureByEdgeId, + string edgeId, + string trafficType, + PressureCauseKind cause, + double quantity, + ICollection pressureEvents, + int period, + double weight = 1d) + { + if (quantity <= Epsilon || string.IsNullOrWhiteSpace(edgeId)) + { + return; + } + + if (!pressureByEdgeId.TryGetValue(edgeId, out var accumulator)) + { + accumulator = new PressureAccumulator(); + pressureByEdgeId[edgeId] = accumulator; + } + + accumulator.Add(cause, quantity, weight); + pressureEvents.Add(new PressureEvent(period, edgeId, IsEdge: true, trafficType, cause, quantity, quantity * weight, string.Empty)); + } + /// + /// Retrieves the effective period based on the provided parameters. + /// + + public static int GetEffectivePeriod(int absolutePeriod, int? loopLength) + { + if (!loopLength.HasValue || loopLength.Value < 1) + { + return absolutePeriod; + } + + return ((absolutePeriod - 1) % loopLength.Value) + 1; + } + + private static bool IsWithinAnyWindow(int period, IReadOnlyList windows) + { + if (windows.Count == 0) + { + return true; + } + + return windows.Any(window => IsWithinWindow(period, window)); + } + + private static bool IsProductionActive(NodeTrafficProfile profile, int period) + { + return IsWithinAnyWindow(period, profile.ProductionWindows, profile.ProductionStartPeriod, profile.ProductionEndPeriod); + } + + private static bool IsConsumptionActive(NodeTrafficProfile profile, int period) + { + return IsWithinAnyWindow(period, profile.ConsumptionWindows, profile.ConsumptionStartPeriod, profile.ConsumptionEndPeriod); + } + + private static bool IsWithinAnyWindow(int period, IReadOnlyList windows, int? legacyStartPeriod, int? legacyEndPeriod) + { + if (windows.Count > 0) + { + return IsWithinAnyWindow(period, windows); + } + + return IsWithinWindow(period, new PeriodWindow + { + StartPeriod = legacyStartPeriod, + EndPeriod = legacyEndPeriod + }); + } + + private static bool IsWithinWindow(int period, PeriodWindow window) + { + if (window.StartPeriod.HasValue && period < window.StartPeriod.Value) + { + return false; + } + + if (window.EndPeriod.HasValue && period > window.EndPeriod.Value) + { + return false; + } + + return true; + } + + private static int GetEdgePeriods(EdgeModel edge) + { + return Math.Max(1, (int)Math.Ceiling(edge.Time)); + } + + private static List BuildCandidateRoutes( + TemporalTrafficContext context, + IReadOnlyDictionary> adjacency, + IDictionary remainingCapacityByEdgeId, + IDictionary remainingTranshipmentCapacityByNodeId) + { + var routes = new List(); + + foreach (var producerNodeId in context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key)) + { + foreach (var consumerNodeId in context.Demand + .Where(pair => pair.Value > Epsilon) + .Select(pair => pair.Key) + .Where(nodeId => context.MeetingDemandEligibleNodeIds.Contains(nodeId))) + { + if (Comparer.Equals(producerNodeId, consumerNodeId)) + { + continue; + } + + var route = FindBestRoute( + context, + producerNodeId, + consumerNodeId, + adjacency, + remainingCapacityByEdgeId, + remainingTranshipmentCapacityByNodeId); + + if (route is not null) + { + routes.Add(route); + } + } + } + + return routes; + } + + private static RouteCandidate? FindBestRoute( + TemporalTrafficContext context, + string producerNodeId, + string consumerNodeId, + IReadOnlyDictionary> adjacency, + IDictionary remainingCapacityByEdgeId, + IDictionary remainingTranshipmentCapacityByNodeId) + { + var distances = new Dictionary(Comparer) { [producerNodeId] = 0d }; + var previous = new Dictionary(Comparer); + var queue = new PriorityQueue(); + queue.Enqueue(producerNodeId, 0d); + + while (queue.TryDequeue(out var currentNodeId, out var currentDistance)) + { + if (currentDistance > distances[currentNodeId] + Epsilon) + { + continue; + } + + if (Comparer.Equals(currentNodeId, consumerNodeId)) + { + break; + } + + if (!adjacency.TryGetValue(currentNodeId, out var arcs)) + { + continue; + } + + foreach (var arc in arcs) + { + if (!remainingCapacityByEdgeId.TryGetValue(arc.EdgeId, out var remainingCapacity) || remainingCapacity <= Epsilon) + { + continue; + } + + if (IsIntermediateNode(arc.ToNodeId, producerNodeId, consumerNodeId) && + remainingTranshipmentCapacityByNodeId.TryGetValue(arc.ToNodeId, out var remainingNodeCapacity) && + remainingNodeCapacity <= Epsilon) + { + continue; + } + + if (!CanTraverseNode(arc.ToNodeId, producerNodeId, consumerNodeId, context.ProfilesByNodeId)) + { + continue; + } + + var proposedDistance = currentDistance + Score(arc.Time, arc.Cost, context.RoutingPreference); + if (distances.TryGetValue(arc.ToNodeId, out var existingDistance) && + proposedDistance >= existingDistance - Epsilon) + { + continue; + } + + distances[arc.ToNodeId] = proposedDistance; + previous[arc.ToNodeId] = new PreviousStep(currentNodeId, arc); + queue.Enqueue(arc.ToNodeId, proposedDistance); + } + } + + if (!distances.ContainsKey(consumerNodeId)) + { + return null; + } + + var pathNodeIds = new List { consumerNodeId }; + var pathArcs = new List(); + var cursor = consumerNodeId; + + while (!Comparer.Equals(cursor, producerNodeId)) + { + var step = previous[cursor]; + pathNodeIds.Add(step.PreviousNodeId); + pathArcs.Add(step.Arc); + cursor = step.PreviousNodeId; + } + + pathNodeIds.Reverse(); + pathArcs.Reverse(); + var pathTranshipmentNodeIds = GetIntermediateNodeIds(pathNodeIds); + + return new RouteCandidate( + context, + producerNodeId, + consumerNodeId, + pathNodeIds, + pathArcs.Select(arc => arc.EdgeId).ToList(), + pathTranshipmentNodeIds, + pathArcs.Sum(arc => arc.Time), + pathArcs.Sum(arc => arc.Cost), + pathArcs.Sum(arc => Score(arc.Time, arc.Cost, context.RoutingPreference)), + GetCapacityBidPerUnit(context, consumerNodeId)); + } + + private static bool CanTraverseNode( + string nodeId, + string producerNodeId, + string consumerNodeId, + IReadOnlyDictionary profilesByNodeId) + { + if (Comparer.Equals(nodeId, producerNodeId) || Comparer.Equals(nodeId, consumerNodeId)) + { + return true; + } + + return profilesByNodeId.TryGetValue(nodeId, out var profile) && profile?.CanTransship == true; + } + + private static Dictionary> BuildAdjacency(NetworkModel network) + { + var adjacency = new Dictionary>(Comparer); + + void AddArc(string fromNodeId, string toNodeId, EdgeModel edge) + { + if (!adjacency.TryGetValue(fromNodeId, out var arcs)) + { + arcs = []; + adjacency[fromNodeId] = arcs; + } + + arcs.Add(new GraphArc(edge.Id, fromNodeId, toNodeId, edge.Time, edge.Cost)); + } + + foreach (var edge in network.Edges) + { + AddArc(edge.FromNodeId, edge.ToNodeId, edge); + if (edge.IsBidirectional) + { + AddArc(edge.ToNodeId, edge.FromNodeId, edge); + } + } + + return adjacency; + } + + private static bool IsIntermediateNode(string nodeId, string producerNodeId, string consumerNodeId) + { + return !Comparer.Equals(nodeId, producerNodeId) && !Comparer.Equals(nodeId, consumerNodeId); + } + + private static IReadOnlyList GetIntermediateNodeIds(IReadOnlyList pathNodeIds) + { + if (pathNodeIds.Count <= 2) + { + return []; + } + + var intermediateNodeIds = new List(pathNodeIds.Count - 2); + for (var index = 1; index < pathNodeIds.Count - 1; index++) + { + intermediateNodeIds.Add(pathNodeIds[index]); + } + + return intermediateNodeIds; + } + + private static double GetRouteRemainingCapacity( + IReadOnlyList pathEdgeIds, + IReadOnlyList pathTranshipmentNodeIds, + IDictionary remainingCapacityByEdgeId, + IDictionary remainingTranshipmentCapacityByNodeId) + { + var edgeCapacity = GetPathRemainingCapacity(pathEdgeIds, remainingCapacityByEdgeId); + var nodeCapacity = GetPathRemainingCapacity(pathTranshipmentNodeIds, remainingTranshipmentCapacityByNodeId); + return Math.Min(edgeCapacity, nodeCapacity); + } + + private static double GetPathRemainingCapacity(IReadOnlyList pathResourceIds, IDictionary remainingCapacityById) + { + if (pathResourceIds.Count == 0) + { + return double.PositiveInfinity; + } + + return pathResourceIds + .Select(resourceId => remainingCapacityById.TryGetValue(resourceId, out var remainingCapacity) ? remainingCapacity : 0d) + .DefaultIfEmpty(0d) + .Min(); + } + + private static double CalculateBidCostPerUnit( + IReadOnlyList pathEdgeIds, + IReadOnlyList pathTranshipmentNodeIds, + IDictionary remainingCapacityByEdgeId, + IDictionary remainingTranshipmentCapacityByNodeId, + double capacityBidPerUnit, + double quantity, + double routeCapacity) + { + if (capacityBidPerUnit <= Epsilon || + quantity <= Epsilon || + double.IsPositiveInfinity(routeCapacity) || + quantity < routeCapacity - Epsilon) + { + return 0d; + } + + var bottleneckResourceCount = + CountBottleneckResources(pathEdgeIds, remainingCapacityByEdgeId, routeCapacity) + + CountBottleneckResources(pathTranshipmentNodeIds, remainingTranshipmentCapacityByNodeId, routeCapacity); + + return bottleneckResourceCount * capacityBidPerUnit; + } + + private static int CountBottleneckResources( + IEnumerable pathResourceIds, + IDictionary remainingCapacityById, + double routeCapacity) + { + return pathResourceIds.Count(resourceId => + remainingCapacityById.TryGetValue(resourceId, out var remainingCapacity) && + !double.IsPositiveInfinity(remainingCapacity) && + remainingCapacity <= routeCapacity + Epsilon); + } + + private static void ReserveCapacity( + IEnumerable pathEdgeIds, + IEnumerable pathTranshipmentNodeIds, + IDictionary remainingCapacityByEdgeId, + IDictionary remainingTranshipmentCapacityByNodeId, + double quantity) + { + ReserveCapacity(pathEdgeIds, remainingCapacityByEdgeId, quantity); + ReserveCapacity(pathTranshipmentNodeIds, remainingTranshipmentCapacityByNodeId, quantity); + } + + private static void ReserveCapacity(IEnumerable pathResourceIds, IDictionary remainingCapacityById, double quantity) + { + foreach (var resourceId in pathResourceIds) + { + if (!remainingCapacityById.TryGetValue(resourceId, out var remainingCapacity) || + double.IsPositiveInfinity(remainingCapacity)) + { + continue; + } + + remainingCapacityById[resourceId] = Math.Max(0d, remainingCapacity - quantity); + } + } + + private static List GetOrderedTrafficNames(NetworkModel network) + { + var definitionsWithOrder = network.TrafficTypes + .Select((definition, index) => new { Definition = definition, Index = index }) + .Where(item => !string.IsNullOrWhiteSpace(item.Definition.Name)) + .GroupBy(item => item.Definition.Name, Comparer) + .Select(group => group.First()) + .OrderBy(item => item.Definition.PerishabilityPeriods.HasValue ? 0 : 1) + .ThenBy(item => item.Definition.PerishabilityPeriods ?? int.MaxValue) + .ThenByDescending(item => item.Definition.RouteChoiceSettings?.Priority ?? 0d) + .ThenBy(item => item.Index) + .Select(item => item.Definition.Name) + .ToList(); + + var seen = new HashSet(definitionsWithOrder, Comparer); + + var undeclaredTrafficNames = network.Nodes + .SelectMany(node => node.TrafficProfiles) + .Select(profile => profile.TrafficType) + .Concat(network.Nodes + .SelectMany(node => node.TrafficProfiles) + .SelectMany(profile => profile.InputRequirements) + .Select(requirement => requirement.TrafficType)) + .Where(name => !string.IsNullOrWhiteSpace(name) && !seen.Contains(name)) + .Distinct(Comparer) + .OrderBy(name => name, Comparer); + + definitionsWithOrder.AddRange(undeclaredTrafficNames); + return definitionsWithOrder; + } + + private static int? GetPerishabilityPeriods( + IReadOnlyDictionary definitionsByTraffic, + string trafficType) + { + if (!definitionsByTraffic.TryGetValue(trafficType, out var definition)) + { + return null; + } + + if (!definition.PerishabilityPeriods.HasValue || definition.PerishabilityPeriods.Value <= 0) + { + return null; + } + + return definition.PerishabilityPeriods.Value; + } + + private static double GetCapacityBidPerUnit(TrafficTypeDefinition definition) + { + var baseBid = definition.CapacityBidPerUnit.HasValue + ? Math.Max(0d, definition.CapacityBidPerUnit.Value) + : definition.RoutingPreference == RoutingPreference.Speed + ? 1d + : 0d; + + var perishabilityBonus = definition.PerishabilityPeriods.HasValue && definition.PerishabilityPeriods.Value > 0 + ? PerishabilityPriorityBidFactor / definition.PerishabilityPeriods.Value + : 0d; + + return baseBid + perishabilityBonus; + } + + private static double ResolveBaseProductionCost(NodeTrafficProfile? profile, TrafficTypeDefinition? definition) + { + if (profile?.ProductionCostPerUnit is { } profileCost) + { + return Math.Max(0d, profileCost); + } + + return Math.Max(0d, definition?.DefaultUnitProductionCost ?? 0d); + } + + private static double GetCapacityBidPerUnit(TemporalTrafficContext context, string consumerNodeId) + { + var baseBid = context.CapacityBidPerUnit; + var consumerPremium = context.ProfilesByNodeId.TryGetValue(consumerNodeId, out var profile) + ? Math.Max(0d, profile?.ConsumerPremiumPerUnit ?? 0d) + : 0d; + return baseBid + consumerPremium; + } + + private static double Score(double time, double cost, RoutingPreference routingPreference) + { + return routingPreference switch + { + RoutingPreference.Speed => time, + RoutingPreference.Cost => cost, + _ => time + cost + }; + } + /// + /// Represents the temporal simulation state component. + /// + + public sealed class TemporalSimulationState + { + /// + /// Gets or sets the current period. + /// + public int CurrentPeriod { get; set; } + /// + /// Gets or sets the node states. + /// + + public Dictionary NodeStates { get; } = new(TemporalNodeTrafficKey.Comparer); + /// + /// Gets the collection of in flight movements associated with this entity. + /// + + public List InFlightMovements { get; } = []; + /// + /// Gets or sets the occupied edge capacity. + /// + + public Dictionary OccupiedEdgeCapacity { get; } = new(Comparer); + /// + /// Gets or sets the occupied edge traffic capacity. + /// + + public Dictionary OccupiedEdgeTrafficCapacity { get; } = new(EdgeTrafficResourceKey.Comparer); + /// + /// Gets or sets the occupied transhipment capacity. + /// + + public Dictionary OccupiedTranshipmentCapacity { get; } = new(Comparer); + /// + /// Retrieves the or create node traffic state based on the provided parameters. + /// + + public TemporalNodeTrafficState GetOrCreateNodeTrafficState(string nodeId, string trafficType) + { + var key = new TemporalNodeTrafficKey(nodeId, trafficType); + if (!NodeStates.TryGetValue(key, out var state)) + { + state = new TemporalNodeTrafficState(); + NodeStates[key] = state; + } + + return state; + } + } + /// + /// Represents the temporal simulation step result component. + /// + + public sealed record TemporalSimulationStepResult( + int Period, + IReadOnlyList Allocations, + IReadOnlyDictionary EdgeFlows, + IReadOnlyDictionary NodeFlows, + IReadOnlyDictionary NodeStates, + IReadOnlyDictionary EdgeOccupancy, + IReadOnlyDictionary TranshipmentOccupancy, + int EffectivePeriod, + int InFlightMovementCount, + IReadOnlyDictionary NodePressureById, + IReadOnlyDictionary EdgePressureById, + IReadOnlyList PressureEvents); + /// + /// Represents the temporal node state snapshot component. + /// + + public readonly record struct TemporalNodeStateSnapshot(double AvailableSupply, double DemandBacklog, double StoreInventory); + /// + /// Represents the temporal node traffic key component. + /// + + public readonly record struct TemporalNodeTrafficKey(string NodeId, string TrafficType) + { + /// + /// Gets or sets the comparer. + /// + public static IEqualityComparer Comparer { get; } = new TemporalNodeTrafficKeyComparer(); + /// + /// Represents the temporal node traffic key comparer component. + /// + + private sealed class TemporalNodeTrafficKeyComparer : IEqualityComparer + { + public bool Equals(TemporalNodeTrafficKey x, TemporalNodeTrafficKey y) + { + return string.Equals(x.NodeId, y.NodeId, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.TrafficType, y.TrafficType, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode(TemporalNodeTrafficKey obj) + { + return HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.NodeId), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.TrafficType)); + } + } + } + /// + /// Represents the temporal node traffic state component. + /// + + public sealed class TemporalNodeTrafficState + { + private readonly List availableSupplyBatches = []; + private readonly List storeInventoryBatches = []; + /// + /// Gets or sets the available supply. + /// + + public double AvailableSupply => availableSupplyBatches.Sum(batch => batch.Quantity); + /// + /// Gets or sets the available supply unit cost per unit. + /// + + public double AvailableSupplyUnitCostPerUnit => GetWeightedUnitCost(availableSupplyBatches); + /// + /// Gets or sets the demand backlog. + /// + + public double DemandBacklog { get; set; } + /// + /// Gets or sets the store inventory. + /// + + public double StoreInventory => storeInventoryBatches.Sum(batch => batch.Quantity); + /// + /// Gets or sets the store inventory unit cost per unit. + /// + + public double StoreInventoryUnitCostPerUnit => GetWeightedUnitCost(storeInventoryBatches); + /// + /// Gets or sets the reserved store receipts. + /// + + public double ReservedStoreReceipts { get; set; } + /// + /// Executes the blend available supply operation. + /// + + public void BlendAvailableSupply(double quantity, double unitCost, int? remainingLifePeriods = null) + { + AddBatch(availableSupplyBatches, quantity, unitCost, remainingLifePeriods); + } + /// + /// Executes the blend store inventory operation. + /// + + public void BlendStoreInventory(double quantity, double unitCost, int? remainingLifePeriods = null) + { + AddBatch(storeInventoryBatches, quantity, unitCost, remainingLifePeriods); + } + /// + /// Executes the consume available supply operation. + /// + + public double ConsumeAvailableSupply(double quantity) + { + return ConsumeFromBatches(availableSupplyBatches, quantity); + } + /// + /// Executes the consume store inventory operation. + /// + + public double ConsumeStoreInventory(double quantity) + { + return ConsumeFromBatches(storeInventoryBatches, quantity); + } + /// + /// Executes the advance perishability operation. + /// + + public TemporalPerishabilityDelta AdvancePerishability() + { + var expiredAvailable = AdvancePerishability(availableSupplyBatches); + var expiredStore = AdvancePerishability(storeInventoryBatches); + ReservedStoreReceipts = Math.Max(0d, Math.Min(ReservedStoreReceipts, StoreInventory)); + return new TemporalPerishabilityDelta(expiredAvailable, expiredStore); + } + /// + /// Executes the clone operation. + /// + + public TemporalNodeTrafficState Clone() + { + var clone = new TemporalNodeTrafficState + { + DemandBacklog = DemandBacklog, + ReservedStoreReceipts = ReservedStoreReceipts + }; + + clone.availableSupplyBatches.AddRange(availableSupplyBatches.Select(batch => batch.Clone())); + clone.storeInventoryBatches.AddRange(storeInventoryBatches.Select(batch => batch.Clone())); + return clone; + } + + private static void AddBatch( + ICollection batches, + double quantity, + double unitCost, + int? remainingLifePeriods) + { + if (quantity <= Epsilon) + { + return; + } + + if (remainingLifePeriods.HasValue && remainingLifePeriods.Value <= 0) + { + return; + } + + batches.Add(new TemporalQuantityBatch + { + Quantity = quantity, + UnitCost = unitCost, + RemainingLifePeriods = remainingLifePeriods + }); + } + + private static double ConsumeFromBatches(List batches, double quantity) + { + if (quantity <= Epsilon) + { + return GetWeightedUnitCost(batches); + } + + var ordered = batches + .OrderBy(batch => batch.RemainingLifePeriods ?? int.MaxValue) + .ThenBy(batch => batch.Sequence) + .ToList(); + + var remaining = quantity; + var totalCost = 0d; + + foreach (var batch in ordered) + { + if (remaining <= Epsilon) + { + break; + } + + var consumed = Math.Min(batch.Quantity, remaining); + if (consumed <= Epsilon) + { + continue; + } + + batch.Quantity -= consumed; + remaining -= consumed; + totalCost += consumed * batch.UnitCost; + } + + batches.RemoveAll(batch => batch.Quantity <= Epsilon); + + if (remaining > Epsilon) + { + throw new InvalidOperationException("Attempted to consume more traffic than was available."); + } + + return quantity > Epsilon ? totalCost / quantity : 0d; + } + + private static double AdvancePerishability(List batches) + { + var expired = 0d; + foreach (var batch in batches) + { + if (batch.RemainingLifePeriods.HasValue) + { + batch.RemainingLifePeriods -= 1; + if (batch.RemainingLifePeriods.Value <= 0 && batch.Quantity > Epsilon) + { + expired += batch.Quantity; + } + } + } + + batches.RemoveAll(batch => + batch.Quantity <= Epsilon || + (batch.RemainingLifePeriods.HasValue && batch.RemainingLifePeriods.Value <= 0)); + return expired; + } + + private static double GetWeightedUnitCost(IEnumerable batches) + { + var quantity = 0d; + var cost = 0d; + + foreach (var batch in batches) + { + if (batch.Quantity <= Epsilon) + { + continue; + } + + quantity += batch.Quantity; + cost += batch.Quantity * batch.UnitCost; + } + + return quantity <= Epsilon ? 0d : cost / quantity; + } + } + /// + /// Represents the temporal quantity batch component. + /// + + private sealed class TemporalQuantityBatch + { + private static long nextSequence; + + public TemporalQuantityBatch() + { + Sequence = Interlocked.Increment(ref nextSequence); + } + /// + /// Gets or sets the sequence. + /// + + public long Sequence { get; } + /// + /// Gets or sets the quantity. + /// + + public double Quantity { get; set; } + /// + /// Gets or sets the unit cost. + /// + + public double UnitCost { get; set; } + /// + /// Gets or sets the remaining life periods. + /// + + public int? RemainingLifePeriods { get; set; } + /// + /// Executes the clone operation. + /// + + public TemporalQuantityBatch Clone() + { + return new TemporalQuantityBatch + { + Quantity = Quantity, + UnitCost = UnitCost, + RemainingLifePeriods = RemainingLifePeriods + }; + } + } + /// + /// Represents the temporal in flight movement component. + /// + + public sealed class TemporalInFlightMovement + { + /// + /// Gets or sets the traffic type. + /// + public string TrafficType { get; init; } = string.Empty; + /// + /// Gets or sets the quantity. + /// + + public double Quantity { get; init; } + /// + /// Gets or sets the source unit cost per unit. + /// + + public double SourceUnitCostPerUnit { get; init; } + /// + /// Gets or sets the landed unit cost per unit. + /// + + public double LandedUnitCostPerUnit { get; init; } + /// + /// Gets the collection of path node ids associated with this entity. + /// + + public List PathNodeIds { get; init; } = []; + /// + /// Gets the collection of path node names associated with this entity. + /// + + public List PathNodeNames { get; init; } = []; + /// + /// Gets the collection of path edge ids associated with this entity. + /// + + public List PathEdgeIds { get; init; } = []; + /// + /// Gets or sets the current edge index. + /// + + public int CurrentEdgeIndex { get; set; } + /// + /// Gets or sets the remaining periods on current edge. + /// + + public int RemainingPeriodsOnCurrentEdge { get; set; } + /// + /// Gets a value indicating whether is waiting between edges is enabled or active. + /// + + public bool IsWaitingBetweenEdges { get; set; } + /// + /// Gets or sets the remaining shelf life periods. + /// + + public int? RemainingShelfLifePeriods { get; set; } + /// + /// Executes the clone operation. + /// + + public TemporalInFlightMovement Clone() + { + return new TemporalInFlightMovement + { + TrafficType = TrafficType, + Quantity = Quantity, + SourceUnitCostPerUnit = SourceUnitCostPerUnit, + LandedUnitCostPerUnit = LandedUnitCostPerUnit, + PathNodeIds = PathNodeIds.ToList(), + PathNodeNames = PathNodeNames.ToList(), + PathEdgeIds = PathEdgeIds.ToList(), + CurrentEdgeIndex = CurrentEdgeIndex, + RemainingPeriodsOnCurrentEdge = RemainingPeriodsOnCurrentEdge, + IsWaitingBetweenEdges = IsWaitingBetweenEdges, + RemainingShelfLifePeriods = RemainingShelfLifePeriods + }; + } + } + /// + /// Represents the edge flow visual summary component. + /// + + public readonly record struct EdgeFlowVisualSummary(double ForwardQuantity, double ReverseQuantity) + { + /// + /// Gets or sets the empty. + /// + public static EdgeFlowVisualSummary Empty => new(0d, 0d); + } + /// + /// Represents the node flow visual summary component. + /// + + public readonly record struct NodeFlowVisualSummary(double OutboundQuantity, double InboundQuantity) + { + /// + /// Gets or sets the empty. + /// + public static NodeFlowVisualSummary Empty => new(0d, 0d); + } + /// + /// Specifies the pressure cause kind. + /// + + public enum PressureCauseKind + { + DemandBacklog, + InputShortage, + StoreCapacitySaturation, + EdgeCapacitySaturation, + TranshipmentCapacitySaturation, + RouteUnavailable, + PerishedInNodeInventory, + PerishedInTransit, + TimelineShock + } + /// + /// Represents the pressure event component. + /// + + public readonly record struct PressureEvent( + int Period, + string EntityId, + bool IsEdge, + string TrafficType, + PressureCauseKind Cause, + double Quantity, + double WeightedImpact, + string Detail); + /// + /// Represents the node pressure snapshot component. + /// + + public readonly record struct NodePressureSnapshot( + double Score, + double BacklogQuantity, + double ExpiredQuantity, + IReadOnlyDictionary CauseWeights, + string TopCause); + /// + /// Represents the edge pressure snapshot component. + /// + + public readonly record struct EdgePressureSnapshot( + double Score, + double BlockedQuantity, + double ExpiredInTransitQuantity, + double Utilization, + IReadOnlyDictionary CauseWeights, + string TopCause); + /// + /// Represents the temporal perishability delta component. + /// + + public readonly record struct TemporalPerishabilityDelta(double ExpiredAvailableSupply, double ExpiredStoreInventory); + /// + /// Represents the pressure accumulator component. + /// + + private sealed class PressureAccumulator + { + private readonly Dictionary weightedByCause = []; + /// + /// Executes the add operation. + /// + + public void Add(PressureCauseKind cause, double quantity, double weight) + { + if (quantity <= Epsilon || weight <= 0d) + { + return; + } + + weightedByCause[cause] = weightedByCause.GetValueOrDefault(cause, 0d) + (quantity * weight); + } + /// + /// Executes the to node snapshot operation. + /// + + public NodePressureSnapshot ToNodeSnapshot() + { + var score = weightedByCause.Sum(pair => pair.Value); + var backlogQuantity = weightedByCause.GetValueOrDefault(PressureCauseKind.DemandBacklog, 0d); + var expiredQuantity = + weightedByCause.GetValueOrDefault(PressureCauseKind.PerishedInNodeInventory, 0d) + + weightedByCause.GetValueOrDefault(PressureCauseKind.PerishedInTransit, 0d); + var topCause = weightedByCause.Count == 0 + ? string.Empty + : weightedByCause.MaxBy(pair => pair.Value).Key.ToString(); + return new NodePressureSnapshot(score, backlogQuantity, expiredQuantity, weightedByCause, topCause); + } + /// + /// Executes the to edge snapshot operation. + /// + + public EdgePressureSnapshot ToEdgeSnapshot() + { + var score = weightedByCause.Sum(pair => pair.Value); + var blockedQuantity = weightedByCause.GetValueOrDefault(PressureCauseKind.EdgeCapacitySaturation, 0d); + var expiredInTransitQuantity = weightedByCause.GetValueOrDefault(PressureCauseKind.PerishedInTransit, 0d); + var topCause = weightedByCause.Count == 0 + ? string.Empty + : weightedByCause.MaxBy(pair => pair.Value).Key.ToString(); + return new EdgePressureSnapshot(score, blockedQuantity, expiredInTransitQuantity, Utilization: 0d, weightedByCause, topCause); + } + } + /// + /// Represents the graph arc component. + /// + + private sealed record GraphArc(string EdgeId, string FromNodeId, string ToNodeId, double Time, double Cost); + /// + /// Represents the previous step component. + /// + + private sealed record PreviousStep(string PreviousNodeId, GraphArc Arc); + /// + /// Represents the available resource capacity component. + /// + + private sealed record AvailableResourceCapacity( + IReadOnlyDictionary EdgeCapacityById, + IReadOnlyDictionary TranshipmentCapacityByNodeId); + /// + /// Represents the production result component. + /// + + private readonly record struct ProductionResult(double OutputQuantity, double InheritedUnitCost); + /// + /// Represents the route candidate component. + /// + + private sealed record RouteCandidate( + TemporalTrafficContext Context, + string ProducerNodeId, + string ConsumerNodeId, + IReadOnlyList PathNodeIds, + IReadOnlyList PathEdgeIds, + IReadOnlyList PathTranshipmentNodeIds, + double TotalTime, + double TransitCostPerUnit, + double TotalScore, + double CapacityBidPerUnit); + /// + /// Represents the temporal traffic context component. + /// + + private sealed record TemporalTrafficContext( + string TrafficType, + RoutingPreference RoutingPreference, + AllocationMode AllocationMode, + RouteChoiceModel RouteChoiceModel, + FlowSplitPolicy FlowSplitPolicy, + RouteChoiceSettings RouteChoiceSettings, + double CapacityBidPerUnit, + int Seed, + IReadOnlyDictionary NodesById, + IReadOnlyDictionary ProfilesByNodeId, + IReadOnlySet MeetingDemandEligibleNodeIds, + IDictionary Supply, + IDictionary SupplyUnitCosts, + IDictionary Demand, + IDictionary CommittedSupply, + IDictionary CommittedDemand, + ISet StoreSupplyNodes, + ISet StoreDemandNodes, + ISet RecipeInputDemandNodes, + List Allocations); +}