Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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": [
Expand All @@ -62,6 +64,7 @@ The app uses a simple custom JSON format:
"toNodeId": "N2",
"time": 3.5,
"cost": 6.0,
"capacity": 20,
"isBidirectional": true
}
]
Expand All @@ -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.
110 changes: 82 additions & 28 deletions src/MedWNetworkSim.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,8 @@

<Border Canvas.Left="{Binding LabelLeft}"
Canvas.Top="{Binding LabelTop}"
Width="150"
Height="44"
Width="176"
Height="58"
Padding="10,6"
Background="{StaticResource EdgeLabelBrush}"
BorderBrush="{StaticResource BorderBrush}"
Expand All @@ -324,6 +324,9 @@
<TextBlock FontSize="11"
Foreground="{StaticResource StrongForegroundBrush}"
Text="{Binding SummaryLabel}" />
<TextBlock FontSize="11"
Foreground="{StaticResource MutedForegroundBrush}"
Text="{Binding CapacityLabel}" />
</StackPanel>
</Border>
</Canvas>
Expand Down Expand Up @@ -439,37 +442,88 @@
<DockPanel>
<TextBlock FontSize="18"
FontWeight="SemiBold"
Text="Routed Movements" />
Text="Simulation Outputs" />
<TextBlock DockPanel.Dock="Right"
FontSize="12"
Foreground="{StaticResource MutedForegroundBrush}"
Text="{Binding VisibleAllocationHeadline}" />
Text="Select a traffic type to filter both tabs" />
</DockPanel>

<DataGrid Grid.Row="2"
ItemsSource="{Binding VisibleAllocations}"
AutoGenerateColumns="False"
IsReadOnly="True"
SelectionMode="Single"
SelectionUnit="FullRow">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding TrafficType}" Header="Traffic" Width="140" />
<DataGridTextColumn Binding="{Binding RoutingPreferenceLabel}" Header="Preference" Width="110" />
<DataGridTextColumn Binding="{Binding ProducerName}" Header="Producer" Width="150" />
<DataGridTextColumn Binding="{Binding ConsumerName}" Header="Consumer" Width="150" />
<DataGridTextColumn Binding="{Binding Quantity, StringFormat={}{0:0.##}}" Header="Qty" Width="70" />
<DataGridTextColumn Binding="{Binding TotalTime, StringFormat={}{0:0.##}}" Header="Time" Width="80" />
<DataGridTextColumn Binding="{Binding TotalCost, StringFormat={}{0:0.##}}" Header="Cost" Width="80" />
<DataGridTextColumn Binding="{Binding TotalScore, StringFormat={}{0:0.##}}" Header="Score" Width="90" />
<DataGridTemplateColumn Header="Path" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate DataType="{x:Type vm:RouteAllocationRowViewModel}">
<TextBlock Text="{Binding PathDescription}" TextWrapping="Wrap" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<TabControl Grid.Row="2">
<TabItem Header="Consumer Costs">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="8" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>

<TextBlock FontSize="12"
Foreground="{StaticResource MutedForegroundBrush}"
Text="{Binding VisibleConsumerCostHeadline}" />

<DataGrid Grid.Row="2"
ItemsSource="{Binding VisibleConsumerCostSummaries}"
AutoGenerateColumns="False"
IsReadOnly="True"
SelectionMode="Single"
SelectionUnit="FullRow">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding TrafficType}" Header="Traffic" Width="140" />
<DataGridTextColumn Binding="{Binding ConsumerName}" Header="Consumer" Width="170" />
<DataGridTextColumn Binding="{Binding LocalQuantity, StringFormat={}{0:0.##}}" Header="Local Qty" Width="90" />
<DataGridTextColumn Binding="{Binding LocalUnitCost, StringFormat={}{0:0.##}}" Header="Local Unit Cost" Width="110" />
<DataGridTextColumn Binding="{Binding ImportedQuantity, StringFormat={}{0:0.##}}" Header="Imported Qty" Width="100" />
<DataGridTextColumn Binding="{Binding ImportedUnitCost, StringFormat={}{0:0.##}}" Header="Imported Unit Cost" Width="125" />
<DataGridTextColumn Binding="{Binding BlendedUnitCost, StringFormat={}{0:0.##}}" Header="Blended Unit Cost" Width="125" />
<DataGridTextColumn Binding="{Binding TotalMovementCost, StringFormat={}{0:0.##}}" Header="Total Movement Cost" Width="140" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</TabItem>

<TabItem Header="Routed Movements">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="8" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>

<TextBlock FontSize="12"
Foreground="{StaticResource MutedForegroundBrush}"
Text="{Binding VisibleAllocationHeadline}" />

<DataGrid Grid.Row="2"
ItemsSource="{Binding VisibleAllocations}"
AutoGenerateColumns="False"
IsReadOnly="True"
SelectionMode="Single"
SelectionUnit="FullRow">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding TrafficType}" Header="Traffic" Width="120" />
<DataGridTextColumn Binding="{Binding RoutingPreferenceLabel}" Header="Preference" Width="95" />
<DataGridTextColumn Binding="{Binding SourceLabel}" Header="Source" Width="85" />
<DataGridTextColumn Binding="{Binding ProducerName}" Header="Producer" Width="135" />
<DataGridTextColumn Binding="{Binding ConsumerName}" Header="Consumer" Width="135" />
<DataGridTextColumn Binding="{Binding Quantity, StringFormat={}{0:0.##}}" Header="Qty" Width="65" />
<DataGridTextColumn Binding="{Binding TotalTime, StringFormat={}{0:0.##}}" Header="Time" Width="70" />
<DataGridTextColumn Binding="{Binding TransitCostPerUnit, StringFormat={}{0:0.##}}" Header="Transit / Unit" Width="95" />
<DataGridTextColumn Binding="{Binding BidCostPerUnit, StringFormat={}{0:0.##}}" Header="Bid / Unit" Width="80" />
<DataGridTextColumn Binding="{Binding DeliveredCostPerUnit, StringFormat={}{0:0.##}}" Header="Landed / Unit" Width="95" />
<DataGridTextColumn Binding="{Binding TotalMovementCost, StringFormat={}{0:0.##}}" Header="Total Cost" Width="90" />
<DataGridTemplateColumn Header="Path" Width="*">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate DataType="{x:Type vm:RouteAllocationRowViewModel}">
<TextBlock Text="{Binding PathDescription}" TextWrapping="Wrap" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</TabItem>
</TabControl>

<TextBlock Grid.Row="4"
FontSize="13"
Expand Down
2 changes: 2 additions & 0 deletions src/MedWNetworkSim.App/Models/EdgeModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ public sealed class EdgeModel

public double Cost { get; set; }

public double? Capacity { get; set; }

public bool IsBidirectional { get; set; } = true;
}
2 changes: 2 additions & 0 deletions src/MedWNetworkSim.App/Models/TrafficTypeDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ public sealed class TrafficTypeDefinition
public string Description { get; set; } = string.Empty;

public RoutingPreference RoutingPreference { get; set; } = RoutingPreference.TotalCost;

public double? CapacityBidPerUnit { get; set; }
}
28 changes: 15 additions & 13 deletions src/MedWNetworkSim.App/Samples/sample-network.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
{
"name": "Infectious Waste",
"description": "Time-sensitive waste moved to treatment facilities.",
"routingPreference": "speed"
"routingPreference": "speed",
"capacityBidPerUnit": 1.5
},
{
"name": "Recyclables",
Expand All @@ -15,7 +16,8 @@
{
"name": "Sterile Supplies",
"description": "Traffic routed on combined time plus cost.",
"routingPreference": "totalCost"
"routingPreference": "totalCost",
"capacityBidPerUnit": 0.5
}
],
"nodes": [
Expand Down Expand Up @@ -80,16 +82,16 @@
}
],
"edges": [
{ "id": "E1", "fromNodeId": "HOSP-A", "toNodeId": "HUB-N", "time": 3.2, "cost": 4.5, "isBidirectional": true },
{ "id": "E2", "fromNodeId": "HOSP-B", "toNodeId": "HUB-N", "time": 3.8, "cost": 5.1, "isBidirectional": true },
{ "id": "E3", "fromNodeId": "HUB-N", "toNodeId": "AUTO-1", "time": 4.9, "cost": 7.2, "isBidirectional": false },
{ "id": "E4", "fromNodeId": "HUB-N", "toNodeId": "REC-1", "time": 4.0, "cost": 4.0, "isBidirectional": false },
{ "id": "E5", "fromNodeId": "HUB-N", "toNodeId": "PORT-1", "time": 5.4, "cost": 3.6, "isBidirectional": true },
{ "id": "E6", "fromNodeId": "PORT-1", "toNodeId": "REC-1", "time": 2.6, "cost": 2.0, "isBidirectional": true },
{ "id": "E7", "fromNodeId": "PORT-1", "toNodeId": "LAB-1", "time": 2.2, "cost": 6.4, "isBidirectional": true },
{ "id": "E8", "fromNodeId": "AUTO-1", "toNodeId": "PORT-1", "time": 2.0, "cost": 3.0, "isBidirectional": true },
{ "id": "E9", "fromNodeId": "AUTO-1", "toNodeId": "LAB-1", "time": 3.1, "cost": 4.2, "isBidirectional": true },
{ "id": "E10", "fromNodeId": "LAB-1", "toNodeId": "HOSP-A", "time": 2.4, "cost": 5.0, "isBidirectional": true },
{ "id": "E11", "fromNodeId": "LAB-1", "toNodeId": "HOSP-B", "time": 2.7, "cost": 5.0, "isBidirectional": true }
{ "id": "E1", "fromNodeId": "HOSP-A", "toNodeId": "HUB-N", "time": 3.2, "cost": 4.5, "capacity": 48, "isBidirectional": true },
{ "id": "E2", "fromNodeId": "HOSP-B", "toNodeId": "HUB-N", "time": 3.8, "cost": 5.1, "capacity": 34, "isBidirectional": true },
{ "id": "E3", "fromNodeId": "HUB-N", "toNodeId": "AUTO-1", "time": 4.9, "cost": 7.2, "capacity": 46, "isBidirectional": false },
{ "id": "E4", "fromNodeId": "HUB-N", "toNodeId": "REC-1", "time": 4.0, "cost": 4.0, "capacity": 18, "isBidirectional": false },
{ "id": "E5", "fromNodeId": "HUB-N", "toNodeId": "PORT-1", "time": 5.4, "cost": 3.6, "capacity": 22, "isBidirectional": true },
{ "id": "E6", "fromNodeId": "PORT-1", "toNodeId": "REC-1", "time": 2.6, "cost": 2.0, "capacity": 17, "isBidirectional": true },
{ "id": "E7", "fromNodeId": "PORT-1", "toNodeId": "LAB-1", "time": 2.2, "cost": 6.4, "capacity": 16, "isBidirectional": true },
{ "id": "E8", "fromNodeId": "AUTO-1", "toNodeId": "PORT-1", "time": 2.0, "cost": 3.0, "capacity": 20, "isBidirectional": true },
{ "id": "E9", "fromNodeId": "AUTO-1", "toNodeId": "LAB-1", "time": 3.1, "cost": 4.2, "capacity": 19, "isBidirectional": true },
{ "id": "E10", "fromNodeId": "LAB-1", "toNodeId": "HOSP-A", "time": 2.4, "cost": 5.0, "capacity": 19, "isBidirectional": true },
{ "id": "E11", "fromNodeId": "LAB-1", "toNodeId": "HOSP-B", "time": 2.7, "cost": 5.0, "capacity": 18, "isBidirectional": true }
]
}
22 changes: 22 additions & 0 deletions src/MedWNetworkSim.App/Services/ConsumerCostSummary.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace MedWNetworkSim.App.Services;

public sealed class ConsumerCostSummary
{
public string TrafficType { get; init; } = string.Empty;

public string ConsumerNodeId { get; init; } = string.Empty;

public string ConsumerName { get; init; } = string.Empty;

public double LocalQuantity { get; init; }

public double LocalUnitCost { get; init; }

public double ImportedQuantity { get; init; }

public double ImportedUnitCost { get; init; }

public double BlendedUnitCost { get; init; }

public double TotalMovementCost { get; init; }
}
17 changes: 16 additions & 1 deletion src/MedWNetworkSim.App/Services/NetworkFileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ private NetworkModel NormalizeAndValidate(NetworkModel model, bool forceLayoutAl

var fromNodeId = edge.FromNodeId.Trim();
var toNodeId = edge.ToNodeId.Trim();
var capacity = edge.Capacity;

if (!nodeIds.Contains(fromNodeId))
{
Expand All @@ -104,13 +105,19 @@ private NetworkModel NormalizeAndValidate(NetworkModel model, bool forceLayoutAl
throw new InvalidOperationException($"Duplicate edge id '{edgeId}' was found.");
}

if (capacity.HasValue && (double.IsNaN(capacity.Value) || double.IsInfinity(capacity.Value) || capacity.Value < 0d))
{
throw new InvalidOperationException($"Edge '{edgeId}' has an invalid capacity value. Use a number >= 0 or omit the property for unlimited capacity.");
}

normalizedEdges.Add(new EdgeModel
{
Id = edgeId,
FromNodeId = fromNodeId,
ToNodeId = toNodeId,
Time = edge.Time,
Cost = edge.Cost,
Capacity = capacity,
IsBidirectional = edge.IsBidirectional
});
}
Expand Down Expand Up @@ -158,11 +165,19 @@ private static List<TrafficTypeDefinition> 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
};
}

Expand Down
Loading
Loading