Skip to content

Commit a8ed488

Browse files
authored
Merge pull request #24 from dawidlinek/feat/15-min-frequency
Feat/15 min frequency
2 parents 8312d4c + 241b73d commit a8ed488

31 files changed

Lines changed: 781 additions & 92 deletions

data_pipeline.yaml

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
sources:
2+
- class: EntsoeSource
3+
params:
4+
country_code: PL
5+
api_key: YOUR_API_KEY_HERE
6+
type:
7+
- load
8+
- price
9+
prefer_15min: false
10+
- class: OpenMeteoSource
11+
params:
12+
latitude: 52.2297
13+
longitude: 21.0122
14+
horizon: 7
15+
model: jma_seamless
16+
columns:
17+
- temperature_2m
18+
- rain
19+
- showers
20+
- snowfall
21+
- relative_humidity_2m
22+
- dew_point_2m
23+
- apparent_temperature
24+
- precipitation
25+
- weather_code
26+
- surface_pressure
27+
- pressure_msl
28+
- cloud_cover
29+
- wind_speed_10m
30+
- wind_direction_10m
31+
prefix: warsaw
32+
proxy_url: null
33+
- class: CalendarSource
34+
params:
35+
country: PL
36+
timezone: Europe/Warsaw
37+
holidays: binary
38+
weekday: onehot
39+
hour: false
40+
month: false
41+
daylight: true
42+
prefix: ''
43+
freq: 1h
44+
transformers:
45+
- class: TimezoneTransformer
46+
params:
47+
target_tz: Europe/Warsaw
48+
daily_columns:
49+
- load_forecast_daily_min
50+
- load_forecast_daily_max
51+
- class: ResampleTransformer
52+
params:
53+
freq: 1h
54+
method: linear
55+
columns: null
56+
- class: LagTransformer
57+
params:
58+
columns:
59+
- load_actual
60+
- price
61+
lags:
62+
- 1
63+
- 2
64+
- 7
65+
freq: day
66+
- class: LagTransformer
67+
params:
68+
columns:
69+
- is_monday
70+
- is_tuesday
71+
- daylight_hours
72+
- is_wednesday
73+
- is_thursday
74+
- is_friday
75+
- is_saturday
76+
- is_sunday
77+
- load_forecast
78+
- is_holiday
79+
lags:
80+
- -7
81+
- -6
82+
- -5
83+
- -4
84+
- -3
85+
- -2
86+
- -1
87+
- 0
88+
freq: day
89+
validators:
90+
- class: NullCheckValidator
91+
params:
92+
columns:
93+
- load_actual
94+
- price
95+
allow_nulls: false

docs/src/content/docs/data-sources/entsoe.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ source = EntsoeSource(
2525
country_code="PL",
2626
api_key=os.environ.get("ENTSOE_API_KEY"),
2727
type=["load", "price"],
28+
prefer_15min=False,
2829
)
2930

3031
df = source.fetch(
@@ -40,6 +41,7 @@ df = source.fetch(
4041
| `country_code` | str | Yes | Country/zone code (see table below) |
4142
| `api_key` | str | Yes | ENTSOE API key |
4243
| `type` | List[str] | Yes | Data types: `load`, `price`, `generation` |
44+
| `prefer_15min` | bool | No | Defaults to `False`. If `True`, prioritizes fetching 15-minute resolution data (particularly relevant for prices). |
4345

4446
---
4547

@@ -58,6 +60,10 @@ df = source.fetch(
5860
|--------|-------------|
5961
| `price` | Day-ahead electricity price (EUR/MWh) |
6062

63+
<Aside type="caution">
64+
**Germany Price Data:** ENTSOE publishes prices by bidding zone. Since the 2018 split, use the country code `"DE_LU"` (Germany/Luxembourg) to fetch German electricity prices. Using `"DE"` will return an empty list of prices.
65+
</Aside>
66+
6167
### Generation Data (`type=["generation"]`)
6268

6369
All supported generation types:

docs/src/content/docs/data-sources/openmeteo.mdx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,35 @@ df = source.fetch(
4242
| `model` | str | No | `"jma_seamless"` | Weather model |
4343
| `columns` | List[str] | No | DEFAULT_COLUMNS | Weather variables |
4444
| `prefix` | str | No | `""` | Prefix for column names |
45+
| `temporal_resolution` | str \| None | No | `None` | Time step of returned data — see below |
46+
47+
### Temporal Resolution
48+
49+
| Value | Time step | Notes |
50+
|-------|-----------|-------|
51+
| `None` *(default)* | 1 h | Standard hourly data; no extra API parameter sent |
52+
| `"hourly_3"` | 3 h | 8 data points per day |
53+
| `"hourly_6"` | 6 h | 4 data points per day |
54+
| `"native"` | model-dependent | Uses the weather model's native resolution; step is inferred automatically from the response |
55+
56+
```python
57+
# 3-hourly data — useful for coarser feature sets or faster pipelines
58+
source = OpenMeteoSource(
59+
latitude=52.2297,
60+
longitude=21.0122,
61+
horizon=7,
62+
prefix="warsaw",
63+
temporal_resolution="hourly_3",
64+
)
65+
66+
# Model-native resolution
67+
source = OpenMeteoSource(
68+
latitude=52.2297,
69+
longitude=21.0122,
70+
model="icon_seamless",
71+
temporal_resolution="native",
72+
)
73+
```
4574

4675
## Rate Limiting
4776

docs/src/content/docs/model-pipeline/overview.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ report = pipeline.run(
3232
test_end="2024-03-01",
3333
target="price",
3434
horizon=7,
35+
freq="1h", # Optional. Allows sub-hourly testing like "15min"
3536
save_dir="results",
3637
)
3738
```
@@ -172,6 +173,7 @@ Models automatically apply `StandardScaler`:
172173
| `test_end` | str | Required | Test period end |
173174
| `target` | str | `"price"` | Target column name |
174175
| `horizon` | int | `7` | Max forecast horizon (days) |
176+
| `freq` | str | `"1h"` | Forecasting frequency (e.g., `"15min"`) |
175177
| `save_dir` | str | `None` | Directory for incremental results |
176178
| `forecast_only` | bool | `False` | Skip evaluators/exporters; `test_start`/`test_end` default to `"today"` if omitted |
177179

docs/src/content/docs/transformers/resample.mdx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Aside } from '@astrojs/starlight/components';
77

88
# ResampleTransformer
99

10-
Resamples data to a specified frequency with interpolation to fill gaps.
10+
Resamples data to a specified frequency, applying aggregation (when downsampling) or interpolation (when upsampling).
1111

1212
## Basic Usage
1313

@@ -23,7 +23,7 @@ df = transformer.transform(df)
2323
| Parameter | Type | Default | Description |
2424
|-----------|------|---------|-------------|
2525
| `freq` | str | `"1h"` | Pandas frequency string |
26-
| `method` | str | `"linear"` | Interpolation method |
26+
| `method` | str | `"linear"` | Method specifying aggregation and interpolation behavior |
2727
| `columns` | list[str] \| str \| None | `None` | Columns to interpolate. If None, all columns are interpolated. |
2828

2929
## Frequency Strings
@@ -35,17 +35,24 @@ df = transformer.transform(df)
3535
| `"15min"` | 15 minutes |
3636
| `"1D"` | 1 day |
3737

38-
## Interpolation Methods
38+
## Methods
3939

40-
| Method | Description |
41-
|--------|-------------|
42-
| `"linear"` | Linear interpolation between points |
43-
| `"ffill"` | Forward fill (use last known value) |
44-
| `"bfill"` | Backward fill (use next known value) |
40+
The `method` parameter controls behavior for **both** directions:
41+
- **Downsampling** (e.g. 15 min → 1 h) – selects the aggregation function.
42+
- **Upsampling** (e.g. 1 h → 15 min) – selects how NaN gaps are filled.
43+
44+
| Method | Aggregation (↓) | Fill Method (↑) |
45+
|--------|----------------|-----------------|
46+
| `"linear"` | `mean` | Linear interpolation |
47+
| `"ffill"` | `first` | Forward-fill |
48+
| `"bfill"` | `last` | Backward-fill |
49+
| `"sum"` | `sum` | Forward-fill |
50+
| `"first"` | `first` | Forward-fill |
51+
| `"last"` | `last` | Backward-fill |
4552

4653
## Selecting Columns
4754

48-
By default, interpolation is applied to all columns. You can restrict interpolation to specific columns using the `columns` parameter. Other columns will be resampled (introducing NaNs) but not interpolated.
55+
By default, the resampling method is applied to all columns. You can restrict it to specific columns using the `columns` parameter. Other columns will be resampled (introducing NaNs/dropping points) but not processed by the specified method.
4956

5057
```python
5158
# Interpolate only 'price', leave 'load' with NaNs where gaps occur

docs/src/content/docs/workflow/overview.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ model_pipeline:
5757
test_end: "2024-07-01"
5858
target: price
5959
horizon: 7
60+
freq: 1h
6061
```
6162
6263
**Forecast-only variant** — add `forecast_only: true` and use date keywords so the file runs daily without editing:
@@ -72,6 +73,7 @@ model_pipeline:
7273
path: model_pipeline.yaml
7374
target: price
7475
horizon: 7
76+
freq: 1h # set to 15min for 15-minute forecasting
7577
forecast_only: true # test_start/test_end default to "today"; skips evaluators and exporters
7678
```
7779

@@ -94,6 +96,7 @@ model_pipeline:
9496
| `data_cache` | bool \| str | `False` | Data caching (True, False, or path) |
9597
| `model_target` | str | `"price"` | Target column name |
9698
| `model_horizon` | int | `7` | Max forecast horizon (days) |
99+
| `model_freq` | str | `"1h"` | Forecasting frequency (e.g., `"15min"`) |
97100
| `max_processes` | int | `None` | Worker process count |
98101
| `threads_per_process` | int | `None` | Threads per process |
99102
| `cache_path` | str | `None` | Cache directory (overrides `data_cache` if set) |

epftoolbox2/data/sources/calendar.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def __init__(
4545
month: Union[str, bool] = False,
4646
daylight: bool = False,
4747
prefix: str = "",
48+
freq: str = "1h",
4849
):
4950
self.country = country.upper()
5051
self._validate_country()
@@ -60,6 +61,7 @@ def __init__(
6061
self.month = month
6162
self.daylight = daylight
6263
self.prefix = prefix
64+
self.freq = freq
6365

6466
self._validate_config()
6567

@@ -86,7 +88,7 @@ def fetch(self, start: pd.Timestamp, end: pd.Timestamp) -> pd.DataFrame:
8688
start = start.tz_convert("UTC") if start.tzinfo else start.tz_localize("UTC")
8789
end = end.tz_convert("UTC") if end.tzinfo else end.tz_localize("UTC")
8890

89-
index = pd.date_range(start, end, freq="1h", tz="UTC")
91+
index = pd.date_range(start, end, freq=self.freq, tz="UTC")
9092
local_index = index.tz_convert(self.timezone)
9193

9294
df = pd.DataFrame(index=index)
@@ -132,7 +134,11 @@ def _add_weekday(self, df: pd.DataFrame, local_index: pd.DatetimeIndex) -> pd.Da
132134
return df
133135

134136
def _add_hour(self, df: pd.DataFrame, local_index: pd.DatetimeIndex) -> pd.DataFrame:
135-
hour_series = pd.Series(local_index.hour, index=df.index)
137+
if self.freq == "15min":
138+
values = local_index.hour * 4 + local_index.minute // 15
139+
else:
140+
values = local_index.hour
141+
hour_series = pd.Series(values, index=df.index)
136142

137143
if self.hour == "number":
138144
df[f"{self.prefix}hour"] = hour_series

epftoolbox2/data/sources/entsoe.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,17 @@ class EntsoeSource(DataSource):
176176

177177
API_URL = "https://web-api.tp.entsoe.eu/api"
178178

179-
def __init__(self, country_code: str, api_key: str, type: List[str]):
179+
def __init__(
180+
self,
181+
country_code: str,
182+
api_key: str,
183+
type: List[str],
184+
prefer_15min: bool = False,
185+
):
180186
self.country_code = country_code
181187
self.api_key = api_key
182188
self.type = type
189+
self.prefer_15min = prefer_15min
183190
self._area_name, self._area_code = lookup_area(country_code)
184191
self.session = requests.Session()
185192

@@ -384,12 +391,15 @@ def _fetch_price(self, start: pd.Timestamp, end: pd.Timestamp) -> pd.DataFrame:
384391
price_dict = self._parse_prices(xml)
385392

386393
series = pd.Series()
387-
if price_dict["15min"] is not None and len(price_dict["15min"]) > 0:
388-
series = price_dict["15min"]
389-
elif price_dict["30min"] is not None and len(price_dict["30min"]) > 0:
390-
series = price_dict["30min"]
391-
elif price_dict["60min"] is not None and len(price_dict["60min"]) > 0:
392-
series = price_dict["60min"]
394+
order = (
395+
("15min", "30min", "60min")
396+
if self.prefer_15min
397+
else ("60min", "30min", "15min")
398+
)
399+
for res in order:
400+
if price_dict.get(res) is not None and len(price_dict[res]) > 0:
401+
series = price_dict[res]
402+
break
393403

394404
series = series.truncate(before=start, after=end)
395405

0 commit comments

Comments
 (0)