Running the bundled example app

Running the example app

Note

Installing with optional dependencies for hosting the basic app is done by

pip install fbmc-linearisation-analysis[app-template]

This package comes bundled with an example app. It just needs a function to read data from internal CNECs, but this defaults to None and can be ignored.

The app is streamlit and the minimal configuration is to import the app function into a file, e.g appfile.py:

from fbmc_quality.linearisation_error_app import app

if __name__ == '__main__':
    app()

This app can then be run from the commandline with:

streamlit run appfile.py

Which will start the streamlit dashboard.

If you have a function that reads data from internal CNECs it must return a dataframe with columns flow and fmax. It takes as argument the date-time range (start-end), and the name of the CNEC as it appears in JAO.

Repeating the setup as above with this function:

from fbmc_quality.linearisation_error_app import app
from XXX import internal_cnec_function

if __name__ == '__main__':
    app(internal_cnec_function)

Example app

  1import logging
  2from datetime import date, timedelta
  3from typing import Callable, Optional
  4
  5import numpy as np
  6import pandas as pd
  7import plotly.express as px
  8import plotly.graph_objects as go
  9import plotly.io as pio
 10import streamlit as st
 11from dotenv import load_dotenv
 12from joblib import Parallel
 13from pandas import NaT
 14from pkg_resources import declare_namespace
 15from pytz import timezone
 16
 17from fbmc_quality.dataframe_schemas.schemas import JaoData
 18
 19# from fbmc_quality.linearisation_analysis.process_data import get_from_to_bz_from_name
 20from fbmc_quality.entsoe_data.fetch_entsoe_data import get_from_to_bz_from_name
 21from fbmc_quality.enums.bidding_zones import BiddingZonesEnum
 22from fbmc_quality.jao_data import get_cnec_id_from_name
 23from fbmc_quality.jao_data.fetch_jao_data import create_uuid_from_string
 24from fbmc_quality.linearisation_analysis import (
 25    JaoDataAndNPS,
 26    compute_cnec_vulnerability_to_err,
 27    compute_linearisation_error,
 28    compute_linearised_flow,
 29    fetch_jao_data_basecase_nps_and_observed_nps,
 30    load_data_for_corridor_cnec,
 31    load_data_for_internal_cnec,
 32)
 33from fbmc_quality.linearisation_analysis.process_data import align_by_index_overlap
 34from fbmc_quality.plotting.flow_map import compute_flow_geo_frame, draw_flow_map_figure, get_european_nps
 35
 36load_dotenv()
 37logging.basicConfig(level="INFO")
 38
 39st.set_page_config(layout="wide")
 40PARALLEL_CONTEXT = Parallel()
 41
 42SHADOW_CNECS = [
 43    "13791_325  65% 420 Namsos-Ogndal + 30% 420 Namsos-Hofstad + 300 Tunnsjødal-Verdal",
 44    "13791_325  65% 420 Namsos-Ogndal + 40% 420 Namsos-Hofstad + 300 Tunnsjødal-Verdal",
 45    "15319_10  420 Sylling-Rjukan + 420 Hasle-Rød + 300 Sylling-Flesaker + 300 Tegneby-Flesaker",
 46    "15319_182  25% 420 Rjukan-Kvilldal + 300 Mauranger-Blåfalli",
 47    "L150_11  40% 420 Hasle-Tegneby + Hasle    T6 Transformator P",
 48    "13791_325  15% 420 Hasle-Rød + 300 Mauranger-Blåfalli",
 49    "15290_10  40% 420 Høyanger-Sogndal + 300 Øvre Vinstra-Fåberg",
 50    "14310_11  55% 300 Blåfalli-Sauda + 300 Husnes-Børtveit",
 51    "13791_10  300 Mauranger-Blåfalli",
 52    "13791_11  40% 300 Øvre Vinstra-Fåberg + 420 Moskog-Høyanger",
 53    "15315_11  40% 300 Minne-Frogner + 300 Roa-Ulven",
 54    "L4_11  40% 420 Tegneby-Hasle + 300 Røykås-Tegneby",
 55    "13791_325  65% 420 Rød-Grenland + 300 Rød-Porsgrunn",
 56]
 57
 58
 59@st.cache_data
 60def get_data(start, end, _deanonymizer):
 61    if isinstance(start, date) and isinstance(end, date):
 62        if start > end:
 63            return None
 64
 65        data_load_state = st.text("Loading data...")
 66        data = fetch_jao_data_basecase_nps_and_observed_nps(start, end)
 67        if _deanonymizer is not None:
 68            jaodata = data.jaoData
 69            jaodata[JaoData.cnecName] = jaodata[JaoData.cnecName].apply(_deanonymizer)
 70            jaodata[JaoData.cnec_id] = jaodata.apply(
 71                lambda row: create_uuid_from_string(row[JaoData.cnecName] + row[JaoData.contName]), axis=1
 72            )
 73            data = JaoDataAndNPS(jaodata, data.basecaseNPs, data.observedNPs)
 74
 75        data_load_state.text("Loading data...done!")
 76        return data
 77
 78
 79class DataContainer:
 80    def __init__(
 81        self, data: JaoDataAndNPS, internal_cnec_func: Callable[[date, date, str], pd.DataFrame | None] | None
 82    ):
 83        self.data = data
 84        self.internal_cnec_func = internal_cnec_func
 85
 86    @st.cache_data
 87    def get_cnec_data(_self, selected_name: str, start, end):
 88        data_load_state = st.text("Loading CNEC data...")
 89
 90        from_bz, to_bz = get_from_to_bz_from_name(selected_name)
 91        if from_bz is None or to_bz is None:
 92            if _self.internal_cnec_func is not None:
 93                cnec_data = load_data_for_internal_cnec(selected_name, _self.internal_cnec_func, _self.data)
 94            else:
 95                st.error(f"No function for reading internal CNECs supplied, and no BZ found for {selected_name}")
 96                cnec_data = None
 97        else:
 98            cnec_data = load_data_for_corridor_cnec(selected_name, _self.data)
 99        data_load_state.text("Loading CNEC data...done!")
100        return cnec_data
101
102
103def get_names(data: JaoDataAndNPS) -> "pd.Series[pd.StringDtype]":
104    return pd.Series(data.jaoData[JaoData.cnecName].unique())
105
106
107@st.cache_data
108def get_data_for_all_cnecs(_internal_cnec_func, names: list[str | pd.StringDtype], start, end):
109    cnec_data = _internal_cnec_func(start, end, names)
110    return cnec_data
111
112
113def app(
114    internal_cnec_func: Callable[[date, date, str | list[str]], pd.DataFrame | dict[str, pd.DataFrame] | None]
115    | None = None,
116    deanonymizer: Callable[[str], str] | None = None,
117):
118    load_dotenv()
119
120    pio.templates.default = "ggplot2"
121    st.title("Linearisation Error Explorer")
122
123    if internal_cnec_func is not None:
124        tabs = st.tabs(["Single CNEC Analysis", "Period all cnec Analysis"])
125        single, all_cnecs = tabs
126        single_cnec_analysis(internal_cnec_func, deanonymizer, single)
127        all_cnecs_analysis(internal_cnec_func, deanonymizer, all_cnecs)
128    else:
129        single_cnec_analysis(None, deanonymizer, st)
130
131
132def draw_flow_map(time: pd.Timestamp, data: JaoDataAndNPS, european_nps: dict[BiddingZonesEnum, pd.Series], st_col):
133    with st_col.status("Plotting Flow..."):
134        st.write("Computing Flow for MTU...")
135        geo_df, obs_flow, fb_flow = compute_flow_geo_frame(time, data.jaoData, data.observedNPs, european_nps)
136        st.write("Drawing Flow map for MTU...")
137        fig = draw_flow_map_figure(
138            geo_df,
139            obs_flow,
140            fb_flow,
141        )
142        st.write("Rendering map...")
143        st_col.plotly_chart(fig, use_container_width=True)
144
145
146def all_cnecs_analysis(
147    internal_cnec_func: Callable[[date, date, list[str]], dict[str, pd.DataFrame] | None] | None = None,
148    deanonymizer: Callable[[str, Optional[bool]], str] | None = None,
149    st=None,
150):
151    if st is None:
152        return
153
154    start = st.date_input("Start Date", value=None, max_value=date.today() - timedelta(2), key="single_start")
155    end = st.date_input("End Date", value=None, max_value=date.today() - timedelta(1), key="single_end")
156    utc = timezone("utc")
157    start = utc.localize(pd.Timestamp(start))
158    end = utc.localize(pd.Timestamp(end))
159
160    st.text(
161        """
162            Plot of the Vulnerability score for all CNECs in a period.
163            The x axis shows if the FB process consistently under or overestimated the flow.
164            When computing the Linearisation Error, the flow is capped to the max. flow allowable at the CNEC.
165            The Vulnerability Score is calculated as: 
166    """
167    )
168
169    st.latex(r"\text{Relative} \hspace{0.1in} V=\frac{F_{obs} - min(F_{fb-max}, F_{fb})}{F_{limit} - F_{obs}}")
170    names = None
171    data = None
172    all_cnec_data = None
173    overallocated_capacity = None
174    underallocated_capacity = None
175
176    if start is not NaT and end is not NaT:
177        data = get_data(start, end, deanonymizer)
178    if data is not None:
179        names = list(get_names(data))
180
181    if names is not None:
182        all_cnec_data = get_data_for_all_cnecs(internal_cnec_func, names, start, end)
183
184    if all_cnec_data is not None and data is not None:
185        too_much_allocated_capacity = []
186        too_little_allocated_capacity = []
187
188        for cnec_name, frame in all_cnec_data.items():
189            try:
190                cnec_id = get_cnec_id_from_name(cnec_name, data.jaoData)
191
192                if ("fmax" not in frame.columns) or ("flow" not in frame.columns):
193                    raise ValueError('The internal cnec function must return a frame with columns "flow" and "fmax"')
194
195                cnec_data = data.jaoData.xs(cnec_id, level=JaoData.cnec_id)
196                overlap = align_by_index_overlap(cnec_data, frame, data.observedNPs)
197                frame = frame.loc[overlap]
198                vuln_cnec_data = compute_cnec_vulnerability_to_err(
199                    cnec_data.loc[overlap],
200                    data.observedNPs.loc[overlap],
201                    frame["flow"].loc[overlap],
202                    frame["fmax"].loc[overlap],
203                )
204            except:
205                continue
206
207            mtus_above_threshold = 100 * (vuln_cnec_data["vulnerability_score"] > 1).sum() / len(vuln_cnec_data)
208            median_above_zero = vuln_cnec_data["vulnerability_score"][
209                vuln_cnec_data["vulnerability_score"] > 0
210            ].median()
211
212            too_much_allocated_capacity.append(
213                {
214                    "mtus_above_threshod": mtus_above_threshold,
215                    "median_above_zero": median_above_zero,
216                    "cnec": cnec_name,
217                    "Significant Shadow Price": cnec_name in SHADOW_CNECS,
218                    "Significant Domain Limit": (cnec_data[JaoData.nonRedundant].sum() / len(cnec_data)) > 0.1,
219                }
220            )
221
222            mtus_below_threshold = 100 * (vuln_cnec_data["vulnerability_score"] < -1).sum() / len(vuln_cnec_data)
223            median_below_zero = vuln_cnec_data["vulnerability_score"][
224                vuln_cnec_data["vulnerability_score"] < 0
225            ].median()
226            too_little_allocated_capacity.append(
227                {
228                    "mtus_below_threshod": mtus_below_threshold,
229                    "median_below_zero": median_below_zero,
230                    "cnec": cnec_name,
231                    "Significant Shadow Price": cnec_name in SHADOW_CNECS,
232                    "Significant Domain Limit": (cnec_data[JaoData.nonRedundant].sum() / len(cnec_data)) > 0.1,
233                }
234            )
235
236        overallocated_capacity = pd.DataFrame(too_much_allocated_capacity)
237        overallocated_capacity = overallocated_capacity[
238            (overallocated_capacity["mtus_above_threshod"] > 0) | (overallocated_capacity["median_above_zero"] > 0.7)
239        ]
240        underallocated_capacity = pd.DataFrame(too_little_allocated_capacity)
241        underallocated_capacity = underallocated_capacity[
242            (underallocated_capacity["mtus_below_threshod"] > 0) | (underallocated_capacity["median_below_zero"] < -0.7)
243        ]
244
245    if overallocated_capacity is not None and underallocated_capacity is not None:
246        # st.dataframe(overallocated_capacity)
247        # st.dataframe(underallocated_capacity)
248
249        # fig_over = go.Figure()
250        # fig_over.add_trace(
251        #     go.Scatter(
252        #         x=overallocated_capacity["median_above_zero"],
253        #         y=overallocated_capacity["mtus_above_threshod"],
254        #         text=overallocated_capacity["cnec"],
255        #         mode="markers",
256        #     )
257        # )
258        fig_over = px.scatter(
259            overallocated_capacity,
260            x="median_above_zero",
261            y="mtus_above_threshod",
262            hover_data=["cnec"],
263            color="Significant Shadow Price",
264            symbol="Significant Domain Limit",
265        )
266
267        fig_over.update_layout(
268            title="CNECs that may have caused overloads",
269            xaxis_title="Median Vulnerability Score - for MTUS with V > 0 ",
270            yaxis_title=r"% of Active MTUS with Vulnerability score > 1",
271            font=dict(
272                size=24,  # Set the font size here
273            ),
274            xaxis=dict(tickfont=dict(size=14)),  # Change the size value as needed
275            yaxis=dict(tickfont=dict(size=14)),  # Change the size value as needed
276        )
277        st.plotly_chart(fig_over, use_container_width=True)
278        filename = f"{start}-to-{end}-overloadrisk.html"
279        st.download_button("Download Overestimate Plot as HTML", fig_over.to_html(), file_name=filename)
280
281        # fig_under = go.Figure()
282        # fig_under.add_trace(
283        #     go.Scatter(
284        #         x=underallocated_capacity["median_below_zero"],
285        #         y=underallocated_capacity["mtus_below_threshod"],
286        #         text=underallocated_capacity["cnec"],
287        #         mode="markers",
288        #     )
289        # )
290
291        fig_under = px.scatter(
292            underallocated_capacity,
293            x="median_below_zero",
294            y="mtus_below_threshod",
295            hover_data=["cnec"],
296            color="Significant Shadow Price",
297            symbol="Significant Domain Limit",
298        )
299        fig_under.update_layout(
300            title="CNECs that may have caused too tight capacity restrictions",
301            xaxis_title="Median Vulnerability Score - for MTUS with V < 0 ",
302            yaxis_title=r"% of Active MTUS with Vulnerability score < -1",
303            font=dict(
304                size=24,  # Set the font size here
305            ),
306            xaxis=dict(tickfont=dict(size=14)),  # Change the size value as needed
307            yaxis=dict(tickfont=dict(size=14)),  # Change the size value as needed
308        )
309        st.plotly_chart(fig_under, use_container_width=True)
310        filename = f"{start}-to-{end}-underallocaterisk.html"
311        st.download_button("Download Underestimate Plot as HTML", fig_under.to_html(), file_name=filename)
312
313
314def single_cnec_analysis(internal_cnec_func, deanonymizer, st):
315    col1, col2 = st.columns(2)
316    lin_err_from_cnec(internal_cnec_func, deanonymizer, col1, col2)
317
318
319def lin_err_from_cnec(internal_cnec_func, deanonymizer, st, map_st):
320    start = st.date_input("Start Date", value=None, max_value=date.today() - timedelta(2))
321    end = st.date_input("End Date", value=None, max_value=date.today() - timedelta(1))
322    utc = timezone("utc")
323    start = utc.localize(pd.Timestamp(start))
324    end = utc.localize(pd.Timestamp(end))
325    cnec_data = None
326    cnec_data_container = None
327    selected_name = None
328    data = None
329    new_fmax = st.number_input("Replace Fmax in calculations with Number")
330
331    if start is not NaT and end is not NaT:
332        data = get_data(start, end, deanonymizer)
333
334    if data is not None:
335        cnec_data_container = DataContainer(data, internal_cnec_func)
336        selected_name = st.selectbox(
337            "Which CNEC do you want to plot for?", get_names(data), index=None, placeholder="Search for CNEC..."
338        )
339
340    if selected_name is not None and cnec_data_container is not None:
341        cnec_data = cnec_data_container.get_cnec_data(selected_name, start, end)
342
343    if cnec_data is not None and data is not None:
344        lin_err = compute_linearisation_error(
345            cnec_data.cnecData, cnec_data.observedNPs, cnec_data.observed_flow["flow"]
346        )
347        lin_err_frame = pd.DataFrame(
348            {
349                "Linearisation Error": lin_err,
350                "Observed Flow": cnec_data.observed_flow["flow"],
351                "Linearised Flow": compute_linearised_flow(cnec_data.cnecData, cnec_data.observedNPs),
352            }
353        )
354
355        fig = px.density_contour(
356            lin_err_frame,
357            x="Observed Flow",
358            y="Linearised Flow",
359            marginal_x="box",
360            marginal_y="box",
361            width=600,
362            height=600,
363            title="Linearisation Error distribution",
364        )
365
366        reset_lin_err = lin_err_frame.reset_index()
367        new_frame = pd.melt(
368            reset_lin_err, id_vars=["time"], value_vars=[col for col in reset_lin_err.columns if col != "time"]
369        )
370        lineplot = px.line(
371            new_frame,
372            x="time",
373            y="value",
374            color="variable",
375            labels={"x": "Date", "y": "Flow and Linearisation Error"},
376            title="Linearisation Error timeseries",
377        )
378        fmax = (
379            cnec_data.observed_flow["fmax"]
380            if "fmax" in cnec_data.observed_flow.columns
381            else cnec_data.cnecData[JaoData.fmax]
382        )
383        fmax = fmax if not new_fmax else np.full_like(cnec_data.cnecData.index, new_fmax)
384        lineplot.add_trace(go.Scatter(x=cnec_data.observed_flow.index, y=fmax, name="Fmax", line=dict(dash="dash")))
385        st.plotly_chart(lineplot)
386
387        selected_time = map_st.selectbox("Select MTU to view flow", cnec_data.observed_flow.index)
388        if selected_time is not None:
389            try:
390                european_nps = get_european_nps(start, end)
391                draw_flow_map(selected_time, data, european_nps, map_st)
392            except Exception as e:
393                st.error(f"Drawing map failed with {e}")
394
395        fig.update_layout(
396            font=dict(
397                size=16,  # Set the font size here
398            )
399        )
400        fig.update_traces(line={"width": 2})
401        st.plotly_chart(fig)
402
403        fig = px.box(
404            lin_err_frame,
405            x=lin_err_frame.index.date,
406            y="Linearisation Error",
407            labels={"x": "Date", "y": "Linearisation Error"},
408        )
409        fig.update_layout(title="Linearisation Error Boxplot per Day")
410        st.plotly_chart(fig)
411
412        vulnerability_frame = compute_cnec_vulnerability_to_err(
413            cnec_data.cnecData, cnec_data.observedNPs, cnec_data.observed_flow["flow"], fmax
414        )
415        reset_vuln_frame = vulnerability_frame.reset_index()
416        new_vuln_frame = pd.melt(
417            reset_vuln_frame, id_vars=["time"], value_vars=[col for col in reset_vuln_frame.columns if col != "time"]
418        )
419
420        fmax_mean = cnec_data.cnecData[JaoData.fmax].mean()
421        vuln_lineplot = px.line(
422            new_vuln_frame,
423            x="time",
424            y="value",
425            color="variable",
426            labels={"x": "Date", "y": "Score value"},
427            title=f"Vulnerability and Reliability against Fmax ~ {fmax_mean}",
428        )
429        st.plotly_chart(vuln_lineplot)
430
431
432if __name__ == "__main__":
433    app()