7575LINE_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
15561649if __name__ == "__main__" :
15571650 try :
0 commit comments