From 8fe97af4beceb21b114ecc5f49654c8ee139d552 Mon Sep 17 00:00:00 2001 From: wnj00524 <68168066+wnj00524@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:45:10 +0100 Subject: [PATCH] Add capacity bidding and consumer landed costs --- README.md | 18 +- src/MedWNetworkSim.App/MainWindow.xaml | 110 +++- src/MedWNetworkSim.App/Models/EdgeModel.cs | 2 + .../Models/TrafficTypeDefinition.cs | 2 + .../Samples/sample-network.json | 28 +- .../Services/ConsumerCostSummary.cs | 22 + .../Services/NetworkFileService.cs | 17 +- .../Services/NetworkSimulationEngine.cs | 489 ++++++++++++------ .../Services/RouteAllocation.cs | 8 + .../ConsumerCostSummaryRowViewModel.cs | 22 + .../ViewModels/EdgeViewModel.cs | 11 +- .../ViewModels/MainWindowViewModel.cs | 68 ++- .../ViewModels/RouteAllocationRowViewModel.cs | 10 +- 13 files changed, 596 insertions(+), 211 deletions(-) create mode 100644 src/MedWNetworkSim.App/Services/ConsumerCostSummary.cs create mode 100644 src/MedWNetworkSim.App/ViewModels/ConsumerCostSummaryRowViewModel.cs diff --git a/README.md b/README.md index d3e7b9e..ab84cba 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ WPF network simulator for modelling multi-traffic movement across producer, cons - Draws the network on a draggable canvas. - Auto-positions nodes when `x` and `y` are omitted from the input file. - Includes an `Auto Arrange` action to regenerate node positions for the whole network. +- Models optional edge capacities and consumes them during routing. - Lets each node participate in any number of traffic types. - Allows the same node to produce, tranship, and consume different traffic types. - Supports per-traffic routing priorities: @@ -38,7 +39,8 @@ The app uses a simple custom JSON format: { "name": "Infectious Waste", "description": "Optional description", - "routingPreference": "speed" + "routingPreference": "speed", + "capacityBidPerUnit": 1.5 } ], "nodes": [ @@ -62,6 +64,7 @@ The app uses a simple custom JSON format: "toNodeId": "N2", "time": 3.5, "cost": 6.0, + "capacity": 20, "isBidirectional": true } ] @@ -70,16 +73,25 @@ The app uses a simple custom JSON format: `x` and `y` are optional. If they are omitted, the app generates an initial layout when the file is loaded, and those generated positions are then saved back out if you use `Save JSON...`. +`capacity` is also optional. If it is omitted, the edge is treated as having unlimited capacity. + +`capacityBidPerUnit` is optional on a traffic type. If omitted, `speed` traffic defaults to a bid of `1` per constrained bottleneck edge and other traffic types default to `0`. + ## Routing Rules - Edge weights are shared across traffic types through `time` and `cost`. +- Edge capacity is optional, but when present it is shared across all traffic routed through that edge. +- Traffic types can place a per-unit bid on constrained edge capacity. - A traffic type chooses how those edge values are scored. - Producer nodes are any nodes with `production > 0` for that traffic. - Consumer nodes are any nodes with `consumption > 0` for that traffic. - Intermediate nodes must have `canTransship: true` for that same traffic. - Local producer-to-consumer matching on the same node is handled before network routing. +- Capacity competition is resolved across all traffic types together. Higher bids win access to scarce edge capacity first, then the normal route score breaks ties. +- Bid premiums are added to the landed movement cost when the route is genuinely bottlenecked by finite edge capacity. ## Notes -- The current simulator does not model edge capacities. -- Routing is path-based and allocates producer supply to consumer demand using the best available routes under the chosen traffic preference. +- Omit `capacity` on an edge when you want it to behave as unlimited. +- The consumer-cost view shows local and imported movement costs separately, plus the blended movement cost seen at each consumer node. +- Routing is path-based and allocates producer supply to consumer demand using the best available routes under the chosen traffic preference and capacity bidding. diff --git a/src/MedWNetworkSim.App/MainWindow.xaml b/src/MedWNetworkSim.App/MainWindow.xaml index 39f685e..40e6b10 100644 --- a/src/MedWNetworkSim.App/MainWindow.xaml +++ b/src/MedWNetworkSim.App/MainWindow.xaml @@ -309,8 +309,8 @@ + @@ -439,37 +442,88 @@ + Text="Simulation Outputs" /> + Text="Select a traffic type to filter both tabs" /> - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + = 0 or omit the property for unlimited capacity."); + } + normalizedEdges.Add(new EdgeModel { Id = edgeId, @@ -111,6 +117,7 @@ private NetworkModel NormalizeAndValidate(NetworkModel model, bool forceLayoutAl ToNodeId = toNodeId, Time = edge.Time, Cost = edge.Cost, + Capacity = capacity, IsBidirectional = edge.IsBidirectional }); } @@ -158,11 +165,19 @@ private static List NormalizeTrafficDefinitions( } var name = definition.Name.Trim(); + var capacityBidPerUnit = definition.CapacityBidPerUnit; + if (capacityBidPerUnit.HasValue && + (double.IsNaN(capacityBidPerUnit.Value) || double.IsInfinity(capacityBidPerUnit.Value) || capacityBidPerUnit.Value < 0d)) + { + throw new InvalidOperationException($"Traffic type '{name}' has an invalid capacityBidPerUnit. Use a number >= 0 or omit it."); + } + result[name] = new TrafficTypeDefinition { Name = name, Description = definition.Description?.Trim() ?? string.Empty, - RoutingPreference = definition.RoutingPreference + RoutingPreference = definition.RoutingPreference, + CapacityBidPerUnit = capacityBidPerUnit }; } diff --git a/src/MedWNetworkSim.App/Services/NetworkSimulationEngine.cs b/src/MedWNetworkSim.App/Services/NetworkSimulationEngine.cs index ae69880..8b2128d 100644 --- a/src/MedWNetworkSim.App/Services/NetworkSimulationEngine.cs +++ b/src/MedWNetworkSim.App/Services/NetworkSimulationEngine.cs @@ -11,197 +11,272 @@ public IReadOnlyList Simulate(NetworkModel network) { ArgumentNullException.ThrowIfNull(network); + var adjacency = BuildAdjacency(network); var definitionsByTraffic = network.TrafficTypes .ToDictionary(definition => definition.Name, definition => definition, Comparer); - - var trafficNames = network.TrafficTypes - .Select(definition => definition.Name) - .Concat(network.Nodes.SelectMany(node => node.TrafficProfiles).Select(profile => profile.TrafficType)) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct(Comparer) - .OrderBy(name => name, Comparer) - .ToList(); - - return trafficNames - .Select(trafficName => + var contexts = GetOrderedTrafficNames(network) + .Select(trafficType => { - var definition = definitionsByTraffic.GetValueOrDefault(trafficName) + var definition = definitionsByTraffic.GetValueOrDefault(trafficType) ?? new TrafficTypeDefinition { - Name = trafficName, + Name = trafficType, RoutingPreference = RoutingPreference.TotalCost }; - return SimulateTraffic(network, trafficName, definition.RoutingPreference); + return BuildContext(network, definition); }) .ToList(); - } - - private static TrafficSimulationOutcome SimulateTraffic( - NetworkModel network, - string trafficType, - RoutingPreference routingPreference) - { - var nodesById = network.Nodes.ToDictionary(node => node.Id, node => node, Comparer); - var profilesByNodeId = network.Nodes.ToDictionary( - node => node.Id, - node => node.TrafficProfiles.FirstOrDefault(profile => Comparer.Equals(profile.TrafficType, trafficType)), + var remainingCapacityByEdgeId = network.Edges.ToDictionary( + edge => edge.Id, + edge => edge.Capacity ?? double.PositiveInfinity, Comparer); + var hasFiniteCapacities = network.Edges.Any(edge => edge.Capacity.HasValue); - var supply = profilesByNodeId - .Where(pair => pair.Value?.Production > Epsilon) - .ToDictionary(pair => pair.Key, pair => pair.Value!.Production, Comparer); + foreach (var context in contexts) + { + if (context.TotalProduction <= Epsilon) + { + context.Notes.Add("No producer nodes were defined for this traffic type."); + } - var demand = profilesByNodeId - .Where(pair => pair.Value?.Consumption > Epsilon) - .ToDictionary(pair => pair.Key, pair => pair.Value!.Consumption, Comparer); + if (context.TotalConsumption <= Epsilon) + { + context.Notes.Add("No consumer nodes were defined for this traffic type."); + } - var totalProduction = supply.Values.Sum(); - var totalConsumption = demand.Values.Sum(); - var notes = new List(); - var allocations = new List(); + ApplyLocalAllocations(context); + } - if (totalProduction <= Epsilon) + while (true) { - notes.Add("No producer nodes were defined for this traffic type."); + var nextCandidate = contexts + .SelectMany(context => BuildCandidateRoutes(context, adjacency, remainingCapacityByEdgeId)) + .OrderByDescending(candidate => candidate.CapacityBidPerUnit) + .ThenBy(candidate => candidate.TotalScore) + .ThenBy(candidate => candidate.TotalTime) + .ThenBy(candidate => candidate.TransitCostPerUnit) + .ThenBy(candidate => candidate.ProducerNodeId, Comparer) + .ThenBy(candidate => candidate.ConsumerNodeId, Comparer) + .FirstOrDefault(); + + if (nextCandidate is null) + { + break; + } + + var context = nextCandidate.Context; + var remainingSupply = context.Supply.TryGetValue(nextCandidate.ProducerNodeId, out var supplyValue) ? supplyValue : 0d; + var remainingDemand = context.Demand.TryGetValue(nextCandidate.ConsumerNodeId, out var demandValue) ? demandValue : 0d; + var quantity = Math.Min(remainingSupply, remainingDemand); + var routeCapacity = GetPathRemainingCapacity(nextCandidate.PathEdgeIds, remainingCapacityByEdgeId); + + if (!double.IsPositiveInfinity(routeCapacity)) + { + quantity = Math.Min(quantity, routeCapacity); + } + + if (quantity <= Epsilon) + { + break; + } + + var bidCostPerUnit = CalculateBidCostPerUnit( + nextCandidate.PathEdgeIds, + remainingCapacityByEdgeId, + nextCandidate.CapacityBidPerUnit, + quantity, + routeCapacity); + var deliveredCostPerUnit = nextCandidate.TransitCostPerUnit + bidCostPerUnit; + + context.Allocations.Add(new RouteAllocation + { + TrafficType = context.TrafficType, + RoutingPreference = context.RoutingPreference, + ProducerNodeId = nextCandidate.ProducerNodeId, + ProducerName = context.NodesById[nextCandidate.ProducerNodeId].Name, + ConsumerNodeId = nextCandidate.ConsumerNodeId, + ConsumerName = context.NodesById[nextCandidate.ConsumerNodeId].Name, + Quantity = quantity, + IsLocalSupply = false, + TotalTime = nextCandidate.TotalTime, + TotalCost = nextCandidate.TransitCostPerUnit, + BidCostPerUnit = bidCostPerUnit, + DeliveredCostPerUnit = deliveredCostPerUnit, + TotalMovementCost = deliveredCostPerUnit * quantity, + TotalScore = nextCandidate.TotalScore, + PathNodeNames = nextCandidate.PathNodeIds.Select(nodeId => context.NodesById[nodeId].Name).ToList() + }); + + context.Supply[nextCandidate.ProducerNodeId] -= quantity; + context.Demand[nextCandidate.ConsumerNodeId] -= quantity; + ReserveCapacity(nextCandidate.PathEdgeIds, remainingCapacityByEdgeId, quantity); } - if (totalConsumption <= Epsilon) + foreach (var context in contexts) { - notes.Add("No consumer nodes were defined for this traffic type."); - } + var unusedSupply = context.Supply.Values.Sum(value => Math.Max(0d, value)); + var unmetDemand = context.Demand.Values.Sum(value => Math.Max(0d, value)); - ApplyLocalAllocations(trafficType, routingPreference, nodesById, supply, demand, allocations); + if (unusedSupply > Epsilon) + { + context.Notes.Add($"Unused supply remains after routing: {unusedSupply:0.##} unit(s)."); + } - if (supply.Values.Sum() > Epsilon && demand.Values.Sum() > Epsilon) - { - var adjacency = BuildAdjacency(network); - var candidateRoutes = BuildCandidateRoutes( - trafficType, - routingPreference, - nodesById, - profilesByNodeId, - adjacency, - supply, - demand); - - foreach (var route in candidateRoutes) + if (unmetDemand > Epsilon) { - if (!supply.TryGetValue(route.ProducerNodeId, out var remainingSupply) || - !demand.TryGetValue(route.ConsumerNodeId, out var remainingDemand)) - { - continue; - } + context.Notes.Add($"Unmet demand remains after routing: {unmetDemand:0.##} unit(s)."); + } - var quantity = Math.Min(remainingSupply, remainingDemand); - if (quantity <= Epsilon) - { - continue; - } + if (hasFiniteCapacities && (unusedSupply > Epsilon || unmetDemand > Epsilon)) + { + context.Notes.Add("Shared edge capacity limits may have prevented additional routing."); + } - allocations.Add(new RouteAllocation - { - TrafficType = trafficType, - RoutingPreference = routingPreference, - ProducerNodeId = route.ProducerNodeId, - ProducerName = nodesById[route.ProducerNodeId].Name, - ConsumerNodeId = route.ConsumerNodeId, - ConsumerName = nodesById[route.ConsumerNodeId].Name, - Quantity = quantity, - TotalTime = route.TotalTime, - TotalCost = route.TotalCost, - TotalScore = route.TotalScore, - PathNodeNames = route.PathNodeIds.Select(nodeId => nodesById[nodeId].Name).ToList() - }); - - supply[route.ProducerNodeId] -= quantity; - demand[route.ConsumerNodeId] -= quantity; + var totalBidCost = context.Allocations.Sum(allocation => allocation.BidCostPerUnit * allocation.Quantity); + if (totalBidCost > Epsilon) + { + context.Notes.Add($"Capacity bidding added {totalBidCost:0.##} in extra movement cost."); + } + + if (context.Allocations.Count == 0 && context.TotalProduction > Epsilon && context.TotalConsumption > Epsilon) + { + context.Notes.Add("No feasible producer-to-consumer routes were found with the current node roles, edge directions, capacities, and bidding rules."); } } - var unusedSupply = supply.Values.Sum(value => Math.Max(0d, value)); - var unmetDemand = demand.Values.Sum(value => Math.Max(0d, value)); + return contexts + .Select(context => new TrafficSimulationOutcome + { + TrafficType = context.TrafficType, + RoutingPreference = context.RoutingPreference, + TotalProduction = context.TotalProduction, + TotalConsumption = context.TotalConsumption, + TotalDelivered = context.Allocations.Sum(allocation => allocation.Quantity), + UnusedSupply = context.Supply.Values.Sum(value => Math.Max(0d, value)), + UnmetDemand = context.Demand.Values.Sum(value => Math.Max(0d, value)), + Allocations = context.Allocations.ToList(), + Notes = context.Notes.ToList() + }) + .ToList(); + } - if (unusedSupply > Epsilon) - { - notes.Add($"Unused supply remains after routing: {unusedSupply:0.##} unit(s)."); - } + public IReadOnlyList SummarizeConsumerCosts(IEnumerable outcomes) + { + return outcomes + .SelectMany(outcome => outcome.Allocations) + .GroupBy(allocation => new { allocation.TrafficType, allocation.ConsumerNodeId, allocation.ConsumerName }) + .Select(group => + { + var localAllocations = group.Where(allocation => allocation.IsLocalSupply).ToList(); + var importedAllocations = group.Where(allocation => !allocation.IsLocalSupply).ToList(); + var localQuantity = localAllocations.Sum(allocation => allocation.Quantity); + var importedQuantity = importedAllocations.Sum(allocation => allocation.Quantity); + var totalMovementCost = group.Sum(allocation => allocation.TotalMovementCost); + var totalQuantity = group.Sum(allocation => allocation.Quantity); + + return new ConsumerCostSummary + { + TrafficType = group.Key.TrafficType, + ConsumerNodeId = group.Key.ConsumerNodeId, + ConsumerName = group.Key.ConsumerName, + LocalQuantity = localQuantity, + LocalUnitCost = CalculateAverageUnitCost(localAllocations), + ImportedQuantity = importedQuantity, + ImportedUnitCost = CalculateAverageUnitCost(importedAllocations), + BlendedUnitCost = totalQuantity > Epsilon ? totalMovementCost / totalQuantity : 0d, + TotalMovementCost = totalMovementCost + }; + }) + .OrderBy(summary => summary.TrafficType, Comparer) + .ThenBy(summary => summary.ConsumerName, Comparer) + .ToList(); + } - if (unmetDemand > Epsilon) + private static double CalculateAverageUnitCost(IReadOnlyCollection allocations) + { + var quantity = allocations.Sum(allocation => allocation.Quantity); + if (quantity <= Epsilon) { - notes.Add($"Unmet demand remains after routing: {unmetDemand:0.##} unit(s)."); + return 0d; } - if (allocations.Count == 0 && totalProduction > Epsilon && totalConsumption > Epsilon) - { - notes.Add("No feasible producer-to-consumer routes were found with the current node roles and edge directions."); - } + return allocations.Sum(allocation => allocation.TotalMovementCost) / quantity; + } - return new TrafficSimulationOutcome - { - TrafficType = trafficType, - RoutingPreference = routingPreference, - TotalProduction = totalProduction, - TotalConsumption = totalConsumption, - TotalDelivered = allocations.Sum(allocation => allocation.Quantity), - UnusedSupply = unusedSupply, - UnmetDemand = unmetDemand, - Allocations = allocations, - Notes = notes - }; + private static TrafficContext BuildContext(NetworkModel network, TrafficTypeDefinition definition) + { + var profilesByNodeId = network.Nodes.ToDictionary( + node => node.Id, + node => node.TrafficProfiles.FirstOrDefault(profile => Comparer.Equals(profile.TrafficType, definition.Name)), + Comparer); + var nodesById = network.Nodes.ToDictionary(node => node.Id, node => node, Comparer); + var supply = profilesByNodeId + .Where(pair => pair.Value?.Production > Epsilon) + .ToDictionary(pair => pair.Key, pair => pair.Value!.Production, Comparer); + var demand = profilesByNodeId + .Where(pair => pair.Value?.Consumption > Epsilon) + .ToDictionary(pair => pair.Key, pair => pair.Value!.Consumption, Comparer); + + return new TrafficContext( + definition.Name, + definition.RoutingPreference, + GetCapacityBidPerUnit(definition), + nodesById, + profilesByNodeId, + supply, + demand, + supply.Values.Sum(), + demand.Values.Sum(), + [], + []); } - private static void ApplyLocalAllocations( - string trafficType, - RoutingPreference routingPreference, - IReadOnlyDictionary nodesById, - IDictionary supply, - IDictionary demand, - ICollection allocations) + private static void ApplyLocalAllocations(TrafficContext context) { - foreach (var nodeId in supply.Keys.Intersect(demand.Keys, Comparer).ToList()) + foreach (var nodeId in context.Supply.Keys.Intersect(context.Demand.Keys, Comparer).ToList()) { - var quantity = Math.Min(supply[nodeId], demand[nodeId]); + var quantity = Math.Min(context.Supply[nodeId], context.Demand[nodeId]); if (quantity <= Epsilon) { continue; } - var node = nodesById[nodeId]; - allocations.Add(new RouteAllocation + var node = context.NodesById[nodeId]; + context.Allocations.Add(new RouteAllocation { - TrafficType = trafficType, - RoutingPreference = routingPreference, + TrafficType = context.TrafficType, + RoutingPreference = context.RoutingPreference, ProducerNodeId = nodeId, ProducerName = node.Name, ConsumerNodeId = nodeId, ConsumerName = node.Name, Quantity = quantity, + IsLocalSupply = true, TotalTime = 0d, TotalCost = 0d, + BidCostPerUnit = 0d, + DeliveredCostPerUnit = 0d, + TotalMovementCost = 0d, TotalScore = 0d, PathNodeNames = [node.Name] }); - supply[nodeId] -= quantity; - demand[nodeId] -= quantity; + context.Supply[nodeId] -= quantity; + context.Demand[nodeId] -= quantity; } } - private static List BuildCandidateRoutes( - string trafficType, - RoutingPreference routingPreference, - IReadOnlyDictionary nodesById, - IReadOnlyDictionary profilesByNodeId, + private static List BuildCandidateRoutes( + TrafficContext context, IReadOnlyDictionary> adjacency, - IReadOnlyDictionary supply, - IReadOnlyDictionary demand) + IDictionary remainingCapacityByEdgeId) { - var routes = new List(); + var routes = new List(); - foreach (var producerNodeId in supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key)) + foreach (var producerNodeId in context.Supply.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key)) { - foreach (var consumerNodeId in demand.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key)) + foreach (var consumerNodeId in context.Demand.Where(pair => pair.Value > Epsilon).Select(pair => pair.Key)) { if (Comparer.Equals(producerNodeId, consumerNodeId)) { @@ -209,34 +284,28 @@ private static List BuildCandidateRoutes( } var route = FindBestRoute( + context, producerNodeId, consumerNodeId, - routingPreference, - profilesByNodeId, - adjacency); + adjacency, + remainingCapacityByEdgeId); if (route is not null) { - routes.Add(route with { TrafficType = trafficType }); + routes.Add(route); } } } - return routes - .OrderBy(route => route.TotalScore) - .ThenBy(route => route.TotalTime) - .ThenBy(route => route.TotalCost) - .ThenBy(route => route.ProducerNodeId, Comparer) - .ThenBy(route => route.ConsumerNodeId, Comparer) - .ToList(); + return routes; } - private static CandidateRoute? FindBestRoute( + private static RouteCandidate? FindBestRoute( + TrafficContext context, string producerNodeId, string consumerNodeId, - RoutingPreference routingPreference, - IReadOnlyDictionary profilesByNodeId, - IReadOnlyDictionary> adjacency) + IReadOnlyDictionary> adjacency, + IDictionary remainingCapacityByEdgeId) { var distances = new Dictionary(Comparer) { @@ -266,12 +335,18 @@ private static List BuildCandidateRoutes( foreach (var arc in arcs) { - if (!CanTraverseNode(arc.ToNodeId, producerNodeId, consumerNodeId, profilesByNodeId)) + if (!remainingCapacityByEdgeId.TryGetValue(arc.EdgeId, out var remainingCapacity) || + remainingCapacity <= Epsilon) + { + continue; + } + + if (!CanTraverseNode(arc.ToNodeId, producerNodeId, consumerNodeId, context.ProfilesByNodeId)) { continue; } - var proposedDistance = currentDistance + Score(arc.Time, arc.Cost, routingPreference); + var proposedDistance = currentDistance + Score(arc.Time, arc.Cost, context.RoutingPreference); if (distances.TryGetValue(arc.ToNodeId, out var existingDistance) && proposedDistance >= existingDistance - Epsilon) { @@ -304,14 +379,16 @@ private static List BuildCandidateRoutes( pathNodeIds.Reverse(); pathArcs.Reverse(); - return new CandidateRoute( - string.Empty, + return new RouteCandidate( + context, producerNodeId, consumerNodeId, pathNodeIds, + pathArcs.Select(arc => arc.EdgeId).ToList(), pathArcs.Sum(arc => arc.Time), pathArcs.Sum(arc => arc.Cost), - pathArcs.Sum(arc => Score(arc.Time, arc.Cost, routingPreference))); + pathArcs.Sum(arc => Score(arc.Time, arc.Cost, context.RoutingPreference)), + context.CapacityBidPerUnit); } private static bool CanTraverseNode( @@ -355,6 +432,95 @@ void AddArc(string fromNodeId, string toNodeId, EdgeModel edge) return adjacency; } + private static double GetPathRemainingCapacity( + IReadOnlyList pathEdgeIds, + IDictionary remainingCapacityByEdgeId) + { + if (pathEdgeIds.Count == 0) + { + return double.PositiveInfinity; + } + + return pathEdgeIds + .Select(edgeId => remainingCapacityByEdgeId.TryGetValue(edgeId, out var remainingCapacity) ? remainingCapacity : 0d) + .DefaultIfEmpty(0d) + .Min(); + } + + private static double CalculateBidCostPerUnit( + IReadOnlyList pathEdgeIds, + IDictionary remainingCapacityByEdgeId, + double capacityBidPerUnit, + double quantity, + double routeCapacity) + { + if (capacityBidPerUnit <= Epsilon || + quantity <= Epsilon || + double.IsPositiveInfinity(routeCapacity) || + quantity < routeCapacity - Epsilon) + { + return 0d; + } + + var bottleneckEdgeCount = pathEdgeIds.Count(edgeId => + remainingCapacityByEdgeId.TryGetValue(edgeId, out var remainingCapacity) && + !double.IsPositiveInfinity(remainingCapacity) && + remainingCapacity <= routeCapacity + Epsilon); + + return bottleneckEdgeCount * capacityBidPerUnit; + } + + private static void ReserveCapacity( + IEnumerable pathEdgeIds, + IDictionary remainingCapacityByEdgeId, + double quantity) + { + foreach (var edgeId in pathEdgeIds) + { + if (!remainingCapacityByEdgeId.TryGetValue(edgeId, out var remainingCapacity) || + double.IsPositiveInfinity(remainingCapacity)) + { + continue; + } + + remainingCapacityByEdgeId[edgeId] = Math.Max(0d, remainingCapacity - quantity); + } + } + + private static List GetOrderedTrafficNames(NetworkModel network) + { + var orderedTrafficNames = new List(); + var seen = new HashSet(Comparer); + + foreach (var definition in network.TrafficTypes) + { + if (!string.IsNullOrWhiteSpace(definition.Name) && seen.Add(definition.Name)) + { + orderedTrafficNames.Add(definition.Name); + } + } + + var undeclaredTrafficNames = network.Nodes + .SelectMany(node => node.TrafficProfiles) + .Select(profile => profile.TrafficType) + .Where(name => !string.IsNullOrWhiteSpace(name) && !seen.Contains(name)) + .Distinct(Comparer) + .OrderBy(name => name, Comparer); + + orderedTrafficNames.AddRange(undeclaredTrafficNames); + return orderedTrafficNames; + } + + private static double GetCapacityBidPerUnit(TrafficTypeDefinition definition) + { + if (definition.CapacityBidPerUnit.HasValue) + { + return Math.Max(0d, definition.CapacityBidPerUnit.Value); + } + + return definition.RoutingPreference == RoutingPreference.Speed ? 1d : 0d; + } + private static double Score(double time, double cost, RoutingPreference routingPreference) { return routingPreference switch @@ -374,12 +540,27 @@ private sealed record GraphArc( private sealed record PreviousStep(string PreviousNodeId, GraphArc Arc); - private sealed record CandidateRoute( - string TrafficType, + private sealed record RouteCandidate( + TrafficContext Context, string ProducerNodeId, string ConsumerNodeId, IReadOnlyList PathNodeIds, + IReadOnlyList PathEdgeIds, double TotalTime, - double TotalCost, - double TotalScore); + double TransitCostPerUnit, + double TotalScore, + double CapacityBidPerUnit); + + private sealed record TrafficContext( + string TrafficType, + RoutingPreference RoutingPreference, + double CapacityBidPerUnit, + IReadOnlyDictionary NodesById, + IReadOnlyDictionary ProfilesByNodeId, + IDictionary Supply, + IDictionary Demand, + double TotalProduction, + double TotalConsumption, + List Allocations, + List Notes); } diff --git a/src/MedWNetworkSim.App/Services/RouteAllocation.cs b/src/MedWNetworkSim.App/Services/RouteAllocation.cs index fbc95c0..888c675 100644 --- a/src/MedWNetworkSim.App/Services/RouteAllocation.cs +++ b/src/MedWNetworkSim.App/Services/RouteAllocation.cs @@ -19,10 +19,18 @@ public sealed class RouteAllocation public double Quantity { get; init; } + public bool IsLocalSupply { get; init; } + public double TotalTime { get; init; } public double TotalCost { get; init; } + public double BidCostPerUnit { get; init; } + + public double DeliveredCostPerUnit { get; init; } + + public double TotalMovementCost { get; init; } + public double TotalScore { get; init; } public IReadOnlyList PathNodeNames { get; init; } = []; diff --git a/src/MedWNetworkSim.App/ViewModels/ConsumerCostSummaryRowViewModel.cs b/src/MedWNetworkSim.App/ViewModels/ConsumerCostSummaryRowViewModel.cs new file mode 100644 index 0000000..f62170b --- /dev/null +++ b/src/MedWNetworkSim.App/ViewModels/ConsumerCostSummaryRowViewModel.cs @@ -0,0 +1,22 @@ +using MedWNetworkSim.App.Services; + +namespace MedWNetworkSim.App.ViewModels; + +public sealed class ConsumerCostSummaryRowViewModel(ConsumerCostSummary summary) : ObservableObject +{ + public string TrafficType { get; } = summary.TrafficType; + + public string ConsumerName { get; } = summary.ConsumerName; + + public double LocalQuantity { get; } = summary.LocalQuantity; + + public double LocalUnitCost { get; } = summary.LocalUnitCost; + + public double ImportedQuantity { get; } = summary.ImportedQuantity; + + public double ImportedUnitCost { get; } = summary.ImportedUnitCost; + + public double BlendedUnitCost { get; } = summary.BlendedUnitCost; + + public double TotalMovementCost { get; } = summary.TotalMovementCost; +} diff --git a/src/MedWNetworkSim.App/ViewModels/EdgeViewModel.cs b/src/MedWNetworkSim.App/ViewModels/EdgeViewModel.cs index 3c0aaf2..fefb2aa 100644 --- a/src/MedWNetworkSim.App/ViewModels/EdgeViewModel.cs +++ b/src/MedWNetworkSim.App/ViewModels/EdgeViewModel.cs @@ -7,8 +7,8 @@ namespace MedWNetworkSim.App.ViewModels; public sealed class EdgeViewModel : ObservableObject { - private const double LabelWidth = 150d; - private const double LabelHeight = 44d; + private const double LabelWidth = 176d; + private const double LabelHeight = 58d; public EdgeViewModel(EdgeModel model, NodeViewModel sourceNode, NodeViewModel targetNode) { @@ -30,6 +30,8 @@ public EdgeViewModel(EdgeModel model, NodeViewModel sourceNode, NodeViewModel ta public double Cost => Model.Cost; + public double? Capacity => Model.Capacity; + public bool IsBidirectional => Model.IsBidirectional; public string DirectionLabel => IsBidirectional ? "2-way" : "1-way"; @@ -50,6 +52,10 @@ public EdgeViewModel(EdgeModel model, NodeViewModel sourceNode, NodeViewModel ta public string SummaryLabel => $"t {Time:0.##} | c {Cost:0.##} | tc {TotalCost:0.##}"; + public string CapacityLabel => Capacity.HasValue + ? $"cap {Capacity.Value:0.##}" + : "cap inf"; + public Visibility ArrowVisibility => IsBidirectional ? Visibility.Collapsed : Visibility.Visible; public string ArrowPoints @@ -100,6 +106,7 @@ public EdgeModel ToModel() ToNodeId = Model.ToNodeId, Time = Model.Time, Cost = Model.Cost, + Capacity = Model.Capacity, IsBidirectional = Model.IsBidirectional }; } diff --git a/src/MedWNetworkSim.App/ViewModels/MainWindowViewModel.cs b/src/MedWNetworkSim.App/ViewModels/MainWindowViewModel.cs index f3caa71..1394861 100644 --- a/src/MedWNetworkSim.App/ViewModels/MainWindowViewModel.cs +++ b/src/MedWNetworkSim.App/ViewModels/MainWindowViewModel.cs @@ -13,6 +13,7 @@ public sealed class MainWindowViewModel : ObservableObject private readonly NetworkFileService fileService = new(); private readonly NetworkSimulationEngine simulationEngine = new(); private readonly List allAllocations = []; + private readonly List allConsumerCostSummaries = []; private NetworkModel currentNetwork = new(); private string activeFileLabel = "Bundled sample"; @@ -37,6 +38,8 @@ public MainWindowViewModel() public ObservableCollection VisibleAllocations { get; } = []; + public ObservableCollection VisibleConsumerCostSummaries { get; } = []; + public string WindowTitle => HasNetwork ? $"MedW Network Simulator - {NetworkName}" : "MedW Network Simulator"; public string ActiveFileLabel @@ -110,7 +113,9 @@ public TrafficSummaryViewModel? SelectedTraffic } OnPropertyChanged(nameof(VisibleAllocationHeadline)); + OnPropertyChanged(nameof(VisibleConsumerCostHeadline)); RefreshVisibleAllocations(); + RefreshVisibleConsumerCostSummaries(); } } @@ -118,6 +123,10 @@ public TrafficSummaryViewModel? SelectedTraffic ? $"{VisibleAllocations.Count} routed movement(s) across all traffic" : $"{VisibleAllocations.Count} routed movement(s) for {SelectedTraffic.Name}"; + public string VisibleConsumerCostHeadline => SelectedTraffic is null + ? $"{VisibleConsumerCostSummaries.Count} consumer cost row(s) across all traffic" + : $"{VisibleConsumerCostSummaries.Count} consumer cost row(s) for {SelectedTraffic.Name}"; + public string SuggestedFileName { get @@ -180,7 +189,12 @@ public void RunSimulation() allAllocations.Clear(); allAllocations.AddRange(outcomes.SelectMany(outcome => outcome.Allocations).Select(allocation => new RouteAllocationRowViewModel(allocation))); + allConsumerCostSummaries.Clear(); + allConsumerCostSummaries.AddRange( + simulationEngine.SummarizeConsumerCosts(outcomes) + .Select(summary => new ConsumerCostSummaryRowViewModel(summary))); RefreshVisibleAllocations(); + RefreshVisibleConsumerCostSummaries(); var totalDelivered = outcomes.Sum(outcome => outcome.TotalDelivered); StatusMessage = $"Simulation complete. Routed {allAllocations.Count} movement(s) delivering {totalDelivered:0.##} unit(s)."; @@ -230,7 +244,9 @@ private void LoadNetwork(NetworkModel network, string? activeFilePath, string su Edges.Clear(); TrafficTypes.Clear(); VisibleAllocations.Clear(); + VisibleConsumerCostSummaries.Clear(); allAllocations.Clear(); + allConsumerCostSummaries.Clear(); SelectedTraffic = null; var nodeMap = new Dictionary(Comparer); @@ -277,16 +293,9 @@ private List BuildTrafficSummaries(NetworkModel network var definitionsByTraffic = network.TrafficTypes .ToDictionary(definition => definition.Name, definition => definition, Comparer); - var trafficNames = network.TrafficTypes - .Select(definition => definition.Name) - .Concat(network.Nodes.SelectMany(node => node.TrafficProfiles).Select(profile => profile.TrafficType)) - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Distinct(Comparer) - .OrderBy(name => name, Comparer); - var summaries = new List(); - foreach (var trafficName in trafficNames) + foreach (var trafficName in GetOrderedTrafficNames(network)) { var profiles = network.Nodes .Select(node => node.TrafficProfiles.FirstOrDefault(profile => Comparer.Equals(profile.TrafficType, trafficName))) @@ -314,6 +323,30 @@ private List BuildTrafficSummaries(NetworkModel network return summaries; } + private IEnumerable GetOrderedTrafficNames(NetworkModel network) + { + var orderedNames = new List(); + var seen = new HashSet(Comparer); + + foreach (var definition in network.TrafficTypes) + { + if (!string.IsNullOrWhiteSpace(definition.Name) && seen.Add(definition.Name)) + { + orderedNames.Add(definition.Name); + } + } + + var undeclaredNames = network.Nodes + .SelectMany(node => node.TrafficProfiles) + .Select(profile => profile.TrafficType) + .Where(name => !string.IsNullOrWhiteSpace(name) && !seen.Contains(name)) + .Distinct(Comparer) + .OrderBy(name => name, Comparer); + + orderedNames.AddRange(undeclaredNames); + return orderedNames; + } + private NetworkModel BuildCurrentNetwork() { currentNetwork = fileService.NormalizeAndValidate(new NetworkModel @@ -325,7 +358,8 @@ private NetworkModel BuildCurrentNetwork() { Name = definition.Name, Description = definition.Description, - RoutingPreference = definition.RoutingPreference + RoutingPreference = definition.RoutingPreference, + CapacityBidPerUnit = definition.CapacityBidPerUnit }) .ToList(), Nodes = Nodes.Select(node => node.ToModel()).ToList(), @@ -371,4 +405,20 @@ private void RefreshVisibleAllocations() OnPropertyChanged(nameof(VisibleAllocationHeadline)); } + + private void RefreshVisibleConsumerCostSummaries() + { + VisibleConsumerCostSummaries.Clear(); + + var source = SelectedTraffic is null + ? allConsumerCostSummaries + : allConsumerCostSummaries.Where(summary => Comparer.Equals(summary.TrafficType, SelectedTraffic.Name)); + + foreach (var summary in source) + { + VisibleConsumerCostSummaries.Add(summary); + } + + OnPropertyChanged(nameof(VisibleConsumerCostHeadline)); + } } diff --git a/src/MedWNetworkSim.App/ViewModels/RouteAllocationRowViewModel.cs b/src/MedWNetworkSim.App/ViewModels/RouteAllocationRowViewModel.cs index 1f0fe75..f31fa82 100644 --- a/src/MedWNetworkSim.App/ViewModels/RouteAllocationRowViewModel.cs +++ b/src/MedWNetworkSim.App/ViewModels/RouteAllocationRowViewModel.cs @@ -22,9 +22,17 @@ public sealed class RouteAllocationRowViewModel(RouteAllocation allocation) : Ob public double Quantity { get; } = allocation.Quantity; + public string SourceLabel { get; } = allocation.IsLocalSupply ? "Local" : "Imported"; + public double TotalTime { get; } = allocation.TotalTime; - public double TotalCost { get; } = allocation.TotalCost; + public double TransitCostPerUnit { get; } = allocation.TotalCost; + + public double BidCostPerUnit { get; } = allocation.BidCostPerUnit; + + public double DeliveredCostPerUnit { get; } = allocation.DeliveredCostPerUnit; + + public double TotalMovementCost { get; } = allocation.TotalMovementCost; public double TotalScore { get; } = allocation.TotalScore;