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.
Files changed (52) hide show
  1. gmns_opt/__init__.py +17 -0
  2. gmns_opt/applications/__init__.py +27 -0
  3. gmns_opt/benchmark/__init__.py +4 -0
  4. gmns_opt/benchmark/case.py +84 -0
  5. gmns_opt/benchmark/registry.py +129 -0
  6. gmns_opt/benchmark/report.py +44 -0
  7. gmns_opt/cli.py +86 -0
  8. gmns_opt/io/__init__.py +6 -0
  9. gmns_opt/io/build_graph.py +36 -0
  10. gmns_opt/io/read_gmns.py +88 -0
  11. gmns_opt/io/validate_gmns.py +33 -0
  12. gmns_opt/ml/__init__.py +8 -0
  13. gmns_opt/ml/branching_dataset.py +29 -0
  14. gmns_opt/ml/features.py +39 -0
  15. gmns_opt/ml/warm_start.py +30 -0
  16. gmns_opt/models/__init__.py +31 -0
  17. gmns_opt/models/accessibility.py +43 -0
  18. gmns_opt/models/facility_location.py +73 -0
  19. gmns_opt/models/max_flow.py +36 -0
  20. gmns_opt/models/min_cost_flow.py +46 -0
  21. gmns_opt/models/multimodal_skeleton.py +40 -0
  22. gmns_opt/models/network_design.py +66 -0
  23. gmns_opt/models/odme.py +86 -0
  24. gmns_opt/models/resilience.py +61 -0
  25. gmns_opt/models/shortest_path.py +32 -0
  26. gmns_opt/models/signal_timing.py +45 -0
  27. gmns_opt/models/system_optimal.py +79 -0
  28. gmns_opt/models/traffic_assignment.py +95 -0
  29. gmns_opt/scenarios/__init__.py +3 -0
  30. gmns_opt/scenarios/generator.py +50 -0
  31. gmns_opt/solvers/__init__.py +19 -0
  32. gmns_opt/solvers/base.py +20 -0
  33. gmns_opt/solvers/commercial_placeholder.py +15 -0
  34. gmns_opt/solvers/networkx_solver.py +13 -0
  35. gmns_opt/solvers/neural_placeholder.py +15 -0
  36. gmns_opt/solvers/ortools_adapter.py +13 -0
  37. gmns_opt/solvers/scipy_highs.py +14 -0
  38. gmns_opt/tensor/__init__.py +12 -0
  39. gmns_opt/tensor/control_tensor.py +12 -0
  40. gmns_opt/tensor/demand_tensor.py +44 -0
  41. gmns_opt/tensor/scenario_tensor.py +37 -0
  42. gmns_opt/tensor/schema.py +71 -0
  43. gmns_opt/visualization/__init__.py +3 -0
  44. gmns_opt/visualization/export_optimization_layers.py +54 -0
  45. gmns_opt/viz/__init__.py +5 -0
  46. gmns_opt/viz/export.py +27 -0
  47. gmnsopt-0.2.0.dist-info/METADATA +149 -0
  48. gmnsopt-0.2.0.dist-info/RECORD +52 -0
  49. gmnsopt-0.2.0.dist-info/WHEEL +5 -0
  50. gmnsopt-0.2.0.dist-info/entry_points.txt +2 -0
  51. gmnsopt-0.2.0.dist-info/licenses/LICENSE +21 -0
  52. 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,4 @@
1
+ """Benchmark harness: run a case folder (problem.yml + GMNS input) and write the standard output set."""
2
+ from .case import run_case
3
+
4
+ __all__ = ["run_case"]
@@ -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())
@@ -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
@@ -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}
@@ -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.
@@ -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)}}