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

blue-marble / gridpath / 26601056421

28 May 2026 08:42PM UTC coverage: 88.671% (-0.4%) from 89.062%
26601056421

push

github

web-flow
GridPath v2026.3.0

Merge pull request #1376 from blue-marble/develop

1314 of 1580 new or added lines in 77 files covered. (83.16%)

44 existing lines in 11 files now uncovered.

28318 of 31936 relevant lines covered (88.67%)

0.89 hits per line

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

85.98
/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
"""
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
1✔
31
from bokeh.models import ColumnDataSource, Legend, NumeralTickFormatter
1✔
32
from bokeh.plotting import figure
1✔
33
from bokeh.models.tools import HoverTool
1✔
34
from bokeh.embed import json_item
1✔
35
from bokeh.palettes import cividis
1✔
36

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

40
# GridPath modules
41
from db.common_functions import connect_to_database
1✔
42
from gridpath.auxiliary.db_interface import get_scenario_id_and_name
1✔
43
from viz.common_functions import (
1✔
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():
1✔
54
    parser = ArgumentParser(add_help=True, parents=[get_parent_parser()])
1✔
55
    parser.add_argument(
1✔
56
        "--scenario_id",
57
        help="The scenario ID. Required if " "no --scenario is specified.",
58
    )
59
    parser.add_argument(
1✔
60
        "--scenario",
61
        help="The scenario name. Required if " "no --scenario_id is specified.",
62
    )
63
    parser.add_argument(
1✔
64
        "--load_zone",
65
        required=True,
66
        type=str,
67
        help="The name of the load zone. Required.",
68
    )
69
    parser.add_argument(
1✔
70
        "--starting_tmp",
71
        default=None,
72
        type=int,
73
        help="The starting timepoint. Defaults to None (" "first timepoint)",
74
    )
75
    parser.add_argument(
1✔
76
        "--ending_tmp",
77
        default=None,
78
        type=int,
79
        help="The ending timepoint. Defaults to None (" "last timepoint)",
80
    )
81
    parser.add_argument(
1✔
82
        "--stage", default=1, type=int, help="The stage ID. Defaults to 1."
83
    )
84
    parser.add_argument(
1✔
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.")
1✔
88
    parser.add_argument(
1✔
89
        "--availability_iteration", default=0, type=int, help="Defaults to 0."
90
    )
91

92
    return parser
1✔
93

94

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

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

103
    return parsed_arguments
1✔
104

105

106
def get_timepoints(conn, scenario_id, starting_tmp=None, ending_tmp=None, stage_id=1):
1✔
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:
1✔
118
        start_query = ""
×
119
    else:
120
        start_query = "AND timepoint >= {}".format(starting_tmp)
1✔
121

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

127
    query = """SELECT timepoint
1✔
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(scenario_id, stage_id, start_query, end_query)
136

137
    tmps = [i[0] for i in conn.execute(query).fetchall()]
1✔
138

139
    return tmps
1✔
140

141

142
def _batch_query_with_timepoints(query_template, conn, timepoints, batch_size=1000):
1✔
143
    """
144
    Helper function to batch large timepoint queries to avoid SQLite limit.
145
    Splits timepoints into batches and concatenates results.
146

147
    :param query_template: Query template that accepts a timepoints_str placeholder
148
    :param conn: Database connection
149
    :param timepoints: List of all timepoints
150
    :param batch_size: Max number of timepoints per query
151
    :return: Concatenated DataFrame or list of results
152
    """
153
    all_dfs = []
1✔
154

155
    for i in range(0, len(timepoints), batch_size):
1✔
156
        batch = timepoints[i : i + batch_size]
1✔
157
        timepoints_str = ",".join(["?"] * len(batch))
1✔
158
        query = query_template.format(timepoints_str=timepoints_str)
1✔
159
        df = pd.read_sql(query, conn, params=batch)
1✔
160
        if not df.empty:
1✔
161
            all_dfs.append(df)
1✔
162

163
    if all_dfs:
1✔
164
        return pd.concat(all_dfs, ignore_index=True)
1✔
165
    else:
NEW
166
        return pd.DataFrame()
×
167

168

169
def get_power_by_tech_results(
1✔
170
    conn,
171
    scenario_id,
172
    load_zone,
173
    weather_iteration,
174
    hydro_iteration,
175
    availability_iteration,
176
    stage,
177
    timepoints,
178
):
179
    """
180
    Get results for power by technology for a given load_zone and set of
181
    points.
182
    :param conn:
183
    :param scenario_id:
184
    :param load_zone:
185
    :param weather_iteration:
186
    :param hydro_iteration:
187
    :param availability_iteration:
188
    :param stage:
189
    :param timepoints
190
    :return:
191
    """
192

193
    # Power by technology with batching to handle large timepoint lists
194
    query_template = f"""SELECT timepoint, technology, power_mw
1✔
195
        FROM results_project_dispatch_by_technology
196
        WHERE scenario_id = {scenario_id}
197
        AND load_zone = '{load_zone}'
198
        AND weather_iteration = {weather_iteration}
199
        AND hydro_iteration = {hydro_iteration}
200
        AND availability_iteration = {availability_iteration}
201
        AND stage_id = {stage}
202
        AND timepoint IN ({{timepoints_str}})
203
        ;"""
204

205
    df = _batch_query_with_timepoints(query_template, conn, timepoints)
1✔
206
    if not df.empty:
1✔
207
        df = df.pivot(index="timepoint", columns="technology")["power_mw"]
1✔
208
        return df
1✔
209
    # If the dataframe was empty, we still need to send the timepoint index
210
    # downstream
211
    else:
212
        index_only_df = pd.DataFrame(index=timepoints)
×
213
        return index_only_df
×
214

215

216
def _batch_execute_query(query_template, cursor, timepoints, batch_size=1000):
1✔
217
    """
218
    Helper function to batch large timepoint queries to avoid SQLite limit.
219
    Splits timepoints into batches and concatenates results.
220

221
    :param query_template: Query template that accepts a timepoints_str placeholder
222
    :param cursor: Database cursor
223
    :param timepoints: List of all timepoints
224
    :param batch_size: Max number of timepoints per query
225
    :return: List of concatenated results
226
    """
227
    all_results = []
1✔
228

229
    for i in range(0, len(timepoints), batch_size):
1✔
230
        batch = timepoints[i : i + batch_size]
1✔
231
        timepoints_str = ",".join(["?"] * len(batch))
1✔
232
        query = query_template.format(timepoints_str=timepoints_str)
1✔
233
        results = cursor.execute(query, batch).fetchall()
1✔
234
        all_results.extend(results)
1✔
235

236
    return all_results
1✔
237

238

239
def get_variable_curtailment_results(
1✔
240
    c,
241
    scenario_id,
242
    load_zone,
243
    weather_iteration,
244
    hydro_iteration,
245
    availability_iteration,
246
    stage,
247
    timepoints,
248
):
249
    """
250
    Get variable generator curtailment for a given load_zone and set of
251
    timepoints.
252
    :param c:
253
    :param scenario_id:
254
    :param load_zone:
255
    :param weather_iteration:
256
    :param hydro_iteration:
257
    :param availability_iteration:
258
    :param stage:
259
    :param timepoints:
260
    :return:
261
    """
262
    query_template = f"""SELECT scheduled_curtailment_mw
1✔
263
            FROM results_project_curtailment_variable_periodagg
264
            WHERE scenario_id = {scenario_id}
265
            AND load_zone = '{load_zone}'
266
            AND weather_iteration = {weather_iteration}
267
            AND hydro_iteration = {hydro_iteration}
268
            AND availability_iteration = {availability_iteration}
269
            AND stage_id = {stage}
270
            AND timepoint IN ({{timepoints_str}})
271
            ;"""
272

273
    results = _batch_execute_query(query_template, c, timepoints)
1✔
274
    curtailment = [i[0] for i in results]
1✔
275

276
    return curtailment
1✔
277

278

279
def get_hydro_curtailment_results(
1✔
280
    c,
281
    scenario_id,
282
    load_zone,
283
    weather_iteration,
284
    hydro_iteration,
285
    availability_iteration,
286
    stage,
287
    timepoints,
288
):
289
    """
290
    Get conventional hydro curtailment for a given load_zone and set of
291
    timepoints.
292
    :param scenario_id:
293
    :param load_zone:
294
    :param weather_iteration:
295
    :param hydro_iteration:
296
    :param availability_iteration:
297
    :param stage:
298
    :param timepoints:
299
    :return:
300
    """
301
    query_template = f"""SELECT scheduled_curtailment_mw
1✔
302
            FROM results_project_curtailment_hydro_periodagg
303
            WHERE scenario_id = {scenario_id}
304
            AND load_zone = '{load_zone}'
305
            AND weather_iteration = {weather_iteration}
306
            AND hydro_iteration = {hydro_iteration}
307
            AND availability_iteration = {availability_iteration}
308
            AND stage_id = {stage}
309
            AND timepoint IN ({{timepoints_str}})
310
            ;"""
311

312
    results = _batch_execute_query(query_template, c, timepoints)
1✔
313
    curtailment = [i[0] for i in results]
1✔
314

315
    return curtailment
1✔
316

317

318
def get_imports_exports_results(
1✔
319
    c,
320
    scenario_id,
321
    load_zone,
322
    weather_iteration,
323
    hydro_iteration,
324
    availability_iteration,
325
    stage,
326
    timepoints,
327
):
328
    """
329
    Get imports/exports results for a given load_zone and set of timepoints.
330
    :param c:
331
    :param scenario_id:
332
    :param load_zone:
333
    :param weather_iteration:
334
    :param hydro_iteration:
335
    :param availability_iteration:
336
    :param stage:
337
    :param timepoints:
338
    :return:
339
    """
340
    query_template = f"""SELECT net_imports_mw
1✔
341
        FROM results_system_load_zone_timepoint
342
        WHERE scenario_id = {scenario_id}
343
        AND load_zone = '{load_zone}'
344
        AND weather_iteration = {weather_iteration}
345
        AND hydro_iteration = {hydro_iteration}
346
        AND availability_iteration = {availability_iteration}
347
        AND stage_id = {stage}
348
        AND timepoint IN ({{timepoints_str}})
349
        ;"""
350

351
    net_imports = _batch_execute_query(query_template, c, timepoints)
1✔
352

353
    # None values should only happen if the transmission feature was not
354
    # included
355
    imports = [0 if i[0] is None else i[0] if i[0] > 0 else 0 for i in net_imports]
1✔
356
    exports = [0 if e[0] is None else -e[0] if e[0] < 0 else 0 for e in net_imports]
1✔
357

358
    return imports, exports
1✔
359

360

361
def get_market_participation_results(
1✔
362
    c,
363
    scenario_id,
364
    load_zone,
365
    weather_iteration,
366
    hydro_iteration,
367
    availability_iteration,
368
    stage,
369
    timepoints,
370
):
371
    """
372
    Get market participation results for a given load_zone and set of timepoints.
373
    :param c:
374
    :param scenario_id:
375
    :param load_zone:
376
    :param weather_iteration:
377
    :param hydro_iteration:
378
    :param availability_iteration:
379
    :param stage:
380
    :param timepoints:
381
    :return:
382
    """
383
    query_template = f"""SELECT net_market_purchases_mw
1✔
384
        FROM results_system_load_zone_timepoint
385
        WHERE scenario_id = {scenario_id}
386
            AND load_zone = '{load_zone}'
387
            AND weather_iteration = {weather_iteration}
388
            AND hydro_iteration = {hydro_iteration}
389
            AND availability_iteration = {availability_iteration}
390
            AND stage_id = {stage}
391
            AND timepoint IN ({{timepoints_str}})
392
            ;"""
393

394
    market_participation = _batch_execute_query(query_template, c, timepoints)
1✔
395

396
    sales = []
1✔
397
    purchases = []
1✔
398
    for i in market_participation:
1✔
399
        if i[0] is None:  # markets feature not included
1✔
400
            sales.append(0)
1✔
401
            purchases.append(0)
1✔
402
        else:
403
            if i[0] < 0:
×
404
                sales.append(-i[0])
×
405
                purchases.append(0)
×
406
            elif i[0] > 0:
×
407
                sales.append(0)
×
408
                purchases.append(i[0])
×
409
            else:
410
                sales.append(0)
×
411
                purchases.append(0)
×
412

413
    return sales, purchases
1✔
414

415

416
def get_load(
1✔
417
    c,
418
    scenario_id,
419
    load_zone,
420
    weather_iteration,
421
    hydro_iteration,
422
    availability_iteration,
423
    stage,
424
    timepoints,
425
):
426
    """
427

428
    :param c:
429
    :param scenario_id:
430
    :param load_zone:
431
    :param weather_iteration:
432
    :param hydro_iteration:
433
    :param availability_iteration:
434
    :param stage:
435
    :param timepoints
436
    :return:
437
    """
438

439
    query_template = f"""SELECT static_load_mw, unserved_energy_mw
1✔
440
        FROM results_system_load_zone_timepoint
441
        WHERE scenario_id = {scenario_id}
442
        AND load_zone = '{load_zone}'
443
        AND weather_iteration = {weather_iteration}
444
        AND hydro_iteration = {hydro_iteration}
445
        AND availability_iteration = {availability_iteration}
446
        AND stage_id = {stage}
447
        AND timepoint IN ({{timepoints_str}})
448
        ;"""
449

450
    load_balance = _batch_execute_query(query_template, c, timepoints)
1✔
451

452
    load = [i[0] for i in load_balance]
1✔
453
    unserved_energy = [i[1] for i in load_balance]
1✔
454

455
    return load, unserved_energy
1✔
456

457

458
def get_plotting_data(
1✔
459
    conn,
460
    scenario_id,
461
    load_zone,
462
    weather_iteration,
463
    hydro_iteration,
464
    availability_iteration,
465
    starting_tmp,
466
    ending_tmp,
467
    stage,
468
    **kwargs,
469
):
470
    """
471
    Get the dispatch data by timepoint and technology for a given
472
    scenario/load_zone/set of timepoints/stage.
473

474
    **kwargs needed, so that an error isn't thrown when calling this
475
    function with extra arguments from the UI.
476

477
    :param conn:
478
    :param scenario_id:
479
    :param load_zone:
480
    :param weather_iteration:
481
    :param hydro_iteration:
482
    :param availability_iteration:
483
    :param starting_tmp:
484
    :param ending_tmp:
485
    :param stage:
486
    :return:
487
    """
488

489
    c = conn.cursor()
1✔
490

491
    # Get the relevant timepoints
492
    timepoints = get_timepoints(conn, scenario_id, starting_tmp, ending_tmp, stage)
1✔
493

494
    # Get dispatch by technology
495
    # TODO: Let tech order depend on specified order in database table.
496
    #  Storage might be tricky because we manipulate it!
497
    df = get_power_by_tech_results(
1✔
498
        conn=conn,
499
        scenario_id=scenario_id,
500
        load_zone=load_zone,
501
        stage=stage,
502
        timepoints=timepoints,
503
        weather_iteration=weather_iteration,
504
        hydro_iteration=hydro_iteration,
505
        availability_iteration=availability_iteration,
506
    )
507

508
    # Add x axis
509
    # TODO: assumes hourly timepoints for now, make it flexible instead
510
    df["x"] = range(0, len(df))
1✔
511

512
    # Split storage into charging and discharging and aggregate storage charging
513
    # Assume any dispatch that is negative is storage charging
514
    # TODO: this doesn't work if there are other negative dispatch values (
515
    #  e.g., negative capacity factors for certain technologies)
516
    df["Storage_Charging"] = 0
1✔
517
    stor_techs = df.columns[(df < 0).any()]
1✔
518
    # stor_techs = ["Battery"]
519
    for tech in stor_techs:
1✔
520
        df["Storage_Charging"] += -df[tech].clip(upper=0)
×
521
        df[tech] = df[tech].clip(lower=0)
×
522

523
    # df["Flex_Load_Charging"] = 0
524
    # flex_load_techs = ["Flexible_Load"]
525
    # for tech in flex_load_techs:
526
    #     df["Flex_Load_Charging"] += -df[tech].clip(upper=0)
527
    #     df[tech] = df[tech].clip(lower=0)
528

529
    # Add variable curtailment (if any)
530
    curtailment_variable = get_variable_curtailment_results(
1✔
531
        c=c,
532
        scenario_id=scenario_id,
533
        load_zone=load_zone,
534
        weather_iteration=weather_iteration,
535
        hydro_iteration=hydro_iteration,
536
        availability_iteration=availability_iteration,
537
        stage=stage,
538
        timepoints=timepoints,
539
    )
540
    if curtailment_variable:
1✔
541
        df["Curtailment_Variable"] = curtailment_variable
×
542

543
    # Add hydro curtailment (if any)
544
    curtailment_hydro = get_hydro_curtailment_results(
1✔
545
        c=c,
546
        scenario_id=scenario_id,
547
        load_zone=load_zone,
548
        weather_iteration=weather_iteration,
549
        hydro_iteration=hydro_iteration,
550
        availability_iteration=availability_iteration,
551
        stage=stage,
552
        timepoints=timepoints,
553
    )
554
    if curtailment_hydro:
1✔
555
        df["Curtailment_Hydro"] = curtailment_hydro
×
556

557
    # Add imports and exports (if any)
558
    imports, exports = get_imports_exports_results(
1✔
559
        c=c,
560
        scenario_id=scenario_id,
561
        load_zone=load_zone,
562
        weather_iteration=weather_iteration,
563
        hydro_iteration=hydro_iteration,
564
        availability_iteration=availability_iteration,
565
        stage=stage,
566
        timepoints=timepoints,
567
    )
568
    if imports:
1✔
569
        df["Imports"] = imports
1✔
570
    if exports:
1✔
571
        df["Exports"] = exports
1✔
572

573
    # Add market participation (if any)
574
    market_sales, market_purchases = get_market_participation_results(
1✔
575
        c=c,
576
        scenario_id=scenario_id,
577
        load_zone=load_zone,
578
        weather_iteration=weather_iteration,
579
        hydro_iteration=hydro_iteration,
580
        availability_iteration=availability_iteration,
581
        stage=stage,
582
        timepoints=timepoints,
583
    )
584
    if market_sales:
1✔
585
        df["Market_Sales"] = market_sales
1✔
586
    if market_purchases:
1✔
587
        df["Market_Purchases"] = market_purchases
1✔
588

589
    # Add load
590
    load_balance = get_load(
1✔
591
        c=c,
592
        scenario_id=scenario_id,
593
        load_zone=load_zone,
594
        weather_iteration=weather_iteration,
595
        hydro_iteration=hydro_iteration,
596
        availability_iteration=availability_iteration,
597
        stage=stage,
598
        timepoints=timepoints,
599
    )
600

601
    df["Load"] = load_balance[0]
1✔
602
    df["Unserved_Energy"] = load_balance[1]
1✔
603

604
    # Dataframe for testing without database
605
    # df = pd.DataFrame(
606
    #     data={
607
    #         "unspecified": range(10),
608
    #         "Nuclear": range(10),
609
    #         "Coal": range(10),
610
    #         "CHP": range(10),
611
    #         "Geothermal": range(10),
612
    #         "Biomass": range(10),
613
    #         "Small_Hydro": range(10),
614
    #         "Steam": range(10),
615
    #         "CCGT": range(10),
616
    #         "Hydro": range(10),
617
    #         "Imports": range(10),
618
    #         "Peaker": range(10),
619
    #         "Wind": range(10),
620
    #         "Solar_BTM": range(10),
621
    #         "Solar": range(10),
622
    #         "Pumped_Storage": range(10),
623
    #         "Battery": range(10),
624
    #         "Curtailment_Variable": range(10),
625
    #         "Curtailment_Hydro": range(10),
626
    #         "x": range(10),
627
    #         "Load": range(10),
628
    #         "Exports": range(10),
629
    #         "Pumped_Storage_Charging": [-5] * 10,
630
    #         "Battery_Charging": [-10] * 10
631
    #     }
632
    # )
633

634
    return df
1✔
635

636

637
def create_plot(
1✔
638
    df, title, power_unit, tech_colors={}, tech_plotting_order={}, ylimit=None
639
):
640
    """
641

642
    :param df:
643
    :param title: string, plot title
644
    :param power_unit: string, the unit of power used in the database/model
645
    :param tech_colors: optional dict that maps technologies to colors.
646
        Technologies without a specified color map will use a default palette
647
    :param tech_plotting_order: optional dict that maps technologies to their
648
        plotting order in the stacked bar/area chart.
649
    :param ylimit: float/int, upper limit of y-axis; optional
650
    :return:
651
    """
652

653
    # Re-arrange df according to plotting order
654
    for col in df.columns:
1✔
655
        if col not in tech_plotting_order:
1✔
656
            tech_plotting_order[col] = max(tech_plotting_order.values()) + 1
1✔
657
    df = df.reindex(sorted(df.columns, key=lambda x: tech_plotting_order[x]), axis=1)
1✔
658

659
    # Set up data source
660
    source = ColumnDataSource(data=df)
1✔
661

662
    # Determine column types for plotting, legend and colors
663
    # Order of stacked_cols will define order of stacked areas in chart
664
    all_cols = list(df.columns)
1✔
665
    x_col = "x"
1✔
666
    # We'll need to remove the following from the stacked columns (will be
667
    # treated as lines instead)
668
    # Note: the order of this list determines the order of the 'Load + ...'
669
    # lines
670
    items_treated_as_load_plus_lines = [
1✔
671
        # "Flex_Load_Charging",
672
        "Storage_Charging",
673
        "Exports",
674
        "Market_Sales",
675
    ]
676
    line_cols_storage_sum_track = [
1✔
677
        "Load",
678
    ] + items_treated_as_load_plus_lines
679
    stacked_cols = [
1✔
680
        c for c in all_cols if c not in line_cols_storage_sum_track + [x_col]
681
    ]
682

683
    # Set up color scheme. Use cividis palette for unspecified colors
684
    unspecified_columns = [c for c in stacked_cols if c not in tech_colors.keys()]
1✔
685
    unspecified_tech_colors = dict(
1✔
686
        zip(unspecified_columns, cividis(len(unspecified_columns)))
687
    )
688
    colors = []
1✔
689
    for tech in stacked_cols:
1✔
690
        if tech in tech_colors:
1✔
691
            colors.append(tech_colors[tech])
1✔
692
        else:
693
            colors.append(unspecified_tech_colors[tech])
1✔
694

695
    # Set up the figure
696
    plot = figure(
1✔
697
        min_width=1200,
698
        min_height=500,
699
        tools=["pan", "reset", "zoom_in", "zoom_out", "save", "help"],
700
        title=title,
701
        # sizing_mode="scale_both"
702
    )
703

704
    # Add stacked area chart to plot
705
    area_renderers = plot.vbar_stack(
1✔
706
        stackers=stacked_cols,
707
        x=x_col,
708
        source=source,
709
        color=colors,
710
        width=1,
711
    )
712
    # Note: can easily change vbar_stack to varea_stack by replacing the plot
713
    # function and removing the width argument. However, hovertools don't work
714
    # with varea_stack.
715

716
    # Add load line chart to plot
717
    load_renderer = plot.line(
1✔
718
        x=df[x_col], y=df["Load"], line_color="black", line_width=2, name="Load"
719
    )
720

721
    # Keep track of legend items and load renderers
722
    legend_items = [
1✔
723
        (x, [area_renderers[i]]) for i, x in enumerate(stacked_cols) if df[x].mean() > 0
724
    ] + [("Load", [load_renderer])]
725
    load_renderers = [load_renderer]
1✔
726

727
    # Add 'Load + ...' lines
728
    active_items = []
1✔
729
    for i in items_treated_as_load_plus_lines:
1✔
730
        if i not in df.columns:
1✔
731
            inactive = True
×
732
        else:
733
            inactive = (df[i] == 0).all()
1✔
734
        if not inactive:
1✔
735
            active_items.append(i)
×
736

737
    previously_processed_items = []
1✔
738
    for active_item in active_items:
1✔
739
        line_cols_storage_sum_track = (
×
740
            ["Load"] + previously_processed_items + [active_item]
741
        )
742
        # Add export line to plot
743
        label = " + ".join(str(x) for x in line_cols_storage_sum_track)
×
744
        exports_renderer = plot.line(
×
745
            x=df[x_col],
746
            y=df[line_cols_storage_sum_track].sum(axis=1),
747
            line_color="black",
748
            line_width=len(line_cols_storage_sum_track),
749
            line_dash=(
750
                "dashed" if (len(line_cols_storage_sum_track) % 2) == 0 else "dotted"
751
            ),
752
            name=label,
753
        )
754
        legend_items.append((label, [exports_renderer]))
×
755
        load_renderers.append(exports_renderer)
×
756

757
        previously_processed_items += [active_item]
×
758

759
    # Add Legend
760
    legend = Legend(items=legend_items)
1✔
761
    plot.add_layout(legend, "right")
1✔
762
    plot.legend[0].items.reverse()  # Reverse legend to match stacked order
1✔
763
    plot.legend.click_policy = "hide"  # Add interactivity to the legend
1✔
764
    # Note: Doesn't rescale the graph down, simply hides the area
765
    # Note2: There's currently no way to auto-size legend based on graph size(?)
766
    # except for maybe changing font size automatically?
767
    show_hide_legend(plot=plot)  # Hide legend on double click
1✔
768

769
    # Format Axes (labels, number formatting, range, etc.)
770
    plot.xaxis.axis_label = "Hour Ending"
1✔
771
    plot.yaxis.axis_label = "Dispatch ({})".format(power_unit)
1✔
772
    plot.yaxis.formatter = NumeralTickFormatter(format="0,0")
1✔
773
    if ylimit is not None:
1✔
774
        plot.y_range.end = ylimit  # will be ignored if ylimit is None
×
775

776
    # Add HoverTools for stacked bars/areas
777
    for r in area_renderers:
1✔
778
        power_source = r.name
1✔
779
        hover = HoverTool(
1✔
780
            tooltips=[
781
                ("Hour Ending", "@x"),
782
                ("Source", power_source),
783
                ("Dispatch", "@%s{0,0} %s" % (power_source, power_unit)),
784
            ],
785
            renderers=[r],
786
            visible=False,
787
        )
788
        plot.add_tools(hover)
1✔
789

790
    # Add HoverTools for load lines
791
    for r in load_renderers:
1✔
792
        load_type = r.name
1✔
793
        hover = HoverTool(
1✔
794
            tooltips=[
795
                ("Hour Ending", "@x"),
796
                (load_type, "@y{0,0} %s" % power_unit),
797
            ],
798
            renderers=[r],
799
            visible=False,
800
        )
801
        plot.add_tools(hover)
1✔
802

803
    return plot, source
1✔
804

805

806
def main(args=None):
1✔
807
    """
808
    Parse the arguments, get the data in a df, and create the plot
809

810
    :return: if requested, return the plot as JSON object
811
    """
812
    if args is None:
1✔
813
        args = sys.argv[1:]
×
814
    parsed_args = parse_arguments(arguments=args)
1✔
815

816
    conn = connect_to_database(db_path=parsed_args.database)
1✔
817
    c = conn.cursor()
1✔
818

819
    scenario_id, scenario = get_scenario_id_and_name(
1✔
820
        scenario_id_arg=parsed_args.scenario_id,
821
        scenario_name_arg=parsed_args.scenario,
822
        c=c,
823
        script="dispatch_plot",
824
    )
825

826
    tech_colors = get_tech_colors(c)
1✔
827
    tech_plotting_order = get_tech_plotting_order(c)
1✔
828
    power_unit = get_unit(c, "power")
1✔
829

830
    plot_title = "{}Dispatch Plot - {} - Stage {} - Timepoints {}-{}".format(
1✔
831
        "{} - ".format(scenario) if parsed_args.scenario_name_in_title else "",
832
        parsed_args.load_zone,
833
        parsed_args.stage,
834
        parsed_args.starting_tmp,
835
        parsed_args.ending_tmp,
836
    )
837
    plot_name = "dispatchPlot-{}-{}-{}-{}".format(
1✔
838
        parsed_args.load_zone,
839
        parsed_args.stage,
840
        parsed_args.starting_tmp,
841
        parsed_args.ending_tmp,
842
    )
843

844
    df = get_plotting_data(
1✔
845
        conn=conn,
846
        scenario_id=scenario_id,
847
        load_zone=parsed_args.load_zone,
848
        starting_tmp=parsed_args.starting_tmp,
849
        ending_tmp=parsed_args.ending_tmp,
850
        stage=parsed_args.stage,
851
        weather_iteration=parsed_args.weather_iteration,
852
        hydro_iteration=parsed_args.hydro_iteration,
853
        availability_iteration=parsed_args.availability_iteration,
854
    )
855

856
    conn.close()
1✔
857

858
    plot, source = create_plot(
1✔
859
        df=df,
860
        title=plot_title,
861
        power_unit=power_unit,
862
        tech_colors=tech_colors,
863
        tech_plotting_order=tech_plotting_order,
864
        ylimit=parsed_args.ylimit,
865
    )
866

867
    # Show plot in HTML browser file if requested
868
    if parsed_args.show:
1✔
869
        show_plot(
×
870
            plot=plot,
871
            plot_name=plot_name,
872
            plot_write_directory=parsed_args.plot_write_directory,
873
            scenario=scenario,
874
            source=source,
875
        )
876

877
    # Return plot in json format if requested
878
    if parsed_args.return_json:
1✔
879
        return json_item(plot, "plotHTMLTarget")
×
880

881

882
if __name__ == "__main__":
1✔
883
    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