File size: 4,405 Bytes
5d7f266
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
"""Leak-free fuel features for the (zone, target hour, horizon h) rows of the EU model, from the daily cache of fetch_fuel.py.

Leakage rule. A forecast of target day T at horizon h is issued on day T - h (h = 2: the day before the day-after-tomorrow's
price auction results are known; the last known PRICE day is T - h + 1). The fuel value used is the one of the CLOSE of the
day BEFORE issue, f = T - h - 1: a full day more cautious than necessary (the MIBGAS index of the issue day and the 11:00
EUA auction of the issue day are public before the day-ahead auction results at 13:00), so no feature can contain a number
that was not settled when the forecast was made. Weekends and holidays carry the last published value forward (the EUA is
auctioned on weekdays only).

Features (per row; the same value for every zone - the fuel is a EUROPE-wide level, the zone decides what it does with it):
  gas, gas_d7, gas_d28       gas day-ahead level (EUR/MWh) and its 7-/28-day change
  gas_m1_spread              month-ahead minus day-ahead gas: where the market expects the level to go
  eua, eua_d7, eua_d28       carbon level (EUR/tCO2) and its changes
  ccgt_mc, ccgt_mc_d7, _d28  marginal cost of a combined-cycle gas plant, gas / 0.55 + 0.202 / 0.55 * eua (EUR/MWh_e):
                             the number a gas-priced hour clears near, with the two inputs already combined
  full, full_d28             storage fill (per cent) and its 28-day change, only when the cache has the AGSI+ column
  fuel_age                   days between f and the newest value actually published at or before f (a stale feed is visible)
"""
import numpy as np
import pandas as pd

CCGT_EFF = 0.55
EF_T_PER_MWH_TH = 0.202          # tCO2 per MWh of gas burnt
BASE = ["gas", "gas_d7", "gas_d28", "gas_m1_spread", "eua", "eua_d7", "eua_d28", "ccgt_mc", "ccgt_mc_d7", "ccgt_mc_d28", "fuel_age"]
STORAGE = ["full", "full_d28"]


def calendar_frame(fuel):
    """The cache on a gapless daily index, filled forward, plus `age` = days since the value was actually published."""
    idx = pd.date_range(fuel.index.min().normalize(), fuel.index.max().normalize(), freq="D")
    f = fuel.reindex(idx)
    published = pd.Series(np.where(f["gas_da"].notna(), idx.to_numpy(), np.datetime64("NaT")), index=idx)
    age = (pd.Series(idx, index=idx) - published.ffill()).dt.days
    out = f.ffill()
    out["age"] = age
    return out


def daily_table(fuel):
    """Per fuel day f: the level and change columns, as a frame indexed by f."""
    c = calendar_frame(fuel)
    t = pd.DataFrame(index=c.index)
    t["gas"] = c["gas_da"]
    t["gas_d7"] = c["gas_da"] - c["gas_da"].shift(7)
    t["gas_d28"] = c["gas_da"] - c["gas_da"].shift(28)
    t["gas_m1_spread"] = c["gas_m1"] - c["gas_da"]
    t["eua"] = c["eua"]
    t["eua_d7"] = c["eua"] - c["eua"].shift(7)
    t["eua_d28"] = c["eua"] - c["eua"].shift(28)
    mc = c["gas_da"] / CCGT_EFF + EF_T_PER_MWH_TH / CCGT_EFF * c["eua"]
    t["ccgt_mc"] = mc
    t["ccgt_mc_d7"] = mc - mc.shift(7)
    t["ccgt_mc_d28"] = mc - mc.shift(28)
    if "full" in c and c["full"].notna().any():
        t["full"] = c["full"]
        t["full_d28"] = c["full"] - c["full"].shift(28)
    t["fuel_age"] = c["age"]
    return t


def for_rows(table, target_dates, h):
    """Fuel features for target DAYS (any date-like array) at horizon h: the table row of f = T - h - 1.
    Days before the table starts (or after it ends: the newest value is carried) give NaN / the last row."""
    f = pd.DatetimeIndex(target_dates).normalize() - pd.Timedelta(days=int(h) + 1)
    rows = table.reindex(f)
    last = table.index.max()
    late = f > last                      # a feed that stopped: carry the newest row, its age grows (visible in fuel_age)
    if late.any():
        rows = rows.copy()
        rows.loc[late, :] = table.iloc[-1].to_numpy()
        rows.loc[late, "fuel_age"] = table.iloc[-1]["fuel_age"] + (f[late] - last).days
    return rows.reset_index(drop=True)


def add_to_frame(df, table, date_col="date", h_col="h"):
    """Append the fuel columns to a rows frame that has a date and an h column, one call per distinct h."""
    parts = []
    for h, g in df.groupby(h_col, sort=False):
        feats = for_rows(table, g[date_col].to_numpy(), h)
        feats.index = g.index
        parts.append(feats)
    out = pd.concat(parts).reindex(df.index)
    return pd.concat([df, out], axis=1)