Skip to content

Commit 0512fc9

Browse files
elahe-sahraieadpr1
authored andcommitted
Merged PR 1265: Decouple planning and dispatch corridor maps
Decouple planning and dispatch corridor maps Related work items: #2213
2 parents 2056b30 + 4b7d4f1 commit 0512fc9

3 files changed

Lines changed: 186 additions & 81 deletions

File tree

pypsa_canada/workflow/Snakefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ def get_all_targets():
103103
*rules.solve_dispatch.output,
104104
*rules.post_process_planning.output,
105105
*rules.post_process_dispatch.output,
106-
*rules.plot_corridor_map.output,
106+
*rules.plot_planning_corridor_map.output,
107+
*rules.plot_dispatch_corridor_map.output,
107108
*rules.create_summary.output,
108109
*rules.copy_config.output,
109110
*rules.collect_logs.output
@@ -115,4 +116,4 @@ def get_all_targets():
115116

116117
rule all:
117118
input:
118-
get_all_targets()
119+
get_all_targets()
Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
# Previous rules: solve_planning, solve dispatch, post_process_planning, post_process_dispatch
22
# Next rule: None
33

4-
rule plot_corridor_map:
4+
rule plot_planning_corridor_map:
5+
input:
6+
planning_solved_network=f"{RUN_OUTPUT_DIR}/planning_solved_network",
7+
post_process_planning=f"{RUN_OUTPUT_DIR}/post_process_planning"
8+
output:
9+
planning_corridor_map=f"{RUN_OUTPUT_DIR}/post_process_maps/planning_corridor_utilization_map.html",
10+
planning_corridor_summary=f"{RUN_OUTPUT_DIR}/post_process_maps/planning_corridor_utilization_map_summary.csv"
11+
log:
12+
f"{RUN_LOG_DIR}/plot_planning_corridor_map_{TIMESTAMP}.log"
13+
script:
14+
"../scripts/plot_corridor_map.py"
15+
16+
17+
rule plot_dispatch_corridor_map:
518
input:
619
planning_solved_network=f"{RUN_OUTPUT_DIR}/planning_solved_network",
720
dispatch_solved_network=f"{RUN_OUTPUT_DIR}/dispatch_solved_network",
821
post_process_planning=f"{RUN_OUTPUT_DIR}/post_process_planning",
922
post_process_dispatch=f"{RUN_OUTPUT_DIR}/post_process_dispatch"
1023
output:
11-
planning_corridor_map=f"{RUN_OUTPUT_DIR}/post_process_maps/planning_corridor_utilization_map.html",
1224
dispatch_corridor_map=f"{RUN_OUTPUT_DIR}/post_process_maps/dispatch_corridor_utilization_map.html",
13-
planning_corridor_summary=f"{RUN_OUTPUT_DIR}/post_process_maps/planning_corridor_utilization_map_summary.csv",
1425
dispatch_corridor_summary=f"{RUN_OUTPUT_DIR}/post_process_maps/dispatch_corridor_utilization_map_summary.csv"
1526
log:
16-
f"{RUN_LOG_DIR}/plot_corridor_map_{TIMESTAMP}.log"
27+
f"{RUN_LOG_DIR}/plot_dispatch_corridor_map_{TIMESTAMP}.log"
1728
script:
1829
"../scripts/plot_corridor_map.py"

pypsa_canada/workflow/scripts/plot_corridor_map.py

Lines changed: 168 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,25 @@
7575
LINE_COLOR_NO_DATA = "#888888" # Grey for existing lines without flow data
7676
_NEW_LINE_BADGE = '<span style="color:#22bb44"><b>[NEW]</b></span> '
7777

78+
SUMMARY_COLUMNS = [
79+
"case",
80+
"corridor",
81+
"busA",
82+
"busB",
83+
"provinceA",
84+
"provinceB",
85+
"corridor_capacity_mw",
86+
"capacity_for_utilization_mw",
87+
"existing_lines_capacity_mw",
88+
"new_lines_capacity_mw",
89+
"provincial_aggregation_used",
90+
"max_utilization",
91+
"avg_utilization",
92+
"hours_ge_0_8",
93+
"hours_ge_1_0",
94+
"flow_orientation",
95+
]
96+
7897

7998
# =============================================================================
8099
# HELPERS
@@ -1431,17 +1450,20 @@ def build_corridor_map(
14311450
drawn_lines += 1
14321451

14331452
folium.LayerControl(collapsed=False).add_to(fmap)
1453+
for output_path in (out_html, out_csv):
1454+
output_dir = os.path.dirname(output_path)
1455+
if output_dir:
1456+
os.makedirs(output_dir, exist_ok=True)
14341457
fmap.save(out_html)
14351458

1436-
# Save CSV summary
1437-
if summary:
1438-
summary_df = pd.DataFrame(summary)
1439-
summary_df = summary_df.sort_values(
1440-
["hours_ge_0_8", "max_utilization"],
1441-
ascending=[False, False],
1442-
)
1443-
summary_df.to_csv(out_csv, index=False, encoding="utf-8-sig")
1444-
logging.info(f"[{map_label}] Saved CSV: {out_csv}")
1459+
# Save CSV summary, including headers when no corridors have flow rows.
1460+
summary_df = pd.DataFrame(summary, columns=SUMMARY_COLUMNS)
1461+
summary_df = summary_df.sort_values(
1462+
["hours_ge_0_8", "max_utilization"],
1463+
ascending=[False, False],
1464+
)
1465+
summary_df.to_csv(out_csv, index=False, encoding="utf-8-sig")
1466+
logging.info(f"[{map_label}] Saved CSV: {out_csv}")
14451467

14461468
logging.info(f"[{map_label}] Saved HTML: {out_html}")
14471469
logging.info(f"[{map_label}] Corridors total: {len(corridors)}")
@@ -1462,22 +1484,52 @@ def build_corridor_map(
14621484
# =============================================================================
14631485

14641486

1465-
def main():
1466-
if snakemake is None:
1467-
raise RuntimeError("plot_corridor_map.py must be executed by Snakemake")
1487+
def get_named_value(named_values, name: str) -> str | None:
1488+
"""
1489+
Return a named Snakemake input/output value when it is declared.
1490+
"""
1491+
try:
1492+
value = getattr(named_values, name)
1493+
except (AttributeError, KeyError):
1494+
return None
1495+
if value is None:
1496+
return None
1497+
value = str(value)
1498+
return value if value else None
14681499

1469-
benchmark_timer, benchmark_memory = start_benchmark_tracker()
14701500

1471-
planning_res_folder = str(snakemake.input.planning_solved_network)
1472-
dispatch_res_folder = str(snakemake.input.dispatch_solved_network)
1473-
planning_post_process_folder = str(snakemake.input.post_process_planning)
1474-
dispatch_post_process_folder = str(snakemake.input.post_process_dispatch)
1475-
planning_out_html = str(snakemake.output.planning_corridor_map)
1476-
dispatch_out_html = str(snakemake.output.dispatch_corridor_map)
1477-
planning_out_csv = str(snakemake.output.planning_corridor_summary)
1478-
dispatch_out_csv = str(snakemake.output.dispatch_corridor_summary)
1501+
def require_named_value(named_values, name: str, value_type: str) -> str:
1502+
"""
1503+
Return a required named Snakemake input/output value.
1504+
"""
1505+
value = get_named_value(named_values, name)
1506+
if value is None:
1507+
raise RuntimeError(
1508+
f"plot_corridor_map.py requires {value_type}.{name} for this output"
1509+
)
1510+
return value
1511+
14791512

1480-
# Load capacity expansion data from planning unit summary
1513+
def load_planning_context(planning_res_folder: str):
1514+
"""
1515+
Load planning topology and group physical lines into corridor rows.
1516+
"""
1517+
bus_ll, _, bus_to_province = load_buses(
1518+
os.path.join(planning_res_folder, "buses.csv")
1519+
)
1520+
lines, _ = load_lines(planning_res_folder)
1521+
1522+
corridors: dict = defaultdict(list)
1523+
for _, row in lines.iterrows():
1524+
corridors[canonical_pair(row["bus0"], row["bus1"])].append(row)
1525+
1526+
return bus_ll, bus_to_province, corridors
1527+
1528+
1529+
def load_planning_metadata(planning_post_process_folder: str):
1530+
"""
1531+
Load planning metadata used in corridor popups.
1532+
"""
14811533
unit_summary_path = os.path.join(
14821534
planning_post_process_folder, "unit_summary_planning.csv"
14831535
)
@@ -1489,69 +1541,110 @@ def main():
14891541
logging.warning("No planning year data found; using base capacities only")
14901542
last_year = "N/A"
14911543

1492-
# Topology (buses, lines, corridors) always comes from the planning network,
1493-
# which contains the full set of physical lines including newly built ones.
1494-
# The dispatch map reuses the same corridors so all planned lines appear on
1495-
# both maps; lines without dispatch flow data are drawn grey.
1496-
bus_ll, _, bus_to_province = load_buses(
1497-
os.path.join(planning_res_folder, "buses.csv")
1498-
)
1499-
lines, _ = load_lines(planning_res_folder)
1500-
link_flows_ts, link_flows_df = load_link_flows(planning_res_folder)
1544+
return final_added, last_year, expansion_detail
15011545

1502-
# Group individual physical lines by their (bus_a, bus_b) corridor key so
1503-
# that parallel lines between the same two buses share one popup and are
1504-
# drawn as offset arcs rather than overlapping polylines.
1505-
corridors: dict = defaultdict(list)
1506-
for _, row in lines.iterrows():
1507-
corridors[canonical_pair(row["bus0"], row["bus1"])].append(row)
15081546

1509-
planning_nodal_index = index_nodal_files(
1510-
planning_post_process_folder, POST_PROCESS_FILE_GLOB
1547+
def main():
1548+
if snakemake is None:
1549+
raise RuntimeError("plot_corridor_map.py must be executed by Snakemake")
1550+
1551+
benchmark_timer, benchmark_memory = start_benchmark_tracker()
1552+
1553+
planning_out_html = get_named_value(snakemake.output, "planning_corridor_map")
1554+
planning_out_csv = get_named_value(snakemake.output, "planning_corridor_summary")
1555+
dispatch_out_html = get_named_value(snakemake.output, "dispatch_corridor_map")
1556+
dispatch_out_csv = get_named_value(snakemake.output, "dispatch_corridor_summary")
1557+
1558+
planning_requested = bool(planning_out_html and planning_out_csv)
1559+
dispatch_requested = bool(dispatch_out_html and dispatch_out_csv)
1560+
if not planning_requested and not dispatch_requested:
1561+
raise RuntimeError("No corridor map outputs were requested by Snakemake")
1562+
1563+
logging.info(
1564+
"Corridor map requests: "
1565+
f"planning={planning_requested}, dispatch={dispatch_requested}"
15111566
)
1512-
build_corridor_map(
1513-
corridors,
1514-
bus_ll,
1515-
bus_to_province,
1516-
planning_nodal_index,
1517-
link_flows_ts,
1518-
link_flows_df,
1519-
planning_out_html,
1520-
planning_out_csv,
1521-
"Planning",
1522-
final_added,
1523-
last_year,
1524-
expansion_detail,
1567+
1568+
planning_res_folder = require_named_value(
1569+
snakemake.input, "planning_solved_network", "input"
15251570
)
1571+
planning_post_process_folder = require_named_value(
1572+
snakemake.input, "post_process_planning", "input"
1573+
)
1574+
1575+
bus_ll, bus_to_province, corridors = load_planning_context(planning_res_folder)
1576+
final_added, last_year, expansion_detail = load_planning_metadata(
1577+
planning_post_process_folder
1578+
)
1579+
1580+
generated_maps = []
1581+
benchmark_result_anchor = planning_out_csv or dispatch_out_csv
1582+
1583+
if planning_requested:
1584+
logging.info(
1585+
"Generating planning corridor map outputs: "
1586+
f"html={planning_out_html}, csv={planning_out_csv}"
1587+
)
1588+
link_flows_ts, link_flows_df = load_link_flows(planning_res_folder)
1589+
planning_nodal_index = index_nodal_files(
1590+
planning_post_process_folder, POST_PROCESS_FILE_GLOB
1591+
)
1592+
build_corridor_map(
1593+
corridors,
1594+
bus_ll,
1595+
bus_to_province,
1596+
planning_nodal_index,
1597+
link_flows_ts,
1598+
link_flows_df,
1599+
planning_out_html,
1600+
planning_out_csv,
1601+
"Planning",
1602+
final_added,
1603+
last_year,
1604+
expansion_detail,
1605+
)
1606+
generated_maps.append("planning")
1607+
1608+
if dispatch_requested:
1609+
dispatch_res_folder = require_named_value(
1610+
snakemake.input, "dispatch_solved_network", "input"
1611+
)
1612+
dispatch_post_process_folder = require_named_value(
1613+
snakemake.input, "post_process_dispatch", "input"
1614+
)
1615+
logging.info(
1616+
"Generating dispatch corridor map outputs: "
1617+
f"html={dispatch_out_html}, csv={dispatch_out_csv}"
1618+
)
1619+
dispatch_nodal_index = index_nodal_files(
1620+
dispatch_post_process_folder, POST_PROCESS_FILE_GLOB
1621+
)
1622+
dispatch_link_flows_ts, dispatch_link_flows_df = load_dispatch_link_flows(
1623+
dispatch_res_folder
1624+
)
1625+
build_corridor_map(
1626+
corridors,
1627+
bus_ll,
1628+
bus_to_province,
1629+
dispatch_nodal_index,
1630+
dispatch_link_flows_ts,
1631+
dispatch_link_flows_df,
1632+
dispatch_out_html,
1633+
dispatch_out_csv,
1634+
"Dispatch",
1635+
final_added,
1636+
last_year,
1637+
expansion_detail,
1638+
)
1639+
generated_maps.append("dispatch")
15261640

15271641
finish_benchmark_tracker(
1528-
result_benchmark_csv_path(planning_out_csv),
1529-
"plot_corridor_map",
1642+
result_benchmark_csv_path(benchmark_result_anchor),
1643+
f"plot_corridor_map_{'_'.join(generated_maps)}",
15301644
benchmark_timer,
15311645
benchmark_memory,
15321646
)
15331647

1534-
dispatch_nodal_index = index_nodal_files(
1535-
dispatch_post_process_folder, POST_PROCESS_FILE_GLOB
1536-
)
1537-
dispatch_link_flows_ts, dispatch_link_flows_df = load_dispatch_link_flows(
1538-
dispatch_res_folder
1539-
)
1540-
build_corridor_map(
1541-
corridors,
1542-
bus_ll,
1543-
bus_to_province,
1544-
dispatch_nodal_index,
1545-
dispatch_link_flows_ts,
1546-
dispatch_link_flows_df,
1547-
dispatch_out_html,
1548-
dispatch_out_csv,
1549-
"Dispatch",
1550-
final_added,
1551-
last_year,
1552-
expansion_detail,
1553-
)
1554-
15551648

15561649
if __name__ == "__main__":
15571650
try:

0 commit comments

Comments
 (0)