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;