gmnsopt 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gmns_opt/__init__.py +17 -0
- gmns_opt/applications/__init__.py +27 -0
- gmns_opt/benchmark/__init__.py +4 -0
- gmns_opt/benchmark/case.py +84 -0
- gmns_opt/benchmark/registry.py +129 -0
- gmns_opt/benchmark/report.py +44 -0
- gmns_opt/cli.py +86 -0
- gmns_opt/io/__init__.py +6 -0
- gmns_opt/io/build_graph.py +36 -0
- gmns_opt/io/read_gmns.py +88 -0
- gmns_opt/io/validate_gmns.py +33 -0
- gmns_opt/ml/__init__.py +8 -0
- gmns_opt/ml/branching_dataset.py +29 -0
- gmns_opt/ml/features.py +39 -0
- gmns_opt/ml/warm_start.py +30 -0
- gmns_opt/models/__init__.py +31 -0
- gmns_opt/models/accessibility.py +43 -0
- gmns_opt/models/facility_location.py +73 -0
- gmns_opt/models/max_flow.py +36 -0
- gmns_opt/models/min_cost_flow.py +46 -0
- gmns_opt/models/multimodal_skeleton.py +40 -0
- gmns_opt/models/network_design.py +66 -0
- gmns_opt/models/odme.py +86 -0
- gmns_opt/models/resilience.py +61 -0
- gmns_opt/models/shortest_path.py +32 -0
- gmns_opt/models/signal_timing.py +45 -0
- gmns_opt/models/system_optimal.py +79 -0
- gmns_opt/models/traffic_assignment.py +95 -0
- gmns_opt/scenarios/__init__.py +3 -0
- gmns_opt/scenarios/generator.py +50 -0
- gmns_opt/solvers/__init__.py +19 -0
- gmns_opt/solvers/base.py +20 -0
- gmns_opt/solvers/commercial_placeholder.py +15 -0
- gmns_opt/solvers/networkx_solver.py +13 -0
- gmns_opt/solvers/neural_placeholder.py +15 -0
- gmns_opt/solvers/ortools_adapter.py +13 -0
- gmns_opt/solvers/scipy_highs.py +14 -0
- gmns_opt/tensor/__init__.py +12 -0
- gmns_opt/tensor/control_tensor.py +12 -0
- gmns_opt/tensor/demand_tensor.py +44 -0
- gmns_opt/tensor/scenario_tensor.py +37 -0
- gmns_opt/tensor/schema.py +71 -0
- gmns_opt/visualization/__init__.py +3 -0
- gmns_opt/visualization/export_optimization_layers.py +54 -0
- gmns_opt/viz/__init__.py +5 -0
- gmns_opt/viz/export.py +27 -0
- gmnsopt-0.2.0.dist-info/METADATA +149 -0
- gmnsopt-0.2.0.dist-info/RECORD +52 -0
- gmnsopt-0.2.0.dist-info/WHEEL +5 -0
- gmnsopt-0.2.0.dist-info/entry_points.txt +2 -0
- gmnsopt-0.2.0.dist-info/licenses/LICENSE +21 -0
- gmnsopt-0.2.0.dist-info/top_level.txt +1 -0
gmns_opt/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""gmnsopt — a GMNS-native transportation optimization testbed.
|
|
2
|
+
|
|
3
|
+
Turns standardized GMNS networks + demand + operations data into reproducible optimization cases:
|
|
4
|
+
GMNS network + demand -> optimization model -> solver -> (simulation check) -> report.
|
|
5
|
+
|
|
6
|
+
Public API:
|
|
7
|
+
read_gmns, build_graph, validate_gmns (io)
|
|
8
|
+
shortest_path, min_cost_flow, traffic_assignment (models)
|
|
9
|
+
run_case (benchmark case runner)
|
|
10
|
+
"""
|
|
11
|
+
from .io import read_gmns, build_graph, validate_gmns
|
|
12
|
+
from .models import shortest_path, min_cost_flow, traffic_assignment
|
|
13
|
+
from .benchmark import run_case
|
|
14
|
+
|
|
15
|
+
__version__ = "0.2.0"
|
|
16
|
+
__all__ = ["read_gmns", "build_graph", "validate_gmns",
|
|
17
|
+
"shortest_path", "min_cost_flow", "traffic_assignment", "run_case"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Application taxonomy — the ten transportation optimization families.
|
|
2
|
+
|
|
3
|
+
Thin, registry-backed surface: the authoritative metadata lives in `gmns_opt.benchmark.registry`; this package
|
|
4
|
+
exposes it for discovery and groups families by maturity. See docs/application_taxonomy.md.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from ..benchmark.registry import FAMILIES, list_families, get_family, ProblemFamily
|
|
8
|
+
|
|
9
|
+
BY_ID = {f.id: f for f in FAMILIES}
|
|
10
|
+
FAMILY_IDS = [f.id for f in FAMILIES]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def by_maturity(maturity: str):
|
|
14
|
+
"""Families at a given maturity: 'runnable' | 'scaffold' | 'planned'."""
|
|
15
|
+
return [f for f in FAMILIES if f.maturity == maturity]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def runnable_models():
|
|
19
|
+
"""All registered runnable problem_type ids across families."""
|
|
20
|
+
out = []
|
|
21
|
+
for f in FAMILIES:
|
|
22
|
+
out.extend(f.runnable_models)
|
|
23
|
+
return sorted(set(out))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["FAMILIES", "FAMILY_IDS", "BY_ID", "list_families", "get_family", "ProblemFamily",
|
|
27
|
+
"by_maturity", "runnable_models"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Run a benchmark case: read `problem.yml` + GMNS `input/`, dispatch to a model, write `output/`.
|
|
2
|
+
|
|
3
|
+
Output set (the open-science case contract):
|
|
4
|
+
output/solution.csv per-link (or per-path) solution
|
|
5
|
+
output/objective_trace.csv iteration, objective, gap, ... (convergence)
|
|
6
|
+
output/constraint_status.csv constraint, status (feasibility)
|
|
7
|
+
output/summary.md human-readable headline + KPIs
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import os
|
|
11
|
+
import yaml
|
|
12
|
+
from ..io import read_gmns, validate_gmns
|
|
13
|
+
from ..models import (shortest_path, accessibility, min_cost_flow, max_flow, traffic_assignment,
|
|
14
|
+
system_optimal, odme, signal_timing, network_design, facility_location,
|
|
15
|
+
resilience_scenario, multimodal_skeleton)
|
|
16
|
+
from .report import write_outputs
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ml_features(net, p, cd):
|
|
20
|
+
from ..ml import extract_features
|
|
21
|
+
feat = extract_features(net, scenario=p.get("scenario", "normal"))
|
|
22
|
+
return {"objective": feat["stats"]["n_links"], "solution": [], "objective_trace": [],
|
|
23
|
+
"constraint_status": [{"constraint": "feature_extraction", "status": "ok"}],
|
|
24
|
+
"meta": {"feasible": True, "maturity": "scaffold", **feat["stats"],
|
|
25
|
+
"note": "GMNS feature blocks for ML-for-optimization (see docs/ml_for_optimization.md)"},
|
|
26
|
+
"features": feat}
|
|
27
|
+
|
|
28
|
+
# each entry: (net, params, case_dir) -> result dict
|
|
29
|
+
DISPATCH = {
|
|
30
|
+
"shortest_path": lambda net, p, cd: shortest_path(net, p["origin"], p["destination"],
|
|
31
|
+
weight=p.get("weight", "fftt_min"),
|
|
32
|
+
by_zone=p.get("by_zone", True)),
|
|
33
|
+
"accessibility": lambda net, p, cd: accessibility(net, threshold_min=p.get("threshold_min", 30.0),
|
|
34
|
+
weight=p.get("weight", "fftt_min"),
|
|
35
|
+
decay=p.get("decay", 0.0)),
|
|
36
|
+
"min_cost_flow": lambda net, p, cd: min_cost_flow(net, p["supplies"], weight=p.get("weight", "fftt_min"),
|
|
37
|
+
by_zone=p.get("by_zone", True), scale=p.get("scale", 1)),
|
|
38
|
+
"max_flow": lambda net, p, cd: max_flow(net, p["source"], p["sink"], by_zone=p.get("by_zone", True)),
|
|
39
|
+
"traffic_assignment": lambda net, p, cd: traffic_assignment(net, max_iter=p.get("max_iter", 60),
|
|
40
|
+
gap_tol=p.get("gap_tol", 1e-4),
|
|
41
|
+
by_zone=p.get("by_zone", True)),
|
|
42
|
+
"system_optimal": lambda net, p, cd: system_optimal(net, max_iter=p.get("max_iter", 80),
|
|
43
|
+
gap_tol=p.get("gap_tol", 1e-4),
|
|
44
|
+
by_zone=p.get("by_zone", True)),
|
|
45
|
+
"odme": lambda net, p, cd: odme(net, case_dir=cd, reg=p.get("reg", 0.1), by_zone=p.get("by_zone", True)),
|
|
46
|
+
"signal_timing": lambda net, p, cd: signal_timing(net, p["phases"], cycle=p.get("cycle", 90.0),
|
|
47
|
+
lost_time_per_phase=p.get("lost_time_per_phase", 4.0),
|
|
48
|
+
min_green=p.get("min_green", 7.0)),
|
|
49
|
+
"network_design": lambda net, p, cd: network_design(net, p["source"], p["sink"], p["flow"], p["budget"],
|
|
50
|
+
by_zone=p.get("by_zone", True),
|
|
51
|
+
candidate_link_ids=p.get("candidate_link_ids")),
|
|
52
|
+
"facility_location": lambda net, p, cd: facility_location(net, p["k"], candidates=p.get("candidates"),
|
|
53
|
+
weight=p.get("weight", "fftt_min")),
|
|
54
|
+
"resilience_scenario": lambda net, p, cd: resilience_scenario(net, drop_link_ids=p.get("drop_link_ids"),
|
|
55
|
+
drop=p.get("drop", 0.5), case_dir=cd,
|
|
56
|
+
by_zone=p.get("by_zone", True)),
|
|
57
|
+
"multimodal_skeleton": lambda net, p, cd: multimodal_skeleton(net, modes=p.get("modes"),
|
|
58
|
+
time_stages=p.get("time_stages", 4),
|
|
59
|
+
scenarios=p.get("scenarios")),
|
|
60
|
+
"ml_features": _ml_features,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_case(case_dir: str, write: bool = True) -> dict:
|
|
65
|
+
"""Run the case in `case_dir`. Returns the result dict; writes output/ when `write` is True."""
|
|
66
|
+
with open(os.path.join(case_dir, "problem.yml"), encoding="utf-8") as f:
|
|
67
|
+
problem = yaml.safe_load(f)
|
|
68
|
+
ptype = problem["problem_type"]
|
|
69
|
+
if ptype not in DISPATCH:
|
|
70
|
+
raise ValueError(f"unknown problem_type '{ptype}'. Available: {sorted(DISPATCH)}")
|
|
71
|
+
|
|
72
|
+
net = read_gmns(case_dir)
|
|
73
|
+
report = validate_gmns(net)
|
|
74
|
+
if not report["ok"]:
|
|
75
|
+
raise ValueError(f"GMNS validation failed for {case_dir}: {report['errors'][:5]}")
|
|
76
|
+
|
|
77
|
+
result = DISPATCH[ptype](net, problem.get("params", {}) or {}, case_dir)
|
|
78
|
+
result["problem"] = problem
|
|
79
|
+
result["network_stats"] = report["stats"]
|
|
80
|
+
if write:
|
|
81
|
+
out = os.path.join(case_dir, "output")
|
|
82
|
+
write_outputs(out, problem, result, report)
|
|
83
|
+
result["output_dir"] = out
|
|
84
|
+
return result
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Benchmark category registry — the machine-readable taxonomy of transportation optimization families.
|
|
2
|
+
|
|
3
|
+
Each `ProblemFamily` records its formulation classes, required/optional GMNS files, required outputs, default
|
|
4
|
+
solver tier, visualization type, and maturity (runnable | scaffold | planned) plus the implemented model
|
|
5
|
+
templates (`problem_type` ids usable in a case `problem.yml`). This is the single source of truth surfaced by
|
|
6
|
+
`gmns-opt list-families` / `describe-family` and by `gmns_opt.applications`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ProblemFamily:
|
|
15
|
+
id: str
|
|
16
|
+
name: str
|
|
17
|
+
formulation_classes: List[str]
|
|
18
|
+
required_files: List[str]
|
|
19
|
+
optional_files: List[str] = field(default_factory=list)
|
|
20
|
+
outputs: List[str] = field(default_factory=lambda: ["solution.csv", "objective_trace.csv",
|
|
21
|
+
"constraint_status.csv", "summary.md"])
|
|
22
|
+
default_solver_tier: int = 0
|
|
23
|
+
visualization: str = "link_flow"
|
|
24
|
+
maturity: str = "planned" # runnable | scaffold | planned
|
|
25
|
+
runnable_models: List[str] = field(default_factory=list) # registered problem_type ids
|
|
26
|
+
subtypes: List[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
FAMILIES: List[ProblemFamily] = [
|
|
30
|
+
ProblemFamily(
|
|
31
|
+
id="routing_accessibility", name="Routing & Accessibility",
|
|
32
|
+
formulation_classes=["LP", "network_flow"],
|
|
33
|
+
required_files=["node.csv", "link.csv"], optional_files=["zone.csv", "demand.csv", "transit_route.csv"],
|
|
34
|
+
default_solver_tier=0, visualization="path_highlight", maturity="runnable",
|
|
35
|
+
runnable_models=["shortest_path", "accessibility", "min_cost_flow"],
|
|
36
|
+
subtypes=["shortest_path", "multimodal_path", "accessibility", "equity_aware_access"]),
|
|
37
|
+
ProblemFamily(
|
|
38
|
+
id="traffic_assignment_pricing", name="Traffic Assignment & Pricing",
|
|
39
|
+
formulation_classes=["convex_NLP", "MIP", "dynamic", "stochastic"],
|
|
40
|
+
required_files=["node.csv", "link.csv", "demand.csv"], optional_files=["toll.csv", "scenario.csv"],
|
|
41
|
+
default_solver_tier=0, visualization="flow_height_speed_color", maturity="runnable",
|
|
42
|
+
runnable_models=["traffic_assignment", "system_optimal"],
|
|
43
|
+
subtypes=["user_equilibrium", "system_optimal", "dynamic_assignment", "congestion_pricing",
|
|
44
|
+
"managed_lane_control"]),
|
|
45
|
+
ProblemFamily(
|
|
46
|
+
id="odme_inverse_optimization", name="ODME & Inverse Optimization",
|
|
47
|
+
formulation_classes=["LP", "QP", "NLP", "stochastic", "inverse"],
|
|
48
|
+
required_files=["node.csv", "link.csv", "demand.csv"],
|
|
49
|
+
optional_files=["counts.csv", "detector.csv", "trajectory.csv", "scenario.csv"],
|
|
50
|
+
default_solver_tier=0, visualization="desire_line_delta", maturity="runnable",
|
|
51
|
+
runnable_models=["odme"],
|
|
52
|
+
subtypes=["od_matrix_estimation", "path_flow_correction", "sensor_residual_min", "demand_tensor_calibration"]),
|
|
53
|
+
ProblemFamily(
|
|
54
|
+
id="signal_queue_control", name="Signal & Queue Spillback Control",
|
|
55
|
+
formulation_classes=["MIP", "dynamic", "stochastic", "RL_ready"],
|
|
56
|
+
required_files=["node.csv", "link.csv", "demand.csv"],
|
|
57
|
+
optional_files=["movement.csv", "signal_timing.csv", "detector.csv", "scenario.csv"],
|
|
58
|
+
outputs=["solution_signal.csv", "queue_profile.csv", "constraint_status.csv", "objective_trace.csv"],
|
|
59
|
+
default_solver_tier=0, visualization="phase_clock_queue_ribbon", maturity="runnable",
|
|
60
|
+
runnable_models=["signal_timing"],
|
|
61
|
+
subtypes=["signal_timing", "green_split_offset_phase", "queue_spillback", "corridor_mpc_milp_rl"]),
|
|
62
|
+
ProblemFamily(
|
|
63
|
+
id="resilience_workzone_incident", name="Resilience, Work-Zone & Incident",
|
|
64
|
+
formulation_classes=["MIP", "stochastic", "dynamic", "robust"],
|
|
65
|
+
required_files=["node.csv", "link.csv", "demand.csv"], optional_files=["scenario.csv", "resource.csv"],
|
|
66
|
+
outputs=["scenario_summary.csv", "solution.csv", "constraint_status.csv", "objective_trace.csv"],
|
|
67
|
+
default_solver_tier=0, visualization="warning_layer_before_after", maturity="runnable",
|
|
68
|
+
runnable_models=["resilience_scenario", "max_flow"],
|
|
69
|
+
subtypes=["incident_response", "work_zone_scheduling", "extreme_heat_weather_flood",
|
|
70
|
+
"evacuation_recovery"]),
|
|
71
|
+
ProblemFamily(
|
|
72
|
+
id="cav_control", name="CAV Mixed-Traffic Control",
|
|
73
|
+
formulation_classes=["MIP", "QP", "NLP", "stochastic", "dynamic", "RL"],
|
|
74
|
+
required_files=["node.csv", "link.csv", "demand.csv"],
|
|
75
|
+
optional_files=["scenario.csv", "penetration.csv", "control_zone.csv"],
|
|
76
|
+
outputs=["decision_variable.csv", "constraint_status.csv", "objective_trace.csv", "summary.md"],
|
|
77
|
+
default_solver_tier=2, visualization="control_zone_speed", maturity="scaffold",
|
|
78
|
+
runnable_models=["multimodal_skeleton"],
|
|
79
|
+
subtypes=["mixed_traffic", "speed_advisory", "platooning", "lane_use_control", "ramp_metering",
|
|
80
|
+
"communication_failure"]),
|
|
81
|
+
ProblemFamily(
|
|
82
|
+
id="uam_air_ground_coordination", name="UAM Air–Ground Coordination",
|
|
83
|
+
formulation_classes=["MIP", "stochastic", "dynamic", "RL"],
|
|
84
|
+
required_files=["node.csv", "link.csv", "demand.csv"],
|
|
85
|
+
optional_files=["vertiport.csv", "air_link.csv", "weather_scenario.csv", "charging_station.csv"],
|
|
86
|
+
outputs=["decision_variable.csv", "constraint_status.csv", "objective_trace.csv", "summary.md"],
|
|
87
|
+
default_solver_tier=2, visualization="vertiport_queue_3d", maturity="scaffold",
|
|
88
|
+
runnable_models=["multimodal_skeleton"],
|
|
89
|
+
subtypes=["vertiport_capacity", "dispatch_repositioning", "weather_uncertainty", "battery_charging",
|
|
90
|
+
"ground_access_integration"]),
|
|
91
|
+
ProblemFamily(
|
|
92
|
+
id="freight_ev_logistics", name="Freight, EV & Logistics Routing",
|
|
93
|
+
formulation_classes=["MIP", "stochastic", "dynamic"],
|
|
94
|
+
required_files=["node.csv", "link.csv"],
|
|
95
|
+
optional_files=["vehicle.csv", "charging_station.csv", "delivery_demand.csv", "scenario.csv"],
|
|
96
|
+
default_solver_tier=1, visualization="route_energy", maturity="scaffold",
|
|
97
|
+
runnable_models=["facility_location"],
|
|
98
|
+
subtypes=["vrp", "ev_routing_charging", "time_windows", "curb_depot_fleet", "truck_drone_uam"]),
|
|
99
|
+
ProblemFamily(
|
|
100
|
+
id="transit_frequency_accessibility", name="Transit Frequency & Accessibility",
|
|
101
|
+
formulation_classes=["MIP", "LP", "stochastic"],
|
|
102
|
+
required_files=["node.csv", "link.csv", "demand.csv"],
|
|
103
|
+
optional_files=["transit_route.csv", "fleet.csv", "scenario.csv"],
|
|
104
|
+
default_solver_tier=1, visualization="frequency_accessibility", maturity="planned",
|
|
105
|
+
runnable_models=[],
|
|
106
|
+
subtypes=["bus_frequency", "fleet_allocation", "first_last_mile", "transfer_coordination",
|
|
107
|
+
"equity_service"]),
|
|
108
|
+
ProblemFamily(
|
|
109
|
+
id="solver_learning_and_neural_optimization", name="Solver Learning & Neural Optimization",
|
|
110
|
+
formulation_classes=["ML", "RL", "surrogate"],
|
|
111
|
+
required_files=["node.csv", "link.csv", "demand.csv"], optional_files=["scenario.csv"],
|
|
112
|
+
outputs=["features.json", "warm_start.csv", "objective_trace.csv", "summary.md"],
|
|
113
|
+
default_solver_tier=3, visualization="convergence_panel", maturity="scaffold",
|
|
114
|
+
runnable_models=["ml_features"],
|
|
115
|
+
subtypes=["learning_to_warm_start", "learning_to_branch", "graph_neural_features", "neural_surrogate",
|
|
116
|
+
"rl_policy_interface", "train_val_test_city_split"]),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
_BY_ID = {f.id: f for f in FAMILIES}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def list_families() -> List[ProblemFamily]:
|
|
123
|
+
return list(FAMILIES)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_family(family_id: str) -> ProblemFamily:
|
|
127
|
+
if family_id not in _BY_ID:
|
|
128
|
+
raise KeyError(f"unknown family '{family_id}'. Known: {sorted(_BY_ID)}")
|
|
129
|
+
return _BY_ID[family_id]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Write the standard case output set (solution / objective_trace / constraint_status / summary.md)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import csv
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _write_rows(path, rows):
|
|
8
|
+
if not rows:
|
|
9
|
+
open(path, "w").close(); return
|
|
10
|
+
keys = list(rows[0].keys())
|
|
11
|
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
12
|
+
w = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore"); w.writeheader(); w.writerows(rows)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def write_outputs(out_dir, problem, result, validation):
|
|
16
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
17
|
+
_write_rows(os.path.join(out_dir, "solution.csv"), result.get("solution") or
|
|
18
|
+
([{"path_node": n} for n in result.get("path", [])]))
|
|
19
|
+
_write_rows(os.path.join(out_dir, "objective_trace.csv"), result.get("objective_trace", []))
|
|
20
|
+
_write_rows(os.path.join(out_dir, "constraint_status.csv"), result.get("constraint_status", []))
|
|
21
|
+
if result.get("scenario_summary"):
|
|
22
|
+
_write_rows(os.path.join(out_dir, "scenario_summary.csv"), result["scenario_summary"])
|
|
23
|
+
if result.get("solution") and isinstance(result["solution"], list) and result["solution"] \
|
|
24
|
+
and isinstance(result["solution"][0], dict) and "variable" in result["solution"][0]:
|
|
25
|
+
_write_rows(os.path.join(out_dir, "decision_variable.csv"), result["solution"])
|
|
26
|
+
|
|
27
|
+
st = result.get("network_stats", {}); meta = result.get("meta", {})
|
|
28
|
+
lines = [f"# {problem.get('problem_name', 'case')} — result summary\n",
|
|
29
|
+
f"- **problem_type:** `{problem.get('problem_type')}`",
|
|
30
|
+
f"- **objective:** {result.get('objective')}",
|
|
31
|
+
f"- **network:** {st.get('nodes')} nodes, {st.get('links')} links, {st.get('zones')} zones, "
|
|
32
|
+
f"{st.get('od_pairs')} OD pairs (demand {st.get('total_demand')})"]
|
|
33
|
+
for k, v in meta.items():
|
|
34
|
+
lines.append(f"- **{k}:** {v}")
|
|
35
|
+
if result.get("objective_trace"):
|
|
36
|
+
last = result["objective_trace"][-1]
|
|
37
|
+
lines.append(f"- **converged trace (last):** {last}")
|
|
38
|
+
if validation.get("warnings"):
|
|
39
|
+
lines.append(f"\n> validation warnings: {len(validation['warnings'])} "
|
|
40
|
+
f"(e.g. {validation['warnings'][0]})")
|
|
41
|
+
lines.append("\n*Outputs:* `solution.csv`, `objective_trace.csv`, `constraint_status.csv`. "
|
|
42
|
+
"Visualize with `gmns_opt.viz` / GUI4GMNS.")
|
|
43
|
+
with open(os.path.join(out_dir, "summary.md"), "w", encoding="utf-8") as f:
|
|
44
|
+
f.write("\n".join(lines) + "\n")
|
gmns_opt/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Command-line entry point.
|
|
2
|
+
|
|
3
|
+
gmns-opt run <case_dir> run a benchmark case (problem.yml + input/)
|
|
4
|
+
gmns-opt validate <case_dir> validate a GMNS case against the data contract
|
|
5
|
+
gmns-opt list-families list the problem-family taxonomy
|
|
6
|
+
gmns-opt describe-family <family_id> describe one problem family
|
|
7
|
+
gmns-opt generate-scenarios --case ... write a scenario.csv perturbation
|
|
8
|
+
gmns-opt solver-status report available solver tiers
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv=None):
|
|
17
|
+
ap = argparse.ArgumentParser(prog="gmns-opt", description="GMNS-native transportation optimization testbed")
|
|
18
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
19
|
+
sub.add_parser("run", help="run a benchmark case folder").add_argument("case_dir")
|
|
20
|
+
sub.add_parser("validate", help="validate a GMNS case").add_argument("case_dir")
|
|
21
|
+
sub.add_parser("list-families", help="list the problem-family taxonomy")
|
|
22
|
+
sub.add_parser("describe-family", help="describe one problem family").add_argument("family_id")
|
|
23
|
+
sub.add_parser("solver-status", help="report available solver tiers")
|
|
24
|
+
g = sub.add_parser("generate-scenarios", help="write a scenario.csv perturbation for a case")
|
|
25
|
+
g.add_argument("--case", required=True)
|
|
26
|
+
g.add_argument("--type", default="capacity_drop")
|
|
27
|
+
g.add_argument("--links", default="", help="comma-separated link ids")
|
|
28
|
+
g.add_argument("--drop", type=float, default=0.5)
|
|
29
|
+
g.add_argument("--factor", type=float, default=1.3)
|
|
30
|
+
g.add_argument("--seed", type=int, default=12345)
|
|
31
|
+
args = ap.parse_args(argv)
|
|
32
|
+
|
|
33
|
+
if args.cmd == "validate":
|
|
34
|
+
from .io import read_gmns, validate_gmns
|
|
35
|
+
rep = validate_gmns(read_gmns(args.case_dir))
|
|
36
|
+
print("OK" if rep["ok"] else "ERRORS", "|", rep["stats"])
|
|
37
|
+
for e in rep["errors"][:10]:
|
|
38
|
+
print(" error:", e)
|
|
39
|
+
return 0 if rep["ok"] else 1
|
|
40
|
+
|
|
41
|
+
if args.cmd == "run":
|
|
42
|
+
from .benchmark import run_case
|
|
43
|
+
res = run_case(args.case_dir)
|
|
44
|
+
print(f"[{os.path.basename(os.path.normpath(args.case_dir))}] objective = {res.get('objective')} | "
|
|
45
|
+
f"{res.get('meta', {})}")
|
|
46
|
+
print(f"outputs -> {res.get('output_dir')}")
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
if args.cmd == "list-families":
|
|
50
|
+
from .benchmark.registry import list_families
|
|
51
|
+
print(f"{'family_id':38s}{'maturity':10s}{'classes'}")
|
|
52
|
+
for f in list_families():
|
|
53
|
+
print(f"{f.id:38s}{f.maturity:10s}{','.join(f.formulation_classes)}")
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
if args.cmd == "describe-family":
|
|
57
|
+
from .benchmark.registry import get_family
|
|
58
|
+
f = get_family(args.family_id)
|
|
59
|
+
print(f"# {f.name} ({f.id}) [maturity: {f.maturity}]")
|
|
60
|
+
print(f"formulation_classes : {', '.join(f.formulation_classes)}")
|
|
61
|
+
print(f"required_files : {', '.join(f.required_files)}")
|
|
62
|
+
print(f"optional_files : {', '.join(f.optional_files)}")
|
|
63
|
+
print(f"outputs : {', '.join(f.outputs)}")
|
|
64
|
+
print(f"default_solver_tier : {f.default_solver_tier}")
|
|
65
|
+
print(f"visualization : {f.visualization}")
|
|
66
|
+
print(f"runnable_models : {', '.join(f.runnable_models) or '(none yet)'}")
|
|
67
|
+
print(f"subtypes : {', '.join(f.subtypes)}")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
if args.cmd == "solver-status":
|
|
71
|
+
from .solvers import solver_status
|
|
72
|
+
for s in solver_status():
|
|
73
|
+
print(f"tier {s['tier']} {s['name']:14s} available={s['available']} {','.join(s['classes'])}")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
if args.cmd == "generate-scenarios":
|
|
77
|
+
from .scenarios import generate_scenarios
|
|
78
|
+
links = [int(x) for x in args.links.split(",") if x.strip()] or None
|
|
79
|
+
path = generate_scenarios(args.case, scenario_type=args.type, links=links, drop=args.drop,
|
|
80
|
+
factor=args.factor, seed=args.seed)
|
|
81
|
+
print(f"wrote {path}")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
sys.exit(main())
|
gmns_opt/io/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""GMNS I/O: read node/link/demand, validate against the data contract, build a routable graph."""
|
|
2
|
+
from .read_gmns import read_gmns, GMNSNetwork
|
|
3
|
+
from .build_graph import build_graph
|
|
4
|
+
from .validate_gmns import validate_gmns
|
|
5
|
+
|
|
6
|
+
__all__ = ["read_gmns", "GMNSNetwork", "build_graph", "validate_gmns"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Build a routable networkx DiGraph from a GMNSNetwork (free-flow travel time as default weight)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import networkx as nx
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_graph(net, weight: str = "fftt_min"):
|
|
7
|
+
"""Return a networkx.DiGraph. Each edge carries link attributes + `weight` (default free-flow minutes).
|
|
8
|
+
|
|
9
|
+
Parallel links (same from/to) are collapsed to the lowest-cost link for simple-graph algorithms; the
|
|
10
|
+
full link list is preserved on the graph as `graph['links']` for flow models that need every link.
|
|
11
|
+
"""
|
|
12
|
+
G = nx.DiGraph()
|
|
13
|
+
for nid, a in net.nodes.items():
|
|
14
|
+
G.add_node(nid, **a)
|
|
15
|
+
best = {}
|
|
16
|
+
for lk in net.links:
|
|
17
|
+
key = (lk.from_node, lk.to_node)
|
|
18
|
+
w = getattr(lk, weight) if hasattr(lk, weight) else lk.fftt_min
|
|
19
|
+
if key not in best or w < best[key][0]:
|
|
20
|
+
best[key] = (w, lk)
|
|
21
|
+
for (u, v), (w, lk) in best.items():
|
|
22
|
+
G.add_edge(u, v, weight=w, link_id=lk.link_id, capacity=lk.capacity,
|
|
23
|
+
fftt_min=lk.fftt_min, length=lk.length, alpha=lk.alpha, beta=lk.beta)
|
|
24
|
+
G.graph["links"] = net.links
|
|
25
|
+
G.graph["nodes"] = net.nodes
|
|
26
|
+
return G
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def zone_to_node(net):
|
|
30
|
+
"""Map zone_id -> node_id (a zone is anchored at the node that declares it)."""
|
|
31
|
+
z2n = {}
|
|
32
|
+
for nid, a in net.nodes.items():
|
|
33
|
+
z = a.get("zone_id")
|
|
34
|
+
if z is not None and z not in z2n:
|
|
35
|
+
z2n[z] = nid
|
|
36
|
+
return z2n
|
gmns_opt/io/read_gmns.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Read a GMNS case directory (node.csv, link.csv, demand.csv) into plain Python structures.
|
|
2
|
+
|
|
3
|
+
GMNS reference: https://github.com/zephyr-data-specs/GMNS . We use the minimal routable subset:
|
|
4
|
+
node.csv : node_id [, zone_id, x_coord, y_coord]
|
|
5
|
+
link.csv : link_id, from_node_id, to_node_id [, length, lanes, free_speed, capacity, vdf_alpha, vdf_beta]
|
|
6
|
+
demand.csv: o_zone_id, d_zone_id, volume
|
|
7
|
+
Free-flow travel time is derived as length / free_speed when not given (consistent units are the caller's
|
|
8
|
+
responsibility; the bundled cases use miles and mph -> hours, reported in minutes).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import csv
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _num(v, default=0.0):
|
|
17
|
+
try:
|
|
18
|
+
return float(v)
|
|
19
|
+
except (TypeError, ValueError):
|
|
20
|
+
return default
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Link:
|
|
25
|
+
link_id: int
|
|
26
|
+
from_node: int
|
|
27
|
+
to_node: int
|
|
28
|
+
length: float = 1.0
|
|
29
|
+
lanes: float = 1.0
|
|
30
|
+
free_speed: float = 30.0
|
|
31
|
+
capacity: float = 1000.0 # per the link (already lane-aggregated in the bundled cases)
|
|
32
|
+
alpha: float = 0.15 # BPR alpha
|
|
33
|
+
beta: float = 4.0 # BPR beta
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def fftt_min(self) -> float:
|
|
37
|
+
"""free-flow travel time in minutes (length[mi] / speed[mph] * 60)."""
|
|
38
|
+
return (self.length / max(self.free_speed, 1e-6)) * 60.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class GMNSNetwork:
|
|
43
|
+
nodes: dict = field(default_factory=dict) # node_id -> {zone_id, x, y}
|
|
44
|
+
links: list = field(default_factory=list) # list[Link]
|
|
45
|
+
demand: list = field(default_factory=list) # list[(o_zone, d_zone, volume)]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def zones(self):
|
|
49
|
+
z = {n["zone_id"] for n in self.nodes.values() if n.get("zone_id")}
|
|
50
|
+
return sorted(z)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_gmns(case_dir: str) -> GMNSNetwork:
|
|
54
|
+
"""Read node.csv / link.csv / demand.csv from `case_dir` (or `case_dir/input`)."""
|
|
55
|
+
base = case_dir
|
|
56
|
+
if os.path.isdir(os.path.join(case_dir, "input")):
|
|
57
|
+
base = os.path.join(case_dir, "input")
|
|
58
|
+
net = GMNSNetwork()
|
|
59
|
+
|
|
60
|
+
with open(os.path.join(base, "node.csv"), encoding="utf-8-sig") as f:
|
|
61
|
+
for r in csv.DictReader(f):
|
|
62
|
+
nid = int(_num(r.get("node_id")))
|
|
63
|
+
z = r.get("zone_id")
|
|
64
|
+
net.nodes[nid] = {"zone_id": (int(_num(z)) if z not in (None, "", "0") else None),
|
|
65
|
+
"x": _num(r.get("x_coord")), "y": _num(r.get("y_coord"))}
|
|
66
|
+
|
|
67
|
+
with open(os.path.join(base, "link.csv"), encoding="utf-8-sig") as f:
|
|
68
|
+
for r in csv.DictReader(f):
|
|
69
|
+
net.links.append(Link(
|
|
70
|
+
link_id=int(_num(r.get("link_id"))),
|
|
71
|
+
from_node=int(_num(r.get("from_node_id"))),
|
|
72
|
+
to_node=int(_num(r.get("to_node_id"))),
|
|
73
|
+
length=_num(r.get("length"), 1.0) or 1.0,
|
|
74
|
+
lanes=_num(r.get("lanes"), 1.0) or 1.0,
|
|
75
|
+
free_speed=_num(r.get("free_speed"), 30.0) or 30.0,
|
|
76
|
+
capacity=_num(r.get("capacity"), 1000.0) or 1000.0,
|
|
77
|
+
alpha=_num(r.get("vdf_alpha"), 0.15) or 0.15,
|
|
78
|
+
beta=_num(r.get("vdf_beta"), 4.0) or 4.0))
|
|
79
|
+
|
|
80
|
+
dpath = os.path.join(base, "demand.csv")
|
|
81
|
+
if os.path.exists(dpath):
|
|
82
|
+
with open(dpath, encoding="utf-8-sig") as f:
|
|
83
|
+
for r in csv.DictReader(f):
|
|
84
|
+
vol = _num(r.get("volume"))
|
|
85
|
+
if vol > 0:
|
|
86
|
+
net.demand.append((int(_num(r.get("o_zone_id"))),
|
|
87
|
+
int(_num(r.get("d_zone_id"))), vol))
|
|
88
|
+
return net
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Validate a GMNSNetwork against the minimal data contract; report problems instead of crashing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def validate_gmns(net) -> dict:
|
|
6
|
+
"""Return a report dict: {ok, errors, warnings, stats}. `ok` is False if any error is found."""
|
|
7
|
+
errors, warnings = [], []
|
|
8
|
+
node_ids = set(net.nodes)
|
|
9
|
+
|
|
10
|
+
if not net.links:
|
|
11
|
+
errors.append("no links")
|
|
12
|
+
for lk in net.links:
|
|
13
|
+
if lk.from_node not in node_ids:
|
|
14
|
+
errors.append(f"link {lk.link_id}: from_node {lk.from_node} not in node.csv")
|
|
15
|
+
if lk.to_node not in node_ids:
|
|
16
|
+
errors.append(f"link {lk.link_id}: to_node {lk.to_node} not in node.csv")
|
|
17
|
+
if lk.capacity <= 0:
|
|
18
|
+
warnings.append(f"link {lk.link_id}: non-positive capacity")
|
|
19
|
+
if lk.free_speed <= 0:
|
|
20
|
+
warnings.append(f"link {lk.link_id}: non-positive free_speed")
|
|
21
|
+
|
|
22
|
+
zones = set(net.zones)
|
|
23
|
+
for (o, d, v) in net.demand:
|
|
24
|
+
if o not in zones:
|
|
25
|
+
warnings.append(f"demand origin zone {o} has no anchoring node")
|
|
26
|
+
if d not in zones:
|
|
27
|
+
warnings.append(f"demand destination zone {d} has no anchoring node")
|
|
28
|
+
|
|
29
|
+
stats = {"nodes": len(net.nodes), "links": len(net.links), "zones": len(zones),
|
|
30
|
+
"od_pairs": len(net.demand), "total_demand": sum(v for *_, v in net.demand)}
|
|
31
|
+
# de-duplicate but keep order
|
|
32
|
+
errors = list(dict.fromkeys(errors)); warnings = list(dict.fromkeys(warnings))[:50]
|
|
33
|
+
return {"ok": not errors, "errors": errors, "warnings": warnings, "stats": stats}
|
gmns_opt/ml/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""ML-for-optimization readiness: feature extraction, warm-start interface, learning-to-branch dataset.
|
|
2
|
+
ML SUPPORTS solvers and repeated scenarios; it does not replace rigorous optimization. See
|
|
3
|
+
docs/ml_for_optimization.md."""
|
|
4
|
+
from .features import extract_features
|
|
5
|
+
from .warm_start import WarmStarter
|
|
6
|
+
from .branching_dataset import BranchRecord, write_branching_dataset
|
|
7
|
+
|
|
8
|
+
__all__ = ["extract_features", "WarmStarter", "BranchRecord", "write_branching_dataset"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Learning-to-branch dataset scaffold (see docs/ml_for_optimization.md).
|
|
2
|
+
|
|
3
|
+
Collects (state, candidate, label) records from MILP solves for imitation of strong branching. This scaffold
|
|
4
|
+
defines the record schema and a writer; wiring it to scipy.milp / commercial callbacks is a TODO."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from dataclasses import dataclass, asdict
|
|
7
|
+
from typing import List
|
|
8
|
+
import csv
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class BranchRecord:
|
|
13
|
+
instance_id: str
|
|
14
|
+
node_id: int
|
|
15
|
+
variable_index: int
|
|
16
|
+
fractionality: float
|
|
17
|
+
pseudo_cost: float
|
|
18
|
+
strong_branch_score: float # imitation label
|
|
19
|
+
chosen: int # 1 if selected by expert
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_branching_dataset(records: List[BranchRecord], path: str) -> str:
|
|
23
|
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
24
|
+
w = csv.DictWriter(f, fieldnames=list(BranchRecord.__annotations__))
|
|
25
|
+
w.writeheader()
|
|
26
|
+
for r in records:
|
|
27
|
+
w.writerow(asdict(r))
|
|
28
|
+
return path
|
|
29
|
+
# TODO: collect records from MILP callbacks; train imitation model; evaluate with train/val/test CITY split.
|
gmns_opt/ml/features.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Feature extraction from a GMNS network for ML-for-optimization (see docs/ml_for_optimization.md).
|
|
2
|
+
|
|
3
|
+
Produces node / link / demand / graph-adjacency features usable by GNN encoders, learning-to-warm-start, and
|
|
4
|
+
learning-to-branch. This does NOT replace solvers — it supports repeated-scenario workflows.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def extract_features(net, scenario: str = "normal") -> dict:
|
|
12
|
+
"""Return numeric feature blocks + adjacency for the network (dependency-light, numpy only)."""
|
|
13
|
+
node_ids = sorted(net.nodes)
|
|
14
|
+
nidx = {n: i for i, n in enumerate(node_ids)}
|
|
15
|
+
indeg = defaultdict(int); outdeg = defaultdict(int)
|
|
16
|
+
link_feat = []
|
|
17
|
+
for lk in net.links:
|
|
18
|
+
outdeg[lk.from_node] += 1; indeg[lk.to_node] += 1
|
|
19
|
+
link_feat.append([lk.length, lk.lanes, lk.free_speed, lk.capacity, lk.fftt_min, lk.alpha, lk.beta])
|
|
20
|
+
node_feat = []
|
|
21
|
+
for n in node_ids:
|
|
22
|
+
a = net.nodes[n]
|
|
23
|
+
node_feat.append([a.get("x", 0.0), a.get("y", 0.0), float(a.get("zone_id") is not None),
|
|
24
|
+
indeg[n], outdeg[n]])
|
|
25
|
+
prod = defaultdict(float); attr = defaultdict(float)
|
|
26
|
+
for (o, d, v) in net.demand:
|
|
27
|
+
prod[o] += v; attr[d] += v
|
|
28
|
+
demand_feat = {"total": sum(v for *_, v in net.demand), "n_od": len(net.demand),
|
|
29
|
+
"max_production": max(prod.values()) if prod else 0.0,
|
|
30
|
+
"max_attraction": max(attr.values()) if attr else 0.0}
|
|
31
|
+
edge_index = [[nidx[lk.from_node], nidx[lk.to_node]] for lk in net.links]
|
|
32
|
+
return {"scenario": scenario,
|
|
33
|
+
"node_features": np.asarray(node_feat, float).tolist(),
|
|
34
|
+
"node_feature_names": ["x", "y", "is_zone", "in_degree", "out_degree"],
|
|
35
|
+
"link_features": np.asarray(link_feat, float).tolist(),
|
|
36
|
+
"link_feature_names": ["length", "lanes", "free_speed", "capacity", "fftt_min", "alpha", "beta"],
|
|
37
|
+
"demand_features": demand_feat,
|
|
38
|
+
"edge_index": edge_index,
|
|
39
|
+
"stats": {"n_nodes": len(node_ids), "n_links": len(net.links)}}
|