qellaris 0.5.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 (86) hide show
  1. qellaris/__init__.py +40 -0
  2. qellaris/__main__.py +111 -0
  3. qellaris/_version.py +3 -0
  4. qellaris/cli.py +230 -0
  5. qellaris/control_env.py +504 -0
  6. qellaris/data/kensington_buildings.json +356793 -0
  7. qellaris/data/kensington_landuse.json +51216 -0
  8. qellaris/data/kensington_streets.json +55913 -0
  9. qellaris/data/kensington_towers.json +1 -0
  10. qellaris/decision_metrics.py +70 -0
  11. qellaris/demographics.py +316 -0
  12. qellaris/domain.py +171 -0
  13. qellaris/equity.py +627 -0
  14. qellaris/equity_scenarios.py +230 -0
  15. qellaris/evaluation.py +260 -0
  16. qellaris/experiment.py +518 -0
  17. qellaris/forecasting.py +217 -0
  18. qellaris/geography.py +409 -0
  19. qellaris/grid.py +420 -0
  20. qellaris/hardware_job.py +113 -0
  21. qellaris/inventory.py +103 -0
  22. qellaris/menu.py +210 -0
  23. qellaris/milp_reference.py +56 -0
  24. qellaris/netbravo.py +442 -0
  25. qellaris/ofcom.py +231 -0
  26. qellaris/optimization.py +332 -0
  27. qellaris/paths.py +98 -0
  28. qellaris/placement.py +245 -0
  29. qellaris/power.py +171 -0
  30. qellaris/quantum.py +514 -0
  31. qellaris/quantum_zoo.py +594 -0
  32. qellaris/qubo.py +152 -0
  33. qellaris/radio.py +189 -0
  34. qellaris/real_data.py +98 -0
  35. qellaris/region_packages.py +202 -0
  36. qellaris/regions.py +519 -0
  37. qellaris/rl.py +237 -0
  38. qellaris/road_network.py +197 -0
  39. qellaris/scenario_generation.py +102 -0
  40. qellaris/sdk.py +195 -0
  41. qellaris/simulation.py +928 -0
  42. qellaris/solara_app.py +2255 -0
  43. qellaris/solvers.py +375 -0
  44. qellaris/template.py +736 -0
  45. qellaris/tests/__init__.py +1 -0
  46. qellaris/tests/conftest.py +8 -0
  47. qellaris/tests/test_benchmark.py +182 -0
  48. qellaris/tests/test_demographics.py +261 -0
  49. qellaris/tests/test_demographics_page.py +114 -0
  50. qellaris/tests/test_entry_points.py +194 -0
  51. qellaris/tests/test_equity.py +67 -0
  52. qellaris/tests/test_equity_scenarios.py +307 -0
  53. qellaris/tests/test_equity_state.py +152 -0
  54. qellaris/tests/test_evaluation.py +144 -0
  55. qellaris/tests/test_geometry.py +121 -0
  56. qellaris/tests/test_hardware_job.py +38 -0
  57. qellaris/tests/test_inventory.py +114 -0
  58. qellaris/tests/test_land_use.py +222 -0
  59. qellaris/tests/test_launcher.py +88 -0
  60. qellaris/tests/test_layout.py +103 -0
  61. qellaris/tests/test_layout_factorial_archive.py +43 -0
  62. qellaris/tests/test_menu.py +73 -0
  63. qellaris/tests/test_milp_reference.py +28 -0
  64. qellaris/tests/test_netbravo.py +73 -0
  65. qellaris/tests/test_netbravo_ingest.py +156 -0
  66. qellaris/tests/test_paths.py +139 -0
  67. qellaris/tests/test_placement.py +110 -0
  68. qellaris/tests/test_power.py +213 -0
  69. qellaris/tests/test_quantum.py +219 -0
  70. qellaris/tests/test_quantum_zoo.py +101 -0
  71. qellaris/tests/test_radio.py +57 -0
  72. qellaris/tests/test_region_packages.py +130 -0
  73. qellaris/tests/test_regions.py +231 -0
  74. qellaris/tests/test_rl_env.py +159 -0
  75. qellaris/tests/test_scenario_generation.py +38 -0
  76. qellaris/tests/test_sdk.py +60 -0
  77. qellaris/tests/test_study_area_page.py +149 -0
  78. qellaris/tests/test_template.py +218 -0
  79. qellaris/tests/test_ui_model.py +157 -0
  80. qellaris/ui.py +198 -0
  81. qellaris-0.5.0.dist-info/METADATA +505 -0
  82. qellaris-0.5.0.dist-info/RECORD +86 -0
  83. qellaris-0.5.0.dist-info/WHEEL +5 -0
  84. qellaris-0.5.0.dist-info/entry_points.txt +13 -0
  85. qellaris-0.5.0.dist-info/licenses/LICENSE +25 -0
  86. qellaris-0.5.0.dist-info/top_level.txt +1 -0
qellaris/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """QAIO cellular demand forecasting and sector-scheduling benchmark.
2
+
3
+ Three things come out of a run, and they are kept apart on purpose:
4
+
5
+ * the **schedule and its cost** - energy, unmet demand, switching, the scalar
6
+ every solver is ranked on (:mod:`~qellaris.experiment`);
7
+ * the **demographic state** - who the population is and what it is doing,
8
+ never what it received (:mod:`~qellaris.demographics`);
9
+ * the **equity output** - synthetic group diagnostics under named scenario
10
+ levers (:mod:`~qellaris.equity`, :mod:`~qellaris.equity_scenarios`),
11
+ not a census fairness claim.
12
+ """
13
+
14
+ from .control_env import TransmitterControlEnv
15
+ from .demographics import (ACTIVITIES, DEMOGRAPHIC_AXES, DemographicTracker,
16
+ format_state)
17
+ from .equity import (EQUITY_SCENARIOS, EquityScenario, equity_output,
18
+ get_equity_scenario)
19
+ from .equity_scenarios import (ScenarioConfig, format_scenarios,
20
+ run_equity_scenarios, run_scenario)
21
+ from .experiment import ExperimentConfig, run_experiment
22
+ from .regions import RegionSpec, download_osm_region, find_region
23
+ from .inventory import load_fixed_inventory
24
+ from .rl import SectorPolicy, train_policy
25
+ from .simulation import GridDemandModel
26
+ from .sdk import (BenchmarkState, JobBroker, register_agent,
27
+ register_forecaster, score_action, state_from_model)
28
+ from .solvers import SOLVER_NAMES, get_solver
29
+ from ._version import __version__
30
+
31
+ __all__ = ["ACTIVITIES", "DEMOGRAPHIC_AXES", "EQUITY_SCENARIOS",
32
+ "BenchmarkState", "ExperimentConfig", "EquityScenario", "GridDemandModel",
33
+ "DemographicTracker", "RegionSpec", "SOLVER_NAMES", "ScenarioConfig",
34
+ "JobBroker", "SectorPolicy", "TransmitterControlEnv", "__version__",
35
+ "download_osm_region", "load_fixed_inventory",
36
+ "equity_output", "find_region", "format_scenarios", "format_state",
37
+ "get_equity_scenario", "get_solver", "run_equity_scenarios",
38
+ "register_agent", "register_forecaster",
39
+ "run_experiment", "run_scenario",
40
+ "score_action", "state_from_model", "train_policy"]
qellaris/__main__.py ADDED
@@ -0,0 +1,111 @@
1
+ """One command for the whole package: ``python -m qellaris <subcommand>``.
2
+
3
+ Everything the project can do used to be reached three different ways -- a
4
+ console script, a module, or a loose file in the repository root that fixed up
5
+ ``sys.path`` and then imported the real code. The root scripts existed only
6
+ because the package was not importable without help, and each carried its own
7
+ copy of the bootstrap.
8
+
9
+ That bootstrap now lives in :mod:`qellaris.ui` and runs here, so this module
10
+ is the single door in. The root ``run_ui.py`` and friends stay as three-line
11
+ shims that call straight into it, because PyCharm run configurations point at
12
+ them by path.
13
+
14
+ python -m qellaris ui
15
+ python -m qellaris benchmark --geography kensington
16
+ python -m qellaris equity --scenarios protective neutral
17
+ python -m qellaris region camden --place "Camden, London" --radius-km 1
18
+ python -m qellaris template --explain
19
+
20
+ Each subcommand is the same function its console script points at, so there is
21
+ one implementation and no drift between the two ways of calling it.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import sys
27
+
28
+
29
+ def _ui(argv: list[str]) -> None:
30
+ import argparse
31
+
32
+ from .ui import main
33
+
34
+ parser = argparse.ArgumentParser(
35
+ prog="python -m qellaris ui",
36
+ description="Launch the dashboard in this process, so Stop stops it "
37
+ "and breakpoints in the model bind.")
38
+ parser.add_argument("--host", default="127.0.0.1")
39
+ parser.add_argument("--port", type=int, default=8765)
40
+ parser.add_argument("--no-browser", action="store_true",
41
+ help="do not open a browser window")
42
+ args = parser.parse_args(argv)
43
+ main(args.host, args.port, not args.no_browser)
44
+
45
+
46
+ #: ``subcommand -> (loader, one-line help)``. Loaders import lazily: starting
47
+ #: the dashboard should not pay for importing the quantum backends, and the
48
+ #: benchmark should not pay for importing Solara.
49
+ def _commands():
50
+ from . import (cli, hardware_job, menu, netbravo, quantum_zoo, real_data,
51
+ region_packages, regions, template)
52
+
53
+ return {
54
+ "menu": (menu.main, "guided menu: answer a few questions, it runs the command"),
55
+ "ui": (_ui, "launch the interactive dashboard"),
56
+ "benchmark": (cli.main, "run the forecasting and scheduling benchmark"),
57
+ "equity": (cli.equity_main, "compare equity scenarios on one population"),
58
+ "train": (cli.train_main, "train the factored sector-control policy"),
59
+ "region": (regions.main, "download a study area by bounding box or place"),
60
+ "region-pack": (region_packages.main, "list, export or import study areas as .zip packages"),
61
+ "opencellid": (real_data.main, "fetch OpenCellID observations"),
62
+ "netbravo": (netbravo.main, "fetch the NetBravo observed-signal layer"),
63
+ "hardware-job": (hardware_job.main, "decode a returned QAOA hardware job"),
64
+ "template": (template.main, "worked example: register an agent, run it, compare it"),
65
+ "zoo": (quantum_zoo.main, "Quantum Algorithm Zoo entries, read against this benchmark"),
66
+ }
67
+
68
+
69
+ def usage(commands) -> str:
70
+ width = max(len(name) for name in commands)
71
+ lines = ["usage: python -m qellaris <subcommand> [options]", "",
72
+ "subcommands:"]
73
+ lines += [f" {name:<{width}} {help_text}"
74
+ for name, (_, help_text) in commands.items()]
75
+ lines += ["", "Add --help after a subcommand for its own options.",
76
+ "New here? Run `qellaris menu` and it will ask what you want."]
77
+ return "\n".join(lines)
78
+
79
+
80
+ def bootstrap() -> None:
81
+ """Make the package importable and drop a foreign environment's packages.
82
+
83
+ Each root script used to carry its own copy of this. It runs for every
84
+ subcommand now, not just the dashboard: a compiled extension built for a
85
+ different CPython fails with a thoroughly misleading "No module named ..."
86
+ deep inside an unrelated import, and that is just as confusing from the
87
+ benchmark as from the UI.
88
+ """
89
+ from .ui import drop_foreign_site_packages, ensure_importable
90
+
91
+ ensure_importable()
92
+ for entry in drop_foreign_site_packages():
93
+ print(f"note: ignoring another environment's packages: {entry}")
94
+
95
+
96
+ def main(argv: list[str] | None = None) -> None:
97
+ argv = list(sys.argv[1:] if argv is None else argv)
98
+ bootstrap()
99
+ commands = _commands()
100
+ if not argv or argv[0] in {"-h", "--help", "help"}:
101
+ print(usage(commands))
102
+ return
103
+ name, rest = argv[0], argv[1:]
104
+ if name not in commands:
105
+ raise SystemExit(f"unknown subcommand {name!r}\n\n{usage(commands)}")
106
+ run, _ = commands[name]
107
+ run(rest)
108
+
109
+
110
+ if __name__ == "__main__":
111
+ main()
qellaris/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source for the package version string."""
2
+
3
+ __version__ = "0.5.0"
qellaris/cli.py ADDED
@@ -0,0 +1,230 @@
1
+ """Command-line entry points for the benchmark and the RL trainer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from . import paths
10
+ from .experiment import ExperimentConfig, format_leaderboard, run_experiment
11
+ from .forecasting import FORECASTER_NAMES
12
+ from .simulation import SCENARIO_NAMES
13
+ from .solvers import installed_solver_names, solver_names
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> None:
17
+ parser = argparse.ArgumentParser(
18
+ description="Run the demand-forecast / sector-scheduling benchmark.")
19
+ parser.add_argument("--output", default=paths.runs("baseline"))
20
+ parser.add_argument("--seed", type=int, default=17)
21
+ parser.add_argument("--agents", type=int, default=240)
22
+ parser.add_argument("--train-days", type=int, default=7)
23
+ parser.add_argument("--horizon", type=int, default=16)
24
+ parser.add_argument("--intervals-per-day", type=int, default=96)
25
+ parser.add_argument("--point-forecast", action="store_true",
26
+ help="schedule on the point forecast instead of its upper bound")
27
+ parser.add_argument("--scenario", choices=SCENARIO_NAMES,
28
+ default="entertainment_event",
29
+ help="'entertainment_event' stages one demand event "
30
+ "at the best venue the study area actually has "
31
+ "(a stadium if there is one, otherwise a "
32
+ "transport hub, high street, campus or park, at "
33
+ "a magnitude that fits); an area with none gets "
34
+ "no event. A specific venue name (e.g. "
35
+ "'park_event') forces that family instead of "
36
+ "auto-picking. 'normal' asks for none.")
37
+ parser.add_argument("--geography", default="synthetic",
38
+ help="'synthetic', 'kensington', or a folder from "
39
+ "`qellaris region`")
40
+ parser.add_argument("--inventory", default=None,
41
+ help="optional fixed tower/sector JSON "
42
+ "(positions + directions; no placement search)")
43
+ parser.add_argument("--solvers", nargs="+", default=None,
44
+ choices=list(solver_names()),
45
+ help="default: every solver whose backend is installed")
46
+ parser.add_argument("--forecasters", nargs="+", default=None,
47
+ choices=list(FORECASTER_NAMES),
48
+ help="default: seasonal_naive only; pass more than one "
49
+ "to run the forecaster x solver factorial "
50
+ "(every solver replayed once per forecaster; "
51
+ "interface support, not a claimed primary result)")
52
+ parser.add_argument("--rl-weights", default=None,
53
+ help="policy JSON written by `qellaris train`")
54
+ parser.add_argument("--decision-reference", default="greedy",
55
+ choices=list(solver_names()),
56
+ help="reference used to attribute forecast and solver decision gaps")
57
+ parser.add_argument("--no-counterfactual", action="store_true",
58
+ help="skip the seed-paired no-event control replay")
59
+ parser.add_argument("--json", action="store_true",
60
+ help="print the full summary instead of the ranked table")
61
+ parser.add_argument("--list-backends", action="store_true",
62
+ help="report which optional quantum packages are installed")
63
+ parser.add_argument("--seeds", type=int, nargs="+", default=None,
64
+ help="replicate over these seeds and report paired "
65
+ "bootstrap intervals instead of a single run")
66
+ parser.add_argument("--metric", default="objective",
67
+ help="metric the replication table ranks and pairs on")
68
+ args = parser.parse_args(argv)
69
+
70
+ if args.list_backends:
71
+ from .quantum import backends
72
+
73
+ for backend in backends():
74
+ state = "installed" if backend.available else f"missing ({backend.detail})"
75
+ print(f"{backend.name:<16} {backend.package:<22} {state}")
76
+ print("\nsolvers available:", ", ".join(installed_solver_names()))
77
+ return
78
+
79
+ chosen = tuple(args.solvers) if args.solvers else installed_solver_names()
80
+ chosen_forecasters = tuple(args.forecasters) if args.forecasters else ("seasonal_naive",)
81
+
82
+ if args.seeds:
83
+ from .evaluation import format_report, run_replications, write_report
84
+
85
+ report = run_replications(
86
+ args.output,
87
+ ExperimentConfig(
88
+ agents=args.agents, train_days=args.train_days,
89
+ horizon=args.horizon, intervals_per_day=args.intervals_per_day,
90
+ uncertainty_aware=not args.point_forecast, scenario=args.scenario,
91
+ geography=args.geography, inventory_path=args.inventory,
92
+ solvers=chosen,
93
+ forecasters=chosen_forecasters,
94
+ rl_weights=args.rl_weights,
95
+ decision_reference=args.decision_reference,
96
+ paired_counterfactual=not args.no_counterfactual),
97
+ seeds=tuple(args.seeds),
98
+ reference=args.decision_reference)
99
+ print(format_report(report, args.metric))
100
+ print(f"\nartefacts written to "
101
+ f"{write_report(args.output, report, args.metric)}")
102
+ return
103
+
104
+ summary = run_experiment(args.output, ExperimentConfig(
105
+ seed=args.seed, agents=args.agents, train_days=args.train_days,
106
+ horizon=args.horizon, intervals_per_day=args.intervals_per_day,
107
+ uncertainty_aware=not args.point_forecast, scenario=args.scenario,
108
+ geography=args.geography, inventory_path=args.inventory,
109
+ solvers=chosen,
110
+ forecasters=chosen_forecasters,
111
+ rl_weights=args.rl_weights, decision_reference=args.decision_reference,
112
+ paired_counterfactual=not args.no_counterfactual))
113
+ if args.json:
114
+ print(json.dumps(summary, indent=2))
115
+ return
116
+ area = summary["study_area"]
117
+ print(f"{area['name']}: {summary['zones']} demand bins, "
118
+ f"{summary['transmitters']} sectors, "
119
+ f"{area['width_km']} x {area['height_km']} km")
120
+ for name, metrics in summary["forecasts"].items():
121
+ print(f"forecast[{name}] MAE {metrics['mae']:.3f}, "
122
+ f"interval coverage {metrics['interval_coverage']:.3f}")
123
+ print()
124
+ print(format_leaderboard(summary["leaderboard"]))
125
+ print(f"\nartefacts written to {args.output}")
126
+
127
+
128
+ def equity_main(argv: list[str] | None = None) -> None:
129
+ """Run the same population under several equity scenarios and compare.
130
+
131
+ Writes the equity output per scenario and the demographic state they all
132
+ shared, as two files, because they are two different things: one says who
133
+ was out there, the other says what the network did about it.
134
+ """
135
+ from .demographics import DEMOGRAPHIC_AXES, format_state
136
+ from .equity import EQUITY_SCENARIOS
137
+ from .equity_scenarios import (ScenarioConfig, format_scenarios,
138
+ run_equity_scenarios, write_scenarios)
139
+
140
+ parser = argparse.ArgumentParser(
141
+ description="Compare equity scenarios on one population.")
142
+ parser.add_argument("--output", default=paths.runs("equity"))
143
+ parser.add_argument("--scenarios", nargs="+", default=None,
144
+ choices=list(EQUITY_SCENARIOS),
145
+ help="default: every scenario")
146
+ parser.add_argument("--reference", default="protective",
147
+ choices=list(EQUITY_SCENARIOS),
148
+ help="scenario the others are reported against")
149
+ parser.add_argument("--agents", type=int, default=240)
150
+ parser.add_argument("--intervals-per-day", type=int, default=96)
151
+ parser.add_argument("--steps", type=int, default=96,
152
+ help="intervals scored, after the warm-up")
153
+ parser.add_argument("--warmup", type=int, default=96,
154
+ help="intervals run before the audit window opens")
155
+ parser.add_argument("--seed", type=int, default=17)
156
+ parser.add_argument("--geography", default="kensington",
157
+ help="'synthetic', 'kensington', or a folder from "
158
+ "`qellaris region`")
159
+ parser.add_argument("--demand-scenario", choices=SCENARIO_NAMES,
160
+ default="entertainment_event")
161
+ parser.add_argument("--solver", default="greedy", choices=list(solver_names()))
162
+ parser.add_argument("--capacity-headroom", type=float, default=1.2,
163
+ help="site sizing vs. pilot peak demand; below 1.0 forces "
164
+ "a genuinely capacity-bound network so bin-weighting "
165
+ "scenarios have demand to actually ration")
166
+ parser.add_argument("--point-forecast", action="store_true")
167
+ parser.add_argument("--show-demographics", nargs="*", default=None,
168
+ metavar="AXIS",
169
+ help="also print the demographic state; name axes to "
170
+ f"narrow it ({', '.join(DEMOGRAPHIC_AXES)})")
171
+ parser.add_argument("--json", action="store_true")
172
+ args = parser.parse_args(argv)
173
+
174
+ report = run_equity_scenarios(args.scenarios, ScenarioConfig(
175
+ agents=args.agents, intervals_per_day=args.intervals_per_day,
176
+ steps=args.steps, warmup=args.warmup, seed=args.seed,
177
+ geography=args.geography, demand_scenario=args.demand_scenario,
178
+ solver=args.solver, uncertainty_aware=not args.point_forecast,
179
+ capacity_headroom=args.capacity_headroom),
180
+ reference=args.reference)
181
+
182
+ if args.json:
183
+ print(json.dumps(report, indent=2))
184
+ else:
185
+ if args.show_demographics is not None:
186
+ axes = tuple(args.show_demographics) or None
187
+ print(format_state(report["demographics"], axes))
188
+ print(format_scenarios(report))
189
+ print(f"artefacts written to {write_scenarios(args.output, report)}")
190
+
191
+
192
+ def train_main(argv: list[str] | None = None) -> None:
193
+ """Train the factored sector policy against the Gymnasium environment."""
194
+ from .control_env import TransmitterControlEnv
195
+ from .rl import SectorPolicy, evaluate_policy, train_policy
196
+
197
+ parser = argparse.ArgumentParser(description="Train the RL sector-control policy.")
198
+ parser.add_argument("--episodes", type=int, default=80)
199
+ parser.add_argument("--episode-length", type=int, default=48)
200
+ parser.add_argument("--agents", type=int, default=240)
201
+ parser.add_argument("--intervals-per-day", type=int, default=96)
202
+ parser.add_argument("--geography", default="kensington")
203
+ parser.add_argument("--seed", type=int, default=17)
204
+ parser.add_argument("--learning-rate", type=float, default=0.05)
205
+ parser.add_argument("--output", default=paths.runs("rl", "policy.json"))
206
+ parser.add_argument("--resume", default=None, help="continue from an existing policy JSON")
207
+ args = parser.parse_args(argv)
208
+
209
+ env = TransmitterControlEnv(
210
+ n_agents=args.agents, intervals_per_day=args.intervals_per_day,
211
+ episode_length=args.episode_length,
212
+ warmup_intervals=args.intervals_per_day, seed=args.seed,
213
+ geography=args.geography)
214
+ start = SectorPolicy.load(args.resume) if args.resume and Path(args.resume).exists() else None
215
+
216
+ def show(episode: int, total: float) -> None:
217
+ if episode % max(1, args.episodes // 20) == 0:
218
+ print(f"episode {episode:4d} return {total:12.1f}")
219
+
220
+ policy, report = train_policy(env, episodes=args.episodes, seed=args.seed,
221
+ learning_rate=args.learning_rate, policy=start,
222
+ progress=show)
223
+ path = policy.save(args.output)
224
+ print(json.dumps({"weights": path.as_posix(), **report.as_dict(),
225
+ "episode_rewards": report.episode_rewards[-5:],
226
+ "evaluation": evaluate_policy(policy, env, episodes=3)}, indent=2))
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()