• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

blue-marble / gridpath / 17838580391

18 Sep 2025 06:56PM UTC coverage: 89.046% (+0.09%) from 88.959%
17838580391

push

github

web-flow
Maintenance upgrades (#1289)

Support Python 3.12 and 3.13, drop testing on Python 3.9 and 3.10.

Upgrade dependencies to latest versions.

Notes testing with coverage sees significant slowdown with Python 3.13.

55 of 65 new or added lines in 48 files covered. (84.62%)

2 existing lines in 2 files now uncovered.

27680 of 31085 relevant lines covered (89.05%)

2.65 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.9
/viz/dispatch_plot.py
1
# Copyright 2016-2025 Blue Marble Analytics LLC.
2
#
3
# Licensed under the Apache License, Version 2.0 (the "License");
4
# you may not use this file except in compliance with the License.
5
# You may obtain a copy of the License at
6
#
7
#     http://www.apache.org/licenses/LICENSE-2.0
8
#
9
# Unless required by applicable law or agreed to in writing, software
10
# distributed under the License is distributed on an "AS IS" BASIS,
11
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
# See the License for the specific language governing permissions and
13
# limitations under the License.
14

15
"""
1✔
16
Make results dispatch plot for a specified zone/stage/set of timepoints
17
"""
18

19
# TODO: adjust x-axis for timepoint duration? (assumes 1h now) - could
20
#  use timestamp from inputs_temporal instead and create x-axis automatically
21
#  using built-in datestime libraries?
22
# TODO: create a database table with technologies and colors for each tech
23
#   note: currently technology more narrowly defined than tech color (latter
24
#   includes curtailment etc.)
25
# TODO: okay to default stage to 1 for cases with only one stage? Need to
26
#   make sure this is aligned with SQL tables (default value for column)
27
#   and data validation
28

29

30
from argparse import ArgumentParser
3✔
31
from bokeh.models import ColumnDataSource, Legend, NumeralTickFormatter
3✔
32
from bokeh.plotting import figure
3✔
33
from bokeh.models.tools import HoverTool
3✔
34
from bokeh.embed import json_item
3✔
35
from bokeh.palettes import cividis
3✔
36

37
import pandas as pd
3✔
38
import sys
3✔
39

40
# GridPath modules
41
from db.common_functions import connect_to_database
3✔
42
from gridpath.auxiliary.db_interface import get_scenario_id_and_name
3✔
43
from viz.common_functions import (
3✔
44
    show_hide_legend,
45
    show_plot,
46
    get_parent_parser,
47
    get_tech_colors,
48
    get_tech_plotting_order,
49
    get_unit,
50
)
51

52

53
def create_parser():
3✔
54
    parser = ArgumentParser(add_help=True, parents=[get_parent_parser()])
3✔
55
    parser.add_argument(
3✔
56
        "--scenario_id",
57
        help="The scenario ID. Required if " "no --scenario is specified.",
58
    )
59
    parser.add_argument(
3✔
60
        "--scenario",
61
        help="The scenario name. Required if " "no --scenario_id is specified.",
62
    )
63
    parser.add_argument(
3✔
64
        "--load_zone",
65
        required=True,
66
        type=str,
67
        help="The name of the load zone. Required.",
68
    )
69
    parser.add_argument(
3✔
70
        "--starting_tmp",
71
        default=None,
72
        type=int,
73
        help="The starting timepoint. Defaults to None (" "first timepoint)",
74
    )
75
    parser.add_argument(
3✔
76
        "--ending_tmp",
77
        default=None,
78
        type=int,
79
        help="The ending timepoint. Defaults to None (" "last timepoint)",
80
    )
81
    parser.add_argument(
3✔
82
        "--stage", default=1, type=int, help="The stage ID. Defaults to 1."
83
    )
84
    parser.add_argument(
3✔
85
        "--weather_iteration", default=0, type=int, help="Defaults to 0."
86
    )
87
    parser.add_argument("--hydro_iteration", default=0, type=int, help="Defaults to 0.")
3✔
88
    parser.add_argument(
3✔
89
        "--availability_iteration", default=0, type=int, help="Defaults to 0."
90
    )
91

92
    return parser
3✔
93

94

95
def parse_arguments(arguments):
3✔
96
    """
97

98
    :return:
99
    """
100
    parser = create_parser()
3✔
101
    parsed_arguments = parser.parse_args(args=arguments)
3✔
102

103
    return parsed_arguments
3✔
104

105

106
def get_timepoints(conn, scenario_id, starting_tmp=None, ending_tmp=None, stage_id=1):
3✔
107
    """
108
    Note: assumes timepoints are ordered!
109
    :param conn:
110
    :param scenario_id:
111
    :param starting_tmp:
112
    :param ending_tmp:
113
    :param stage_id:
114
    :return:
115
    """
116

117
    if starting_tmp is None:
3✔
118
        start_query = ""
×
119
    else:
120
        start_query = "AND timepoint >= {}".format(starting_tmp)
3✔
121

122
    if ending_tmp is None:
3✔
123
        end_query = ""
×
124
    else:
125
        end_query = "AND timepoint <= {}".format(ending_tmp)
3✔
126

127
    query = """SELECT timepoint
3✔
128
        FROM inputs_temporal
129
        INNER JOIN
130
        (SELECT temporal_scenario_id FROM scenarios WHERE scenario_id = {})
131
        USING (temporal_scenario_id)
132
        WHERE stage_id = {}
133
        {}
134
        {}
135
        ;""".format(
136
        scenario_id, stage_id, start_query, end_query
137
    )
138

139
    tmps = [i[0] for i in conn.execute(query).fetchall()]
3✔
140

141
    return tmps
3✔
142

143

144
def get_power_by_tech_results(
3✔
145
    conn,
146
    scenario_id,
147
    load_zone,
148
    weather_iteration,
149
    hydro_iteration,
150
    availability_iteration,
151
    stage,
152
    timepoints,
153
):
154
    """
155
    Get results for power by technology for a given load_zone and set of
156
    points.
157
    :param conn:
158
    :param scenario_id:
159
    :param load_zone:
160
    :param weather_iteration:
161
    :param hydro_iteration:
162
    :param availability_iteration:
163
    :param stage:
164
    :param timepoints
165
    :return:
166
    """
167

168
    # Power by technology
169
    timepoints_str = ",".join(["?"] * len(timepoints))
3✔
170
    query = f"""SELECT timepoint, technology, power_mw
3✔
171
        FROM results_project_dispatch_by_technology
172
        WHERE scenario_id = {scenario_id}
173
        AND load_zone = '{load_zone}'
174
        AND weather_iteration = {weather_iteration}
175
        AND hydro_iteration = {hydro_iteration}
176
        AND availability_iteration = {availability_iteration}
177
        AND stage_id = {stage}
178
        AND timepoint IN ({timepoints_str})
179
        ;"""
180

181
    df = pd.read_sql(query, conn, params=timepoints)
3✔
182
    if not df.empty:
3✔
183
        df = df.pivot(index="timepoint", columns="technology")["power_mw"]
3✔
184
        return df
3✔
185
    # If the dataframe was empty, we still need to send the timepoint index
186
    # downstream
187
    else:
188
        index_only_df = pd.DataFrame(index=timepoints)
×
189
        return index_only_df
×
190

191

192
def get_variable_curtailment_results(
3✔
193
    c,
194
    scenario_id,
195
    load_zone,
196
    weather_iteration,
197
    hydro_iteration,
198
    availability_iteration,
199
    stage,
200
    timepoints,
201
):
202
    """
203
    Get variable generator curtailment for a given load_zone and set of
204
    timepoints.
205
    :param c:
206
    :param scenario_id:
207
    :param load_zone:
208
    :param weather_iteration:
209
    :param hydro_iteration:
210
    :param availability_iteration:
211
    :param stage:
212
    :param timepoints:
213
    :return:
214
    """
215
    query = f"""SELECT scheduled_curtailment_mw
3✔
216
            FROM results_project_curtailment_variable_periodagg
217
            WHERE scenario_id = {scenario_id}
218
            AND load_zone = '{load_zone}'
219
            AND weather_iteration = {weather_iteration}
220
            AND hydro_iteration = {hydro_iteration}
221
            AND availability_iteration = {availability_iteration}
222
            AND stage_id = {stage}
223
            AND timepoint IN ({",".join(["?"] * len(timepoints))})
224
            ;"""
225

226
    curtailment = [i[0] for i in c.execute(query, timepoints).fetchall()]
3✔
227

228
    return curtailment
3✔
229

230

231
def get_hydro_curtailment_results(
3✔
232
    c,
233
    scenario_id,
234
    load_zone,
235
    weather_iteration,
236
    hydro_iteration,
237
    availability_iteration,
238
    stage,
239
    timepoints,
240
):
241
    """
242
    Get conventional hydro curtailment for a given load_zone and set of
243
    timepoints.
244
    :param scenario_id:
245
    :param load_zone:
246
    :param weather_iteration:
247
    :param hydro_iteration:
248
    :param availability_iteration:
249
    :param stage:
250
    :param timepoints:
251
    :return:
252
    """
253
    query = f"""SELECT scheduled_curtailment_mw
3✔
254
            FROM results_project_curtailment_hydro_periodagg
255
            WHERE scenario_id = {scenario_id}
256
            AND load_zone = '{load_zone}'
257
            AND weather_iteration = {weather_iteration}
258
            AND hydro_iteration = {hydro_iteration}
259
            AND availability_iteration = {availability_iteration}
260
            AND stage_id = {stage}
261
            AND timepoint IN ({",".join(["?"] * len(timepoints))})
262
            ;"""
263

264
    curtailment = [i[0] for i in c.execute(query, timepoints).fetchall()]
3✔
265

266
    return curtailment
3✔
267

268

269
def get_imports_exports_results(
3✔
270
    c,
271
    scenario_id,
272
    load_zone,
273
    weather_iteration,
274
    hydro_iteration,
275
    availability_iteration,
276
    stage,
277
    timepoints,
278
):
279
    """
280
    Get imports/exports results for a given load_zone and set of timepoints.
281
    :param c:
282
    :param scenario_id:
283
    :param load_zone:
284
    :param weather_iteration:
285
    :param hydro_iteration:
286
    :param availability_iteration:
287
    :param stage:
288
    :param timepoints:
289
    :return:
290
    """
291
    query = f"""SELECT net_imports_mw
3✔
292
        FROM results_system_load_zone_timepoint
293
        WHERE scenario_id = {scenario_id}
294
        AND load_zone = '{load_zone}'
295
        AND weather_iteration = {weather_iteration}
296
        AND hydro_iteration = {hydro_iteration}
297
        AND availability_iteration = {availability_iteration}
298
        AND stage_id = {stage}
299
        AND timepoint IN ({",".join(["?"] * len(timepoints))})
300
        ;"""
301

302
    net_imports = c.execute(query, timepoints).fetchall()
3✔
303

304
    # None values should only happen if the transmission feature was not
305
    # included
306
    imports = [0 if i[0] is None else i[0] if i[0] > 0 else 0 for i in net_imports]
3✔
307
    exports = [0 if e[0] is None else -e[0] if e[0] < 0 else 0 for e in net_imports]
3✔
308

309
    return imports, exports
3✔
310

311

312
def get_market_participation_results(
3✔
313
    c,
314
    scenario_id,
315
    load_zone,
316
    weather_iteration,
317
    hydro_iteration,
318
    availability_iteration,
319
    stage,
320
    timepoints,
321
):
322
    """
323
    Get market participation results for a given load_zone and set of timepoints.
324
    :param c:
325
    :param scenario_id:
326
    :param load_zone:
327
    :param weather_iteration:
328
    :param hydro_iteration:
329
    :param availability_iteration:
330
    :param stage:
331
    :param timepoints:
332
    :return:
333
    """
334
    query = f"""SELECT net_market_purchases_mw
3✔
335
        FROM results_system_load_zone_timepoint
336
        WHERE scenario_id = {scenario_id}
337
            AND load_zone = '{load_zone}'
338
            AND weather_iteration = {weather_iteration}
339
            AND hydro_iteration = {hydro_iteration}
340
            AND availability_iteration = {availability_iteration}
341
            AND stage_id = {stage}
342
            AND timepoint IN ({",".join(["?"] * len(timepoints))})
343
            ;"""
344

345
    market_participation = c.execute(query, timepoints).fetchall()
3✔
346

347
    sales = []
3✔
348
    purchases = []
3✔
349
    for i in market_participation:
3✔
350
        if i[0] is None:  # markets feature not included
3✔
351
            sales.append(0)
3✔
352
            purchases.append(0)
3✔
353
        else:
354
            if i[0] < 0:
×
355
                sales.append(-i[0])
×
356
                purchases.append(0)
×
357
            elif i[0] > 0:
×
358
                sales.append(0)
×
359
                purchases.append(i[0])
×
360
            else:
361
                sales.append(0)
×
362
                purchases.append(0)
×
363

364
    return sales, purchases
3✔
365

366

367
def get_load(
3✔
368
    c,
369
    scenario_id,
370
    load_zone,
371
    weather_iteration,
372
    hydro_iteration,
373
    availability_iteration,
374
    stage,
375
    timepoints,
376
):
377
    """
378

379
    :param c:
380
    :param scenario_id:
381
    :param load_zone:
382
    :param weather_iteration:
383
    :param hydro_iteration:
384
    :param availability_iteration:
385
    :param stage:
386
    :param timepoints
387
    :return:
388
    """
389

390
    query = f"""SELECT static_load_mw, unserved_energy_mw
3✔
391
        FROM results_system_load_zone_timepoint
392
        WHERE scenario_id = {scenario_id}
393
        AND load_zone = '{load_zone}'
394
        AND weather_iteration = {weather_iteration}
395
        AND hydro_iteration = {hydro_iteration}
396
        AND availability_iteration = {availability_iteration}
397
        AND stage_id = {stage}
398
        AND timepoint IN ({",".join(["?"] * len(timepoints))})
399
        ;"""
400

401
    load_balance = c.execute(query, timepoints).fetchall()
3✔
402

403
    load = [i[0] for i in load_balance]
3✔
404
    unserved_energy = [i[1] for i in load_balance]
3✔
405

406
    return load, unserved_energy
3✔
407

408

409
def get_plotting_data(
3✔
410
    conn,
411
    scenario_id,
412
    load_zone,
413
    weather_iteration,
414
    hydro_iteration,
415
    availability_iteration,
416
    starting_tmp,
417
    ending_tmp,
418
    stage,
419
    **kwargs,
420
):
421
    """
422
    Get the dispatch data by timepoint and technology for a given
423
    scenario/load_zone/set of timepoints/stage.
424

425
    **kwargs needed, so that an error isn't thrown when calling this
426
    function with extra arguments from the UI.
427

428
    :param conn:
429
    :param scenario_id:
430
    :param load_zone:
431
    :param weather_iteration:
432
    :param hydro_iteration:
433
    :param availability_iteration:
434
    :param starting_tmp:
435
    :param ending_tmp:
436
    :param stage:
437
    :return:
438
    """
439

440
    c = conn.cursor()
3✔
441

442
    # Get the relevant timepoints
443
    timepoints = get_timepoints(conn, scenario_id, starting_tmp, ending_tmp, stage)
3✔
444

445
    # Get dispatch by technology
446
    # TODO: Let tech order depend on specified order in database table.
447
    #  Storage might be tricky because we manipulate it!
448
    df = get_power_by_tech_results(
3✔
449
        conn=conn,
450
        scenario_id=scenario_id,
451
        load_zone=load_zone,
452
        stage=stage,
453
        timepoints=timepoints,
454
        weather_iteration=weather_iteration,
455
        hydro_iteration=hydro_iteration,
456
        availability_iteration=availability_iteration,
457
    )
458

459
    # Add x axis
460
    # TODO: assumes hourly timepoints for now, make it flexible instead
461
    df["x"] = range(0, len(df))
3✔
462

463
    # Split storage into charging and discharging and aggregate storage charging
464
    # Assume any dispatch that is negative is storage charging
465
    df["Storage_Charging"] = 0
3✔
466
    stor_techs = df.columns[(df < 0).any()]
3✔
467
    for tech in stor_techs:
3✔
468
        df["Storage_Charging"] += -df[tech].clip(upper=0)
×
469
        df[tech] = df[tech].clip(lower=0)
×
470

471
    # Add variable curtailment (if any)
472
    curtailment_variable = get_variable_curtailment_results(
3✔
473
        c=c,
474
        scenario_id=scenario_id,
475
        load_zone=load_zone,
476
        weather_iteration=weather_iteration,
477
        hydro_iteration=hydro_iteration,
478
        availability_iteration=availability_iteration,
479
        stage=stage,
480
        timepoints=timepoints,
481
    )
482
    if curtailment_variable:
3✔
483
        df["Curtailment_Variable"] = curtailment_variable
×
484

485
    # Add hydro curtailment (if any)
486
    curtailment_hydro = get_hydro_curtailment_results(
3✔
487
        c=c,
488
        scenario_id=scenario_id,
489
        load_zone=load_zone,
490
        weather_iteration=weather_iteration,
491
        hydro_iteration=hydro_iteration,
492
        availability_iteration=availability_iteration,
493
        stage=stage,
494
        timepoints=timepoints,
495
    )
496
    if curtailment_hydro:
3✔
497
        df["Curtailment_Hydro"] = curtailment_hydro
×
498

499
    # Add imports and exports (if any)
500
    imports, exports = get_imports_exports_results(
3✔
501
        c=c,
502
        scenario_id=scenario_id,
503
        load_zone=load_zone,
504
        weather_iteration=weather_iteration,
505
        hydro_iteration=hydro_iteration,
506
        availability_iteration=availability_iteration,
507
        stage=stage,
508
        timepoints=timepoints,
509
    )
510
    if imports:
3✔
511
        df["Imports"] = imports
3✔
512
    if exports:
3✔
513
        df["Exports"] = exports
3✔
514

515
    # Add market participation (if any)
516
    market_sales, market_purchases = get_market_participation_results(
3✔
517
        c=c,
518
        scenario_id=scenario_id,
519
        load_zone=load_zone,
520
        weather_iteration=weather_iteration,
521
        hydro_iteration=hydro_iteration,
522
        availability_iteration=availability_iteration,
523
        stage=stage,
524
        timepoints=timepoints,
525
    )
526
    if market_sales:
3✔
527
        df["Market_Sales"] = market_sales
3✔
528
    if market_purchases:
3✔
529
        df["Market_Purchases"] = market_purchases
3✔
530

531
    # Add load
532
    load_balance = get_load(
3✔
533
        c=c,
534
        scenario_id=scenario_id,
535
        load_zone=load_zone,
536
        weather_iteration=weather_iteration,
537
        hydro_iteration=hydro_iteration,
538
        availability_iteration=availability_iteration,
539
        stage=stage,
540
        timepoints=timepoints,
541
    )
542

543
    df["Load"] = load_balance[0]
3✔
544
    df["Unserved_Energy"] = load_balance[1]
3✔
545

546
    # Dataframe for testing without database
547
    # df = pd.DataFrame(
548
    #     data={
549
    #         "unspecified": range(10),
550
    #         "Nuclear": range(10),
551
    #         "Coal": range(10),
552
    #         "CHP": range(10),
553
    #         "Geothermal": range(10),
554
    #         "Biomass": range(10),
555
    #         "Small_Hydro": range(10),
556
    #         "Steam": range(10),
557
    #         "CCGT": range(10),
558
    #         "Hydro": range(10),
559
    #         "Imports": range(10),
560
    #         "Peaker": range(10),
561
    #         "Wind": range(10),
562
    #         "Solar_BTM": range(10),
563
    #         "Solar": range(10),
564
    #         "Pumped_Storage": range(10),
565
    #         "Battery": range(10),
566
    #         "Curtailment_Variable": range(10),
567
    #         "Curtailment_Hydro": range(10),
568
    #         "x": range(10),
569
    #         "Load": range(10),
570
    #         "Exports": range(10),
571
    #         "Pumped_Storage_Charging": [-5] * 10,
572
    #         "Battery_Charging": [-10] * 10
573
    #     }
574
    # )
575

576
    return df
3✔
577

578

579
def create_plot(
3✔
580
    df, title, power_unit, tech_colors={}, tech_plotting_order={}, ylimit=None
581
):
582
    """
583

584
    :param df:
585
    :param title: string, plot title
586
    :param power_unit: string, the unit of power used in the database/model
587
    :param tech_colors: optional dict that maps technologies to colors.
588
        Technologies without a specified color map will use a default palette
589
    :param tech_plotting_order: optional dict that maps technologies to their
590
        plotting order in the stacked bar/area chart.
591
    :param ylimit: float/int, upper limit of y-axis; optional
592
    :return:
593
    """
594

595
    # Re-arrange df according to plotting order
596
    for col in df.columns:
3✔
597
        if col not in tech_plotting_order:
3✔
598
            tech_plotting_order[col] = max(tech_plotting_order.values()) + 1
3✔
599
    df = df.reindex(sorted(df.columns, key=lambda x: tech_plotting_order[x]), axis=1)
3✔
600

601
    # Set up data source
602
    source = ColumnDataSource(data=df)
3✔
603

604
    # Determine column types for plotting, legend and colors
605
    # Order of stacked_cols will define order of stacked areas in chart
606
    all_cols = list(df.columns)
3✔
607
    x_col = "x"
3✔
608
    # TODO: remove hard-coding?
609
    line_cols_storage_sum_track = [
3✔
610
        "Load",
611
        "Exports",
612
        "Storage_Charging",
613
        "Market_Sales",
614
    ]
615
    stacked_cols = [
3✔
616
        c for c in all_cols if c not in line_cols_storage_sum_track + [x_col]
617
    ]
618

619
    # Set up color scheme. Use cividis palette for unspecified colors
620
    unspecified_columns = [c for c in stacked_cols if c not in tech_colors.keys()]
3✔
621
    unspecified_tech_colors = dict(
3✔
622
        zip(unspecified_columns, cividis(len(unspecified_columns)))
623
    )
624
    colors = []
3✔
625
    for tech in stacked_cols:
3✔
626
        if tech in tech_colors:
3✔
627
            colors.append(tech_colors[tech])
3✔
628
        else:
629
            colors.append(unspecified_tech_colors[tech])
3✔
630

631
    # Set up the figure
632
    plot = figure(
3✔
633
        min_width=800,
634
        min_height=500,
635
        tools=["pan", "reset", "zoom_in", "zoom_out", "save", "help"],
636
        title=title,
637
        # sizing_mode="scale_both"
638
    )
639

640
    # Add stacked area chart to plot
641
    area_renderers = plot.vbar_stack(
3✔
642
        stackers=stacked_cols,
643
        x=x_col,
644
        source=source,
645
        color=colors,
646
        width=1,
647
    )
648
    # Note: can easily change vbar_stack to varea_stack by replacing the plot
649
    # function and removing the width argument. However, hovertools don't work
650
    # with varea_stack.
651

652
    # Add load line chart to plot
653
    load_renderer = plot.line(
3✔
654
        x=df[x_col], y=df["Load"], line_color="black", line_width=2, name="Load"
655
    )
656

657
    # Keep track of legend items and load renderers
658
    legend_items = [
3✔
659
        (x, [area_renderers[i]]) for i, x in enumerate(stacked_cols) if df[x].mean() > 0
660
    ] + [("Load", [load_renderer])]
661
    load_renderers = [load_renderer]
3✔
662

663
    # Add 'Load + ...' lines
664
    if "Exports" not in df.columns:
3✔
665
        inactive_exports = True
×
666
    else:
667
        inactive_exports = (df["Exports"] == 0).all()
3✔
668

669
    if "Market_Sales" not in df.columns:
3✔
670
        inactive_markets = True
×
671
    else:
672
        inactive_markets = (df["Market_Sales"] == 0).all()
3✔
673

674
    inactive_storage = (df["Storage_Charging"] == 0).all()
3✔
675

676
    if inactive_exports and inactive_markets:
3✔
677
        line_cols_storage_sum_track = ["Load", "Storage_Charging"]
3✔
678
    if not inactive_exports and inactive_markets:
3✔
679
        line_cols_storage_sum_track = ["Load", "Exports", "Storage_Charging"]
×
680
        # Add export line to plot
681
        label = "Load + Exports"
×
682
        exports_renderer = plot.line(
×
683
            x=df[x_col],
684
            y=df[["Load", "Exports"]].sum(axis=1),
685
            line_color="black",
686
            line_width=2,
687
            line_dash="dashed",
688
            name=label,
689
        )
690
        legend_items.append((label, [exports_renderer]))
×
691
        load_renderers.append(exports_renderer)
×
692
    if not inactive_exports and not inactive_markets:
3✔
693
        line_cols_storage_sum_track = [
×
694
            "Load",
695
            "Exports",
696
            "Market_Sales",
697
            "Storage_Charging",
698
        ]
699
        # Add export and market lines to plot
700
        label = "Load + Exports + Market Sales"
×
701
        exports_renderer = plot.line(
×
702
            x=df[x_col],
703
            y=df[["Load", "Exports", "Market_Sales"]].sum(axis=1),
704
            line_color="black",
705
            line_width=3,
706
            line_dash="dashed",
707
            name=label,
708
        )
709
        legend_items.append((label, [exports_renderer]))
×
710
        load_renderers.append(exports_renderer)
×
711
    if inactive_exports and not inactive_markets:
3✔
712
        line_cols_storage_sum_track = ["Load", "Storage_Charging", "Market_Sales"]
×
713
        # Add export line to plot
714
        label = "Load + Market Sales"
×
715
        exports_renderer = plot.line(
×
716
            x=df[x_col],
717
            y=df[["Load", "Market_Sales"]].sum(axis=1),
718
            line_color="black",
719
            line_width=2,
720
            line_dash="dashed",
721
            name=label,
722
        )
723
        legend_items.append((label, [exports_renderer]))
×
724
        load_renderers.append(exports_renderer)
×
725

726
    if not inactive_storage:
3✔
727
        # Add storage line to plot
728
        label = legend_items[-1][0] + " + Storage Charging"
×
729
        stor_renderer = plot.line(
×
730
            x=df[x_col],
731
            y=df[line_cols_storage_sum_track].sum(axis=1),
732
            line_color="black",
733
            line_width=2,
734
            line_dash="dotted",
735
            name=label,
736
        )
737
        legend_items.append((label, [stor_renderer]))
×
738
        load_renderers.append(stor_renderer)
×
739

740
    # Add Legend
741
    legend = Legend(items=legend_items)
3✔
742
    plot.add_layout(legend, "right")
3✔
743
    plot.legend[0].items.reverse()  # Reverse legend to match stacked order
3✔
744
    plot.legend.click_policy = "hide"  # Add interactivity to the legend
3✔
745
    # Note: Doesn't rescale the graph down, simply hides the area
746
    # Note2: There's currently no way to auto-size legend based on graph size(?)
747
    # except for maybe changing font size automatically?
748
    show_hide_legend(plot=plot)  # Hide legend on double click
3✔
749

750
    # Format Axes (labels, number formatting, range, etc.)
751
    plot.xaxis.axis_label = "Hour Ending"
3✔
752
    plot.yaxis.axis_label = "Dispatch ({})".format(power_unit)
3✔
753
    plot.yaxis.formatter = NumeralTickFormatter(format="0,0")
3✔
754
    if ylimit is not None:
3✔
NEW
755
        plot.y_range.end = ylimit  # will be ignored if ylimit is None
×
756

757
    # Add HoverTools for stacked bars/areas
758
    for r in area_renderers:
3✔
759
        power_source = r.name
3✔
760
        hover = HoverTool(
3✔
761
            tooltips=[
762
                ("Hour Ending", "@x"),
763
                ("Source", power_source),
764
                ("Dispatch", "@%s{0,0} %s" % (power_source, power_unit)),
765
            ],
766
            renderers=[r],
767
            visible=False,
768
        )
769
        plot.add_tools(hover)
3✔
770

771
    # Add HoverTools for load lines
772
    for r in load_renderers:
3✔
773
        load_type = r.name
3✔
774
        hover = HoverTool(
3✔
775
            tooltips=[
776
                ("Hour Ending", "@x"),
777
                (load_type, "@y{0,0} %s" % power_unit),
778
            ],
779
            renderers=[r],
780
            visible=False,
781
        )
782
        plot.add_tools(hover)
3✔
783

784
    return plot
3✔
785

786

787
def main(args=None):
3✔
788
    """
789
    Parse the arguments, get the data in a df, and create the plot
790

791
    :return: if requested, return the plot as JSON object
792
    """
793
    if args is None:
3✔
794
        args = sys.argv[1:]
×
795
    parsed_args = parse_arguments(arguments=args)
3✔
796

797
    conn = connect_to_database(db_path=parsed_args.database)
3✔
798
    c = conn.cursor()
3✔
799

800
    scenario_id, scenario = get_scenario_id_and_name(
3✔
801
        scenario_id_arg=parsed_args.scenario_id,
802
        scenario_name_arg=parsed_args.scenario,
803
        c=c,
804
        script="dispatch_plot",
805
    )
806

807
    tech_colors = get_tech_colors(c)
3✔
808
    tech_plotting_order = get_tech_plotting_order(c)
3✔
809
    power_unit = get_unit(c, "power")
3✔
810

811
    plot_title = "{}Dispatch Plot - {} - Stage {} - Timepoints {}-{}".format(
3✔
812
        "{} - ".format(scenario) if parsed_args.scenario_name_in_title else "",
813
        parsed_args.load_zone,
814
        parsed_args.stage,
815
        parsed_args.starting_tmp,
816
        parsed_args.ending_tmp,
817
    )
818
    plot_name = "dispatchPlot-{}-{}-{}-{}".format(
3✔
819
        parsed_args.load_zone,
820
        parsed_args.stage,
821
        parsed_args.starting_tmp,
822
        parsed_args.ending_tmp,
823
    )
824

825
    df = get_plotting_data(
3✔
826
        conn=conn,
827
        scenario_id=scenario_id,
828
        load_zone=parsed_args.load_zone,
829
        starting_tmp=parsed_args.starting_tmp,
830
        ending_tmp=parsed_args.ending_tmp,
831
        stage=parsed_args.stage,
832
        weather_iteration=parsed_args.weather_iteration,
833
        hydro_iteration=parsed_args.hydro_iteration,
834
        availability_iteration=parsed_args.availability_iteration,
835
    )
836

837
    conn.close()
3✔
838

839
    plot = create_plot(
3✔
840
        df=df,
841
        title=plot_title,
842
        power_unit=power_unit,
843
        tech_colors=tech_colors,
844
        tech_plotting_order=tech_plotting_order,
845
        ylimit=parsed_args.ylimit,
846
    )
847

848
    # Show plot in HTML browser file if requested
849
    if parsed_args.show:
3✔
850
        show_plot(
×
851
            plot=plot,
852
            plot_name=plot_name,
853
            plot_write_directory=parsed_args.plot_write_directory,
854
            scenario=scenario,
855
        )
856

857
    # Return plot in json format if requested
858
    if parsed_args.return_json:
3✔
859
        return json_item(plot, "plotHTMLTarget")
×
860

861

862
if __name__ == "__main__":
3✔
863
    main()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc