Skip to content

Commit

Permalink
Plot children (#87)
Browse files Browse the repository at this point in the history
* changed to poetry

* fixed pint problem (groups in unitdef.txt) and pint-pandas dependency problem by fixing pandas to 2.0

* ci pipeline

* ci pipeline

* ci with poetry

* solve dependency problems between pandas and pint-pandas

* workaround pint-pandas bug

* typo

* seaborn pinned version to 0.8

* fixed to_excel function, updated version of pint

* fixed arithmatic on pflines without overlap

* small changes
small changes

* more consistent functions to get random pfline

* deleted comment

* change the python version for push request to 3.11

* test

* Removed unused dependency

* updated toml

* added exception if no clipboard available

* updated toml

* fixed pfline_excelclipboad.py

* changed lock file for pre-commit

* install poetry libraries in pre-commit step

* use black on all files in pre-commit

* updated flake8 version in pre-commit.yaml

* exclude .venv folder from flake8

* changed setup.cfg to ignore flake8 error messages

* initial commit

* first try at plotting children

* bar plot with children

* created plot_children(),fixed bug with darken

* area plots for children stacked on top of each other. for daily it plots only parent

* changed the logic of all plot_timeseries_as functions, now based on frequency

* created a function to test plotting pfline

* created slice attr, wrote tests for it

* more flexible intersec function with ignore freq, tz, start_of_day + tests

* small changes to intersect

* intersect_flex function is finished, more testing with frames needed

* finished intersect_flex, more testing with frames needed

* added children bool to plot pfstate

* added hash function for colors for children

* deleted unnecessary test

* changed hash function, and width of hline

* deleted unnecessary test

* plot with children

* changed plot_pfstate to work with new logic

* tests for plot function

* added function to set limits to plot pfstate

* fixed strict variable for test cases

* Deleted unnecessary files

* changed yaml file to exclude python 3.10 with mac 14

---------

Co-authored-by: rwijtvliet <[email protected]>
Co-authored-by: Alina Voilova <[email protected]>
  • Loading branch information
3 people authored Apr 30, 2024
1 parent ad79fa2 commit 2666c1f
Show file tree
Hide file tree
Showing 11 changed files with 457 additions and 284 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/ci-on-pullreq.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ jobs:
matrix:
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
python-version: ["3.10", "3.11", "3.12"]

exclude:
# Exclude Python 3.10 on macOS latest
- os: "macos-latest"
python-version: "3.10"
steps:
- name: Checkout source
uses: actions/checkout@v2
Expand Down
1 change: 1 addition & 0 deletions portfolyo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

# from .core.shared.concat import general as concat


VOLUME = Kind.VOLUME
PRICE = Kind.PRICE
REVENUE = Kind.REVENUE
Expand Down
4 changes: 2 additions & 2 deletions portfolyo/core/pfline/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@
import pandas as pd

from ... import tools
from ..shared import ExcelClipboardOutput, PfLinePlot, PfLineText
from ..ndframelike import NDFrameLike
from ..shared import ExcelClipboardOutput, PfLinePlot, PfLineText
from . import (
children,
create,
dataframeexport,
decorators,
flat_methods,
nested_methods,
prices,
children,
)
from .arithmatic import PfLineArithmatic
from .enums import Kind, Structure
Expand Down
Empty file added portfolyo/core/pfline/concat.py
Empty file.
260 changes: 162 additions & 98 deletions portfolyo/core/shared/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@

from __future__ import annotations

from typing import TYPE_CHECKING
import hashlib
from typing import TYPE_CHECKING, Dict, Tuple

import matplotlib

import pandas as pd
from matplotlib import pyplot as plt

from ... import tools
from ... import visualize as vis
from ..pfline import classes
from ..pfline.enums import Kind

if TYPE_CHECKING: # needed to avoid circular imports
from ..pfline import PfLine
from ..pfstate import PfState


DEFAULTHOW = {"r": "bar", "q": "bar", "p": "hline", "w": "area", "f": "area"}
DEFAULTFMT = {
"w": "{:,.1f}",
"q": "{:,.0f}",
Expand All @@ -28,94 +30,151 @@
}


def defaultkwargs(col: str, is_cat: bool):
"""Styling and type of graph, depending on column ``col`` and whether or not the x-axis
is a category axis (``is_cat``)."""
kwargs = {}
kwargs["alpha"] = 0.8
# Add defaults for each column.
kwargs["color"] = getattr(vis.Colors.Wqpr, col, "grey")
kwargs["labelfmt"] = DEFAULTFMT.get(col, "{:.2f}")
kwargs["how"] = DEFAULTHOW.get(col, "hline")
kwargs["cat"] = is_cat
# Override specific cases.
if not is_cat and col == "p":
kwargs["how"] = "step"
if is_cat and col == "f":
kwargs["how"] = "bar"
def defaultkwargs(name: str, col: str):
# Get plot default kwargs.
if name is None: # no children
kwargs = {
"color": getattr(vis.Colors.Wqpr, col, "grey"),
"alpha": 0.7,
"labelfmt": DEFAULTFMT.get(col, "{:.2f}"),
}
elif name == "": # parent with children
kwargs = {
"color": "grey",
"alpha": 0.7,
"labelfmt": DEFAULTFMT.get(col, "{:.2f}"),
}
else: # child with name
hashed_value = hashlib.sha256(name.encode()).hexdigest()
hashed_int = int(hashed_value, 16)
index = hashed_int % len(vis.Colors.General)
kwargs = {
"color": list(vis.Colors.General)[index].value,
"alpha": 0.9,
"labelfmt": "", # no labels on children
"label": name,
"linewidth": 0.9,
}

return kwargs


def plotfn_and_kwargs(
col: str, freq: str, name: str
) -> Tuple[vis.PlotTimeseriesToAxFunction, Dict]:
"""Get correct function to plot as well as default kwargs. ``col``: one of 'qwprf',
``freq``: frequency; ``name``: name of the child. If name is emptystring, it is the
parent of a plot which also has children. If it is None, there are no children."""
# Get plot function.
if tools.freq.shortest(freq, "MS") == "MS": # categorical
if name == "" or name is None: # parent
fn = vis.plot_timeseries_as_bar
else: # child
fn = vis.plot_timeseries_as_hline
else: # timeaxis
if col in ["w", "q"]:
if name == "" or name is None: # parent
fn = vis.plot_timeseries_as_area
else: # child
fn = vis.plot_timeseries_as_step
else: # col in ['p', 'r']
if name == "" or name is None: # parent
fn = vis.plot_timeseries_as_step
else: # child
fn = vis.plot_timeseries_as_step

kwargs = defaultkwargs(name, col)

return fn, kwargs


class PfLinePlot:
def plot_to_ax(
self: PfLine, ax: plt.Axes, col: str, how: str, labelfmt: str, **kwargs
self: PfLine, ax: plt.Axes, children: bool = False, kind: Kind = None, **kwargs
) -> None:
"""Plot a timeseries of the PfLine to a specific axes.
"""Plot a specific dimension (i.e., kind) of the PfLine to a specific axis.
Parameters
----------
ax : plt.Axes
The axes object to which to plot the timeseries.
col : str
The column to plot. One of {'w', 'q', 'p', 'r'}.
how : str
How to plot the data. One of {'jagged', 'bar', 'area', 'step', 'hline'}.
labelfmt : str
Labels are added to each datapoint in the specified format. ('' to add no labels)
Any additional kwargs are passed to the pd.Series.plot function.
children : bool, optional (default: False)
If True, plot also the direct children of the PfLine.
kind : Kind, optional (default: None)
What dimension of the data to plot. Ignored unless PfLine.kind is COMPLETE.
**kwargs
Any additional kwargs are passed to the pd.Series.plot function when drawing
the parent.
Returns
-------
None
"""
if col not in self.kind.available:
# Ensure ``kind`` is volume, price, or revenue.
if self.kind is not Kind.COMPLETE:
kind = self.kind
elif kind not in [Kind.VOLUME, Kind.PRICE, Kind.REVENUE]:
raise ValueError(
f"For this PfLine, parameter ``col`` must be one of {', '.join(self.kind.available)}; got {col}."
"To plot a complete portfolio line, the dimension to be plotted must be specified. "
f"Parameter ``kind`` must be one of {{Kind.VOLUME, Kind.PRICE, Kind.REVENUE}}; got {kind}."
)
vis.plot_timeseries(ax, getattr(self, col), how, labelfmt, **kwargs)

def plot(self: PfLine, cols: str = None) -> plt.Figure:
"""Plot one or more timeseries of the PfLine.
# Create function to select correct series of the pfline.
def col_and_series(pfl: PfLine) -> Tuple[str, pd.Series]:
if kind is Kind.PRICE:
return "p", pfl.p
elif kind is Kind.REVENUE:
return "r", pfl.r
elif tools.freq.longest(pfl.index.freq, "D") == "D": # timeaxis
return "w", pfl.w # kind is Kind.VOLUME
else:
return "q", pfl.q # kind is Kind.VOLUME

# Plot top-level data first.
col, s = col_and_series(self)
# s = s.pint.m
fn, d_kwargs = plotfn_and_kwargs(col, self.index.freq, "" if children else None)
fn(ax, s, **(d_kwargs | kwargs))

# Plot children if wanted and available.
if not children or not isinstance(self, classes.NestedPfLine):
return
for name, child in self.items():
col, s = col_and_series(child)
# s = s.pint.m
fn, d_kwargs = plotfn_and_kwargs(col, self.index.freq, name)
fn(ax, s, **d_kwargs)
ax.legend()

def plot(self, children: bool = False) -> plt.Figure:
"""Plot the PfLine.
Parameters
----------
cols : str, optional
The columns to plot. Default: plot volume (in [MW] for daily values and
shorter, [MWh] for monthly values and longer) and price `p` [Eur/MWh]
(if available).
children : bool, optional (default: False)
If True, plot also the direct children of the PfLine.
Returns
-------
plt.Figure
The figure object to which the series was plotted.
"""
# Plot on category axis if freq monthly or longer, else on time axis.
is_category = tools.freq.shortest(self.index.freq, "MS") == "MS"

# If columns are specified, plot these. Else: take defaults, based on what's available
if cols is None:
cols = ""
if "q" in self.kind.available:
cols += "q" if is_category else "w"
if "p" in self.kind.available:
cols += "p"
else:
cols = [col for col in cols if col in self.kind.available]
if not cols:
raise ValueError("No columns to plot.")

# Create the plots.
size = (10, len(cols) * 3)
fig, axes = plt.subplots(
len(cols), 1, sharex=True, sharey=False, squeeze=False, figsize=size
)

for col, ax in zip(cols, axes.flatten()):
kwargs = defaultkwargs(col, is_category)
s = getattr(self, col)
vis.plot_timeseries(ax, s, **kwargs)
if self.kind is not Kind.COMPLETE:
# one axes
fig, ax = plt.subplots(1, 1, squeeze=True, figsize=(10, 3))
self.plot_to_ax(ax, children)

else:
fig, axes = plt.subplots(3, 1, sharex=True, squeeze=True, figsize=(10, 9))
for ax, kind in zip(axes, [Kind.VOLUME, Kind.PRICE, Kind.REVENUE]):
self.plot_to_ax(ax, children, kind)

return fig


class PfStatePlot:
def plot(self: PfState) -> plt.Figure:
def plot(self: PfState, children: bool = False) -> plt.Figure:
"""Plot the portfolio state.
Parameters
Expand All @@ -127,56 +186,61 @@ def plot(self: PfState) -> plt.Figure:
plt.Figure
The figure object to which the series was plotted.
"""
gridspec = {"width_ratios": [1, 1], "height_ratios": [4, 1]}
fig, axes = plt.subplots(
2, 2, sharex=True, gridspec_kw=gridspec, figsize=(10, 6)
gridspec = {"width_ratios": [1, 1, 1], "height_ratios": [4, 1]}
fig, (volumeaxes, priceaxes) = plt.subplots(
2, 3, sharex=True, sharey="row", gridspec_kw=gridspec, figsize=(10, 6)
)
axes = axes.flatten()
axes[0].sharey(axes[1])

# If freq is MS or longer: use categorical axes. Plot volumes in MWh.
# If freq is D or shorter: use time axes. Plot volumes in MW.
is_category = tools.freq.shortest(self.index.freq, "MS") == "MS"

# Volumes.
if is_category:
so, ss = -1 * self.offtakevolume.q, self.sourced.q
kwargs = defaultkwargs("q", is_category)
else:
so, ss = -1 * self.offtakevolume.w, self.sourced.w
kwargs = defaultkwargs("w", is_category)
vis.plot_timeseries(axes[0], so, **kwargs)
vis.plot_timeseries(axes[1], ss, **kwargs)
so, ss, usv = (
-1 * self.offtakevolume,
self.sourced,
self.unsourced,
)

so.plot_to_ax(volumeaxes[0], children=children, kind=so.kind, labelfmt="")
ss.plot_to_ax(volumeaxes[1], children=children, kind=Kind.VOLUME, labelfmt="")
# Unsourced volume.
usv.plot_to_ax(volumeaxes[2], kind=Kind.VOLUME, labelfmt="")
# Procurement Price.
vis.plot_timeseries(axes[2], self.pnl_cost.p, **defaultkwargs("p", is_category))

# Sourced fraction.
vis.plot_timeseries(
axes[3], self.sourcedfraction, **defaultkwargs("f", is_category)
self.pnl_cost.plot_to_ax(priceaxes[0], kind=Kind.PRICE, labelfmt="")
self.sourced.plot_to_ax(
priceaxes[1], children=children, kind=Kind.PRICE, labelfmt=""
)

# Empty.

# Unsourced price
self.unsourced.plot_to_ax(priceaxes[2], kind=Kind.PRICE, labelfmt="")
# Set titles.
axes[0].set_title("Offtake volume")
axes[1].set_title("Sourced volume")
axes[2].set_title("Procurement price")
axes[3].set_title("Sourced fraction")
volumeaxes[0].set_title("Offtake volume")
volumeaxes[1].set_title("Sourced volume")
volumeaxes[2].set_title("Unsourced volume")
priceaxes[0].set_title("Procurement price")
priceaxes[1].set_title("Sourced price")
priceaxes[2].set_title("Unsourced price")

limits_vol = [ax.get_ylim() for ax in volumeaxes]
limits_pr = [ax.get_ylim() for ax in priceaxes]
PfStatePlot.set_max_min_limits(volumeaxes, limits_vol)
PfStatePlot.set_max_min_limits(priceaxes, limits_pr)

# Format tick labels.
formatter = matplotlib.ticker.FuncFormatter(
lambda x, p: "{:,.0f}".format(x).replace(",", " ")
)
axes[0].yaxis.set_major_formatter(formatter)
axes[1].yaxis.set_major_formatter(formatter)
axes[3].yaxis.set_major_formatter(matplotlib.ticker.PercentFormatter(1.0))
volumeaxes[0].yaxis.set_major_formatter(formatter)
priceaxes[0].yaxis.set_major_formatter(formatter)
# axes[3].yaxis.set_major_formatter(matplotlib.ticker.PercentFormatter(1.0))

# Set ticks.
axes[0].xaxis.set_tick_params(labeltop=False, labelbottom=True)
axes[1].xaxis.set_tick_params(labeltop=False, labelbottom=True)
axes[2].xaxis.set_tick_params(labeltop=False, labelbottom=False)
axes[3].xaxis.set_tick_params(labeltop=False, labelbottom=False)
for ax in volumeaxes:
ax.xaxis.set_tick_params(labeltop=False, labelbottom=True)
for ax in priceaxes:
ax.xaxis.set_tick_params(labeltop=False, labelbottom=False)

fig.tight_layout()
return fig

def set_max_min_limits(axes: plt.Axes, limit: int):
mins_vol, maxs_vol = zip(*limit)

themin, themax = min(mins_vol), max(maxs_vol)
for ax in axes:
ax.set_ylim(themin * 1.1, themax * 1.1)
4 changes: 2 additions & 2 deletions portfolyo/dev/develop.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def get_pfline(
childcount : int, optional (default: 2)
Number of children on each level. (Ignored if `nlevels` == 1)
positive : bool, optional (default: False)
If True, return only positive values. If False, make 1/3 of pflines negative.
If True, return only positive values. If False, make 1/2 of pflines negative.
_ancestornames : Tuple[str], optional (default: ())
Text to start the childrens' names with (concatenated with '-')
_seed : int, optional (default: no seed value)
Expand All @@ -234,7 +234,7 @@ def get_pfline(
Kind.COMPLETE: "qr",
}[kind]
df = get_dataframe(i, columns, _seed=_seed)
if not positive and np.random.rand() < 0.33:
if not positive and np.random.randint(1, 4) == 1:
df = -1 * df # HACK: `-df` leads to error in pint. Maybe fixed in future
return create.flatpfline(df)
# Create nested PfLine.
Expand Down
Loading

0 comments on commit 2666c1f

Please sign in to comment.