"""Utilities to generate SimWrapper dashboard configuration for an AequilibraE project.
Usage
-----
.. code-block:: python
:caption: Generate SimWrapper dashboard for an open project
>>> from aequilibrae.project import Project
>>> from aequilibrae.utils.simwrapper.generate_simwrapper_config import SimwrapperConfigGenerator
>>> prj = Project()
>>> prj.open('/path/to/project')
>>> gen = SimwrapperConfigGenerator(prj, output_dir='simwrapper')
>>> gen.write_yamls()
Notes
-----
- `output_dir` must be inside the project directory
"""
from pathlib import Path
import json
import pandas as pd
import yaml
from aequilibrae.utils.simwrapper.simwrapper_panel import (
ConvergencePanel,
TilePanel,
TextPanel,
AequilibraEMapPanel,
AequilibraEResultsMapPanel,
)
from aequilibrae.utils.simwrapper.simwrapper_utils import (
get_project_center,
get_project_zoom,
export_convergence_csv,
)
[docs]
class SimwrapperConfigGenerator:
"""Generates SimWrapper dashboard configuration for an AequilibraE project."""
def __init__(
self,
project,
output_dir="simwrapper",
max_results_tables=3,
results_tables=None,
centroid_link_types=None,
):
"""Initialise the configuration generator.
Arguments:
**project** (:obj:`Project`): AequilibraE Project object
**output_dir** (:obj:`str`, optional): Path where SimWrapper output files will be written.
Relative paths are created under the project's base directory. Absolute paths are
accepted only when they reside inside the project; absolute paths outside the
project will raise a ValueError.
**max_results_tables** (:obj:`int`, optional): Maximum number of results scenarios to include
when ``results_tables`` is not specified (default: 3)
**results_tables** (:obj:`list[str]`, optional): Explicit list of results table names to include.
When set, no automatic truncation is applied
**centroid_link_types** (:obj:`list[str]`, optional): Link type names considered to be centroid
connectors. When not provided, the generator attempts to infer them
"""
self.project = project
self.max_results_tables = int(max_results_tables) if max_results_tables is not None else 3
self.results_tables = results_tables
self.centroid_link_types = centroid_link_types
od = Path(output_dir)
self.project_root = Path(self.project.project_base_path).resolve()
# Treat relative paths as project-relative subdirectories; accept absolute paths
# only when they are located inside the project directory. Absolute paths
# outside the project are rejected to guarantee all SimWrapper outputs remain
# under the project directory.
if not od.is_absolute():
od = (self.project_root / od).resolve()
else:
od = od.resolve()
if not od.is_relative_to(self.project_root):
raise ValueError(f"output_dir must be inside the project directory ({self.project_root}); got '{od}'")
self.output_dir = od
self.generated_files = {}
self._create_directories()
self.center = get_project_center(self.project)
self.zoom = get_project_zoom(self.project)
def _create_directories(self):
"""Create output directory structure for SimWrapper.
Structure:
PROJECT-DIRECTORY/
dashboard-*.yaml # Dashboard configuration file(s)
simwrapper/
simwrapper_data/ # Data files referenced by configs
assignment_convergence.vega.json # Vegalite JSON for convergence plot
assignment_convergence.csv # Additional CSV outputs
...
"""
self.data_dir = self.output_dir / "simwrapper_data" # make subcategories
self.output_dir.mkdir(exist_ok=True) # base
self.data_dir.mkdir(exist_ok=True) # data
def _find_project_title(self):
"""Generate a project title.
Uses the project's model name when available;
otherwise derive a readable title from the project folder name. Guaranteed
to return a non-empty string.
"""
model_name = getattr(self.project.about, "model_name", None)
if model_name:
return model_name
folder_name = Path(self.project.project_base_path).name
title = folder_name.replace("_", " ").title()
if title.strip():
return title
return "AequilibraE Project"
def _add_to_generated_files(self, key, path):
"""Add a generated file reference for inspection."""
self.generated_files[key] = Path(path)
def _dashboard_skeleton(self):
"""Creates the base dashboard configuration."""
title = self._find_project_title()
desc = f"Dashboard generated from '{title}'"
return {"header": {"title": title, "description": desc}, "layout": {}}
def _intro_row(self):
"""Returns a project introduction text panel."""
title = self._find_project_title()
content = f"# {title}\n\nThis dashboard was generated by AequilibraE to help explore the network and results."
return [TextPanel(title="Overview", data=content)]
def _get_link_types(self):
"""Return a list of link-type *names* present in the project's network (empty list if none)."""
try:
lts = self.project.network.link_types.all_types()
if lts:
return [lt.link_type for lt in lts.values()]
except AttributeError:
# safe fallback to links table below when link_types API is not available
pass
return []
def _categorical_palette(self, n):
"""Returns n visually distinct hex colour strings.
Notes:
This uses a fixed palette to avoid optional heavyweight dependencies.
"""
if n <= 0:
return []
# Matplotlib tab20 palette (approx), stored as hex values.
base = [
"#1f77b4",
"#aec7e8",
"#ff7f0e",
"#ffbb78",
"#2ca02c",
"#98df8a",
"#d62728",
"#ff9896",
"#9467bd",
"#c5b0d5",
"#8c564b",
"#c49c94",
"#e377c2",
"#f7b6d2",
"#7f7f7f",
"#c7c7c7",
"#bcbd22",
"#dbdb8d",
"#17becf",
"#9edae5",
]
if n <= len(base):
return base[:n]
# For more categories than the base palette, cycle values.
return [base[i % len(base)] for i in range(n)]
def _select_results_tables(self, results_dataframe):
"""Selects which results tables to include in the dashboard.
If ``self.results_tables`` is provided, returns that list (filtered to existing).
Otherwise, selects up to ``self.max_results_tables`` most recent results based on the
``timestamp`` field when available.
Returns:
**tables** (:obj:`list[str]`): Selected results table names
**truncated** (:obj:`bool`): Whether some scenarios were omitted
**total** (:obj:`int`): Total number of available scenarios
"""
res_df = results_dataframe
total = int(len(res_df))
if total == 0:
return [], False, 0
available = res_df["table_name"].tolist()
if self.results_tables is not None:
chosen = [t for t in self.results_tables if t in available]
return chosen, False, total
df = res_df.copy()
if "timestamp" in df.columns:
df["_ts"] = pd.to_datetime(df["timestamp"], errors="coerce")
df = df.sort_values(by=["_ts", "table_name"], ascending=[False, True])
max_tables = max(0, int(self.max_results_tables))
if total <= max_tables or max_tables == 0:
# max_tables == 0 means "do not include any results" in the auto mode
return ([] if max_tables == 0 else df["table_name"].tolist()), (max_tables == 0), total
chosen = df["table_name"].head(max_tables).tolist()
return chosen, True, total
def _results_truncation_notice(self, shown, total):
"""Builds a short notice panel when not all scenarios are shown."""
return TextPanel(
title="Please Note",
data=(
f"Showing {shown} of {total} result scenarios (most recent first).\n\n"
"Additional scenarios were omitted to keep the dashboard readable."
),
)
def _stats_rows(self):
"""Returns a row of basic project statistics."""
dataset = [
{
"key": "Link Count",
"value": {"database": "project_database.sqlite", "query": "SELECT printf('%,d', COUNT(*)) FROM links"},
},
{
"key": "Node Count",
"value": {"database": "project_database.sqlite", "query": "SELECT printf('%,d', COUNT(*)) FROM nodes"},
},
{
"key": "Zone Count",
"value": {
"database": "project_database.sqlite",
"query": "SELECT printf('%,d', COUNT(*)) FROM nodes WHERE is_centroid=1",
},
},
]
panel = TilePanel("Network Size", dataset, height=1, colors="monochrome")
return [panel]
def _centroid_link_filters(self):
"""Builds SQL filters for centroid connectors.
Uses explicit ``self.centroid_link_types`` when provided; otherwise attempts to infer
centroid link types by name.
Returns:
**centroid_filter** (:obj:`str`): SQL boolean expression matching centroid connectors
**non_centroid_filter** (:obj:`str`): SQL boolean expression matching non-centroid links
"""
centroid_names = self.centroid_link_types
if centroid_names is None:
centroid_names = [
name
for name in (self._get_link_types() or [])
if "centroid" in name.lower() or "connector" in name.lower()
]
if centroid_names:
# escape single-quotes in link-type names for safe SQL interpolation
safe_names = [n.replace("'", "''") for n in centroid_names]
centroid_filter = " OR ".join([f"link_type = '{s}'" for s in safe_names])
non_centroid_filter = " AND ".join([f"link_type != '{s}'" for s in safe_names])
else:
centroid_filter = (
"a_node IN (SELECT node_id FROM nodes WHERE is_centroid=1) "
"OR b_node IN (SELECT node_id FROM nodes WHERE is_centroid=1)"
)
non_centroid_filter = f"NOT ({centroid_filter})"
return centroid_filter, non_centroid_filter
def _entire_network_row(self):
"""Builds a map of the entire network."""
# aequilibrae panel with center and zoom
panel = AequilibraEMapPanel(
"Entire Network",
height=10,
center=self.center,
zoom=self.zoom,
)
centroid_filter, non_centroid_filter = self._centroid_link_filters()
# set legend
panel.set_legend(
[
{"label": "Regular Links", "color": "#4c72b0", "shape": "line"},
{"label": "Centroid Connectors", "color": "#9c72b0", "shape": "line"},
{"label": "Centroid Node", "color": "#FF6600", "shape": "circle"},
{"label": "Regular Node", "color": "#cacaca", "shape": "circle"},
]
)
# non-centroid connector links
panel.add_layer(
"links_regular",
{
"table": "links",
"geometry": "line",
"sqlFilter": non_centroid_filter,
"style": {"lineColor": "#4C78A8", "lineWidth": 2},
},
)
# centroid connector links
panel.add_layer(
"links_centroid_connectors",
{
"table": "links",
"geometry": "line",
"sqlFilter": centroid_filter,
"style": {
"lineColor": "#9c72b0",
"lineWidth": 20,
},
},
)
# add centroid nodes layer
centroid_node_style = {"fillColor": "#FF6600", "pointRadius": 300}
panel.add_layer(
"nodes_centroids",
{"table": "nodes", "geometry": "point", "sqlFilter": "is_centroid=1", "style": centroid_node_style},
)
# add regular nodes layer
regular_node_style = {"fillColor": "#cacaca", "pointRadius": 100}
panel.add_layer(
"nodes_regular",
{"table": "nodes", "geometry": "point", "sqlFilter": "is_centroid=0", "style": regular_node_style},
)
return [panel]
def _links_info_row(self):
"""Builds a map styled by link type."""
link_type_names = self._get_link_types()
# fallback: read unique values directly from the links table
if not link_type_names:
with self.project.db_connection as conn:
rows = conn.execute(
"SELECT DISTINCT link_type FROM links WHERE link_type IS NOT NULL ORDER BY link_type"
).fetchall()
link_type_names = [r[0] for r in rows]
colours = self._categorical_palette(len(link_type_names))
colour_map = dict(zip(link_type_names, colours, strict=False))
# map panel
panel = AequilibraEMapPanel("Link Types", height=10, width=1, center=self.center, zoom=self.zoom)
# build and set legend
legend = [{"subtitle": "Link Types"}]
for i, lt_name in enumerate(link_type_names):
legend.append({"label": f"{lt_name}", "color": colours[i], "shape": "line"})
panel.set_legend(legend)
# add links layer styled by link type
panel.add_layer(
"links",
{
"table": "links",
"geometry": "line",
"style": {
"lineColor": {
"column": "link_type",
"colors": colour_map,
},
"lineWidth": 10,
},
},
)
return [panel]
def _capacity_map_row(self):
"""Builds a map styled by link capacity.
This reads ``capacity_ab`` directly from the project ``links`` table.
"""
_, non_centroid_filter = self._centroid_link_filters()
panel = AequilibraEResultsMapPanel(
title="Link Capacity",
project=self.project,
colour_metric="capacity_ab",
width_metric="capacity_ab",
palette="SunsetDark",
height=10,
width=1,
center=self.center,
zoom=self.zoom,
sql_filter=non_centroid_filter,
)
return [panel]
def _delay_factor_row(self, results_tables):
"""Builds delay factor comparison panels."""
return [
AequilibraEResultsMapPanel(
title=f"{table} Delay Factor",
project=self.project,
results_table=table,
colour_metric="Delay_factor_Max",
width_metric="capacity_ab",
)
for table in results_tables
]
def _voc_comp_row(self, results_tables):
"""Builds vehicles/capacity comparison panels."""
return [
AequilibraEResultsMapPanel(
title=f"{table} vehicles / capacity",
project=self.project,
results_table=table,
colour_metric="VOC_max",
)
for table in results_tables
]
def _write_convergence_vega_spec(self, csv_path):
"""Write a Vega-Lite spec for assignment convergence.
The Vega spec references the CSV using a relative URL to keep the output folder portable.
Returns:
**spec_name** (:obj:`str`): Vega spec filename
"""
# where to save it
path = self.data_dir / "assignment_convergence.vega.json"
spec = {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"url": csv_path.relative_to(self.project_root).as_posix(),
"format": {"type": "csv"},
},
"mark": {"type": "line", "point": False},
"encoding": {
"x": {
"field": "iteration",
"type": "quantitative",
"title": "Iteration",
},
"y": {"field": "rgap", "type": "quantitative", "title": "Relative Gap", "scale": {"type": "log"}},
"color": {
"field": "series",
"type": "nominal",
"title": "Scenario",
},
},
}
# write vega json
with path.open("w", encoding="utf-8") as f:
json.dump(spec, f, indent=2)
return path.name
def _assignment_convergence_plot(self, results_dataframe):
"""Return a Vega-Lite convergence plot panel."""
# export convergence csv
csv_path = export_convergence_csv(results_dataframe, self.data_dir)
# skip if no convergence data
if csv_path is None:
return None
self._add_to_generated_files("assignment_convergence", csv_path)
vega_spec = self._write_convergence_vega_spec(csv_path)
# panel wrapper
full_path = self.data_dir / vega_spec
rel_path = full_path.relative_to(self.project_root)
panel = ConvergencePanel(
title="Assignment Convergence",
config=rel_path.as_posix(),
height=6,
)
return [panel]
def _flow_map_row(self, results_tables):
"""Builds maps styled by assigned flows."""
return [
AequilibraEResultsMapPanel(
title=f"{table} flow",
project=self.project,
results_table=table,
colour_metric="VOC_max",
width_metric="PCE_tot",
)
for table in results_tables
]
def _build_dashboard_config(self):
"""Builds and returns the full dashboard configuration."""
config = self._dashboard_skeleton() # base config
# dashboard rows
rows = {
"introRow": self._intro_row(),
"statsRow": self._stats_rows(),
"entireNetworkRow": self._entire_network_row(),
"linkTypeAndCapacityRow": self._links_info_row() + self._capacity_map_row(),
}
res_df = self.project.results.list()
results_tables, truncated, total = self._select_results_tables(res_df)
if truncated and total:
rows["resultsNoticeRow"] = [self._results_truncation_notice(len(results_tables), total)]
# if we have results table, add relevant panels to dashboard
if len(results_tables) > 0:
rows["flowMapRow"] = self._flow_map_row(results_tables)
rows["delayFactorComparisonRow"] = self._delay_factor_row(results_tables)
rows["vocComparisonRow"] = self._voc_comp_row(results_tables)
rows["assignmentConvergencePlot"] = self._assignment_convergence_plot(res_df)
# convert panels to dicts and add to config
for name, panels in rows.items():
if panels:
config["layout"][name] = [p.to_dict() for p in panels]
return config
[docs]
def write_yamls(self):
"""Writes the SimWrapper dashboard YAML file."""
config = self._build_dashboard_config()
output_file = self.output_dir / "dashboard.yaml"
# write it
with output_file.open("w", encoding="utf-8") as f:
yaml.safe_dump(config, f, sort_keys=False)
self._add_to_generated_files("dashboard", output_file)
[docs]
def main(argv=None):
"""Command-line entry point for generating SimWrapper configs.
Example:
aequilibrae-simwrapper --project /path/to/project --output-dir simwrapper
"""
import argparse
parser = argparse.ArgumentParser(
prog="aequilibrae-simwrapper",
description="Generate SimWrapper dashboard YAML for an AequilibraE project",
)
parser.add_argument("-p", "--project", default=".", help="Project path (folder containing project_database.sqlite)")
parser.add_argument("-o", "--output-dir", default="simwrapper", help="Output directory (inside project)")
parser.add_argument("--max-results-tables", type=int, default=None, help="Maximum number of results tables")
parser.add_argument(
"--results-tables", nargs="+", default=None, help="Explicit results table names (space-separated)"
)
parser.add_argument(
"--centroid-link-types", nargs="+", default=None, help="Centroid link type names (space-separated)"
)
parser.add_argument("-q", "--quiet", action="store_true", help="Suppress informational output")
args = parser.parse_args(argv)
# lazy import to avoid side-effects at module import time
import sys
from aequilibrae.project import Project
prj = Project()
try:
prj.open(args.project)
except Exception as e:
print(f"Error opening project at '{args.project}': {e}", file=sys.stderr)
return 2
try:
gen = SimwrapperConfigGenerator(
prj,
output_dir=args.output_dir,
max_results_tables=args.max_results_tables,
results_tables=args.results_tables,
centroid_link_types=args.centroid_link_types,
)
gen.write_yamls()
if not args.quiet:
print(f"Written {len(gen.generated_files)} files to {gen.output_dir}")
for k, v in gen.generated_files.items():
print(f" - {k}: {v}")
return 0
except Exception as e:
print(f"Error generating simwrapper config: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
import sys
sys.exit(main())