python-entsoe 0.6.2__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/CHANGELOG.md +14 -0
  2. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/PKG-INFO +7 -3
  3. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/README.md +6 -2
  4. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/pyproject.toml +1 -1
  5. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/_mappings.py +24 -0
  6. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/_output.py +8 -2
  7. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/_resolve.py +3 -3
  8. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/app.py +4 -3
  9. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/balancing.py +4 -2
  10. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/config_cmd.py +5 -1
  11. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/exec_cmd.py +10 -5
  12. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/generation.py +8 -4
  13. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/load.py +4 -2
  14. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/prices.py +2 -1
  15. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/transmission.py +6 -3
  16. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/client.py +12 -5
  17. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/namespaces/_base.py +61 -12
  18. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/namespaces/balancing.py +12 -4
  19. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/namespaces/generation.py +23 -6
  20. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/namespaces/load.py +12 -4
  21. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/namespaces/prices.py +6 -2
  22. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/namespaces/transmission.py +21 -8
  23. python_entsoe-0.7.0/tests/test_cli_periods.py +15 -0
  24. python_entsoe-0.7.0/tests/test_output_tz.py +109 -0
  25. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/tests/test_xml_day_ahead.py +14 -0
  26. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/uv.lock +1 -1
  27. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/.github/workflows/publish.yml +0 -0
  28. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/.gitignore +0 -0
  29. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/.gitlab-ci.yml +0 -0
  30. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/.python-version +0 -0
  31. /python_entsoe-0.6.2/CLAUDE.md → /python_entsoe-0.7.0/AGENTS.md +0 -0
  32. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/docs/data-availability.md +0 -0
  33. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/examples/balancing.ipynb +0 -0
  34. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/examples/generation.ipynb +0 -0
  35. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/examples/generation_per_plant.ipynb +0 -0
  36. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/examples/load.ipynb +0 -0
  37. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/examples/prices.ipynb +0 -0
  38. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/examples/transmission.ipynb +0 -0
  39. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/scripts/generate_notebooks.py +0 -0
  40. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/.agents/skills/entsoe/SKILL.md +0 -0
  41. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/__init__.py +0 -0
  42. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/_http.py +0 -0
  43. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/_xml.py +0 -0
  44. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cache.py +0 -0
  45. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/catalog.py +0 -0
  46. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/__init__.py +0 -0
  47. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/cache_cmd.py +0 -0
  48. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/catalog_cmd.py +0 -0
  49. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/cli/config.py +0 -0
  50. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/exceptions.py +0 -0
  51. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/namespaces/__init__.py +0 -0
  52. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/src/entsoe/py.typed +0 -0
  53. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/tests/__init__.py +0 -0
  54. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/tests/conftest.py +0 -0
  55. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/tests/test_balancing.py +0 -0
  56. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/tests/test_cache_layout.py +0 -0
  57. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/tests/test_generation.py +0 -0
  58. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/tests/test_load.py +0 -0
  59. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/tests/test_multi.py +0 -0
  60. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/tests/test_prices.py +0 -0
  61. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/tests/test_transmission.py +0 -0
  62. {python_entsoe-0.6.2 → python_entsoe-0.7.0}/tests/test_validation.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://gitlab.com/datons.com/libraries/python-entsoe/-/tags/v0.7.0) (2026-11-02)
4
+
5
+ ### Features
6
+
7
+ * **timezones:** configure result timestamps as UTC (default), local area time, or a fixed IANA timezone through the client, per-call override, or CLI `--tz` and persistent `output-tz` setting.
8
+
9
+ ## [0.6.3](https://gitlab.com/datons.com/libraries/python-entsoe/-/tags/v0.6.3) (2026-10-15)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **cli:** `--period today` and `--period yesterday` produced an empty range and always failed with "start must be before end"; each now covers the full day.
15
+ * **cache:** a failed download (timeout, HTTP error) no longer returns an empty or partial DataFrame with only a warning in the log; the error is raised.
16
+
3
17
  ## [0.6.2](https://gitlab.com/datons.com/libraries/python-entsoe/-/tags/v0.6.2) (2026-10-15)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-entsoe
3
- Version: 0.6.2
3
+ Version: 0.7.0
4
4
  Summary: Python client for the ENTSO-E Transparency Platform API
5
5
  Project-URL: Homepage, https://gitlab.com/datons.com/libraries/python-entsoe
6
6
  Project-URL: Repository, https://gitlab.com/datons.com/libraries/python-entsoe
@@ -57,7 +57,7 @@ start = pd.Timestamp("2024-06-01", tz="Europe/Paris")
57
57
  df = client.load.actual(start, "2024-06-08", country="FR")
58
58
  ```
59
59
 
60
- Every method returns a `pandas.DataFrame` with a `timestamp` column (UTC) and a `value` column.
60
+ Every method returns a `pandas.DataFrame` with a timezone-aware `timestamp` column and a `value` column.
61
61
 
62
62
  ## Authentication
63
63
 
@@ -216,7 +216,11 @@ df = client.load.actual("2024-01-01", pd.Timestamp("2024-01-07", tz="UTC"), coun
216
216
  start = pd.Timestamp("2024-01-01") # ← no tz, will error
217
217
  ```
218
218
 
219
- Returned timestamps are always in **UTC**.
219
+ ## Timezones
220
+
221
+ The client `tz` setting interprets input date strings (default `Europe/Brussels`); it does not control returned timestamps. The separate `output_tz` setting controls results: `"UTC"` (default, unchanged behavior), `"local"` (the requested area's timezone), or a fixed IANA timezone such as `"Europe/Madrid"`. Every namespace method also accepts `output_tz=` for one call. The CLI accepts `--tz` on data commands, or `entsoe config set output-tz local` as a persistent default; `--tz` takes precedence.
222
+
223
+ For a multi-area request, `"local"` requires matching clock offsets throughout the returned period (for example, FR and ES); otherwise choose `"UTC"` or a fixed IANA zone. Cross-border results use the first importing (`in_domain`) zone. During the October DST fall-back, local wall-clock times repeat; each row retains its offset, but timestamps cease to be unique if the timezone is stripped.
220
224
 
221
225
  ## Features
222
226
 
@@ -29,7 +29,7 @@ start = pd.Timestamp("2024-06-01", tz="Europe/Paris")
29
29
  df = client.load.actual(start, "2024-06-08", country="FR")
30
30
  ```
31
31
 
32
- Every method returns a `pandas.DataFrame` with a `timestamp` column (UTC) and a `value` column.
32
+ Every method returns a `pandas.DataFrame` with a timezone-aware `timestamp` column and a `value` column.
33
33
 
34
34
  ## Authentication
35
35
 
@@ -188,7 +188,11 @@ df = client.load.actual("2024-01-01", pd.Timestamp("2024-01-07", tz="UTC"), coun
188
188
  start = pd.Timestamp("2024-01-01") # ← no tz, will error
189
189
  ```
190
190
 
191
- Returned timestamps are always in **UTC**.
191
+ ## Timezones
192
+
193
+ The client `tz` setting interprets input date strings (default `Europe/Brussels`); it does not control returned timestamps. The separate `output_tz` setting controls results: `"UTC"` (default, unchanged behavior), `"local"` (the requested area's timezone), or a fixed IANA timezone such as `"Europe/Madrid"`. Every namespace method also accepts `output_tz=` for one call. The CLI accepts `--tz` on data commands, or `entsoe config set output-tz local` as a persistent default; `--tz` takes precedence.
194
+
195
+ For a multi-area request, `"local"` requires matching clock offsets throughout the returned period (for example, FR and ES); otherwise choose `"UTC"` or a fixed IANA zone. Cross-border results use the first importing (`in_domain`) zone. During the October DST fall-back, local wall-clock times repeat; each row retains its offset, but timestamps cease to be unique if the timezone is stripped.
192
196
 
193
197
  ## Features
194
198
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-entsoe"
3
- version = "0.6.2"
3
+ version = "0.7.0"
4
4
  description = "Python client for the ENTSO-E Transparency Platform API"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -64,6 +64,30 @@ AREA_CODES: dict[str, str] = {
64
64
  "XK": "10Y1001C--00100H",
65
65
  }
66
66
 
67
+ # IANA timezone of each ENTSO-E bidding area. Keys intentionally mirror AREA_CODES.
68
+ AREA_TZ: dict[str, str] = {
69
+ "AL": "Europe/Tirane", "AT": "Europe/Vienna", "BA": "Europe/Sarajevo",
70
+ "BE": "Europe/Brussels", "BG": "Europe/Sofia", "CH": "Europe/Zurich",
71
+ "CZ": "Europe/Prague", "DE": "Europe/Berlin", "DE_LU": "Europe/Berlin",
72
+ "DE_AT_LU": "Europe/Berlin", "DK": "Europe/Copenhagen",
73
+ "DK_1": "Europe/Copenhagen", "DK_2": "Europe/Copenhagen",
74
+ "EE": "Europe/Tallinn", "ES": "Europe/Madrid", "FI": "Europe/Helsinki",
75
+ "FR": "Europe/Paris", "GB": "Europe/London", "GR": "Europe/Athens",
76
+ "HR": "Europe/Zagreb", "HU": "Europe/Budapest", "IE": "Europe/Dublin",
77
+ "IE_SEM": "Europe/Dublin", "IT": "Europe/Rome", "IT_NORTH": "Europe/Rome",
78
+ "IT_CNOR": "Europe/Rome", "IT_CSUD": "Europe/Rome", "IT_SUD": "Europe/Rome",
79
+ "IT_SICI": "Europe/Rome", "IT_SARD": "Europe/Rome", "LT": "Europe/Vilnius",
80
+ "LU": "Europe/Luxembourg", "LV": "Europe/Riga", "ME": "Europe/Podgorica",
81
+ "MK": "Europe/Skopje", "MT": "Europe/Malta", "NL": "Europe/Amsterdam",
82
+ "NO": "Europe/Oslo", "NO_1": "Europe/Oslo", "NO_2": "Europe/Oslo",
83
+ "NO_3": "Europe/Oslo", "NO_4": "Europe/Oslo", "NO_5": "Europe/Oslo",
84
+ "PL": "Europe/Warsaw", "PT": "Europe/Lisbon", "RO": "Europe/Bucharest",
85
+ "RS": "Europe/Belgrade", "SE": "Europe/Stockholm", "SE_1": "Europe/Stockholm",
86
+ "SE_2": "Europe/Stockholm", "SE_3": "Europe/Stockholm", "SE_4": "Europe/Stockholm",
87
+ "SI": "Europe/Ljubljana", "SK": "Europe/Bratislava", "TR": "Europe/Istanbul",
88
+ "UA": "Europe/Kyiv", "UK": "Europe/London", "XK": "Europe/Belgrade",
89
+ }
90
+
67
91
  # ISO code → human-readable country/area name
68
92
  COUNTRY_NAMES: dict[str, str] = {
69
93
  "AL": "Albania",
@@ -34,7 +34,10 @@ def output(df, format: str, title: str = "", output_path: str | None = None):
34
34
  typer.echo(text)
35
35
 
36
36
  elif format == "json":
37
- text = df.to_json(orient="records", indent=2, date_format="iso")
37
+ display = df.copy()
38
+ if "timestamp" in display:
39
+ display["timestamp"] = display["timestamp"].map(lambda value: value.isoformat())
40
+ text = display.to_json(orient="records", indent=2)
38
41
  if output_path:
39
42
  with open(output_path, "w") as f:
40
43
  f.write(text)
@@ -72,7 +75,10 @@ def render_result(result, format: str, output_path: str | None) -> None:
72
75
  if format == "csv":
73
76
  text = result.to_csv()
74
77
  elif format == "json":
75
- text = result.to_json(orient="records", indent=2, date_format="iso")
78
+ display = result.copy()
79
+ if "timestamp" in display:
80
+ display["timestamp"] = display["timestamp"].map(lambda value: value.isoformat())
81
+ text = display.to_json(orient="records", indent=2)
76
82
  else:
77
83
  table = Table()
78
84
  idx_name = result.index.name or ""
@@ -100,11 +100,11 @@ def _expand_period(period: str, today: date) -> tuple[date, date]:
100
100
 
101
101
  p = period.lower().strip()
102
102
 
103
+ # The library's end is exclusive, so a single day ends on the next one.
103
104
  if p == "today":
104
- return today, today
105
+ return today, today + timedelta(days=1)
105
106
  if p == "yesterday":
106
- yesterday = today - timedelta(days=1)
107
- return yesterday, yesterday
107
+ return today - timedelta(days=1), today
108
108
  if p == "week":
109
109
  monday = today - timedelta(days=today.weekday())
110
110
  return monday, today
@@ -6,7 +6,7 @@ from typing import Optional
6
6
 
7
7
  import typer
8
8
 
9
- from entsoe.cli.config import get_api_key
9
+ from entsoe.cli.config import get_api_key, get_config
10
10
 
11
11
  app = typer.Typer(
12
12
  name="entsoe",
@@ -15,7 +15,7 @@ app = typer.Typer(
15
15
  )
16
16
 
17
17
 
18
- def get_client(api_key: str | None = None):
18
+ def get_client(api_key: str | None = None, output_tz: str | None = None):
19
19
  """Lazy import + construct client."""
20
20
  from entsoe.client import ENTSOEClient
21
21
 
@@ -26,7 +26,8 @@ def get_client(api_key: str | None = None):
26
26
  err=True,
27
27
  )
28
28
  raise typer.Exit(1)
29
- return ENTSOEClient(api_key=resolved)
29
+ configured_tz = get_config("output-tz") if output_tz is None else output_tz
30
+ return ENTSOEClient(api_key=resolved, output_tz="UTC" if configured_tz is None else configured_tz)
30
31
 
31
32
 
32
33
  # -- Register sub-commands ---------------------------------------------------
@@ -21,12 +21,13 @@ def prices(
21
21
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
22
22
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
23
23
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
24
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
24
25
  ):
25
26
  """Query imbalance prices."""
26
27
  from entsoe.cli.app import get_client
27
28
 
28
29
  s, e = resolve_dates(start, end, period)
29
- client = get_client(api_key)
30
+ client = get_client(api_key, tz)
30
31
  countries = resolve_many_required(country)
31
32
  df = client.balancing.imbalance_prices(s, e, country=countries)
32
33
  output(df, format, title="Imbalance Prices", output_path=output_path)
@@ -41,12 +42,13 @@ def volumes(
41
42
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
42
43
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
43
44
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
45
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
44
46
  ):
45
47
  """Query imbalance volumes."""
46
48
  from entsoe.cli.app import get_client
47
49
 
48
50
  s, e = resolve_dates(start, end, period)
49
- client = get_client(api_key)
51
+ client = get_client(api_key, tz)
50
52
  countries = resolve_many_required(country)
51
53
  df = client.balancing.imbalance_volumes(s, e, country=countries)
52
54
  output(df, format, title="Imbalance Volumes", output_path=output_path)
@@ -9,12 +9,16 @@ config_app = typer.Typer(no_args_is_help=True)
9
9
 
10
10
  @config_app.command("set")
11
11
  def config_set(
12
- key: str = typer.Argument(..., help="Configuration key (e.g., 'api-key')"),
12
+ key: str = typer.Argument(..., help="Configuration key (api-key or output-tz)"),
13
13
  value: str = typer.Argument(..., help="Configuration value"),
14
14
  ):
15
15
  """Set a configuration value."""
16
16
  from entsoe.cli.config import set_config
17
17
 
18
+ if key == "output-tz":
19
+ from entsoe.namespaces._base import validate_output_tz
20
+ validate_output_tz(value)
21
+
18
22
  set_config(key, value)
19
23
  typer.echo(f"Set {key} = {'***' if 'key' in key.lower() else value}")
20
24
 
@@ -62,6 +62,7 @@ def exec_prices(
62
62
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
63
63
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
64
64
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
65
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
65
66
  ):
66
67
  """Fetch price data and evaluate a Python expression.
67
68
 
@@ -73,7 +74,7 @@ def exec_prices(
73
74
 
74
75
  method_name = _resolve_method(method, _PRICES_METHODS)
75
76
  s, e = resolve_dates(start, end, period)
76
- client = get_client(api_key)
77
+ client = get_client(api_key, tz)
77
78
  fn = getattr(client.prices, method_name)
78
79
  df = fn(s, e, country=resolve_many_required(country))
79
80
  _eval_and_render(df, expr, format, output_path)
@@ -96,6 +97,7 @@ def exec_load(
96
97
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
97
98
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
98
99
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
100
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
99
101
  ):
100
102
  """Fetch load data and evaluate a Python expression.
101
103
 
@@ -107,7 +109,7 @@ def exec_load(
107
109
 
108
110
  method_name = _resolve_method(method, _LOAD_METHODS)
109
111
  s, e = resolve_dates(start, end, period)
110
- client = get_client(api_key)
112
+ client = get_client(api_key, tz)
111
113
  fn = getattr(client.load, method_name)
112
114
  df = fn(s, e, country=resolve_many_required(country))
113
115
  _eval_and_render(df, expr, format, output_path)
@@ -136,6 +138,7 @@ def exec_generation(
136
138
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
137
139
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
138
140
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
141
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
139
142
  ):
140
143
  """Fetch generation data and evaluate a Python expression.
141
144
 
@@ -147,7 +150,7 @@ def exec_generation(
147
150
 
148
151
  method_name = _resolve_method(method, _GENERATION_METHODS)
149
152
  s, e = resolve_dates(start, end, period)
150
- client = get_client(api_key)
153
+ client = get_client(api_key, tz)
151
154
  fn = getattr(client.generation, method_name)
152
155
  df = fn(s, e, country=resolve_many_required(country), psr_type=resolve_many(psr))
153
156
  _eval_and_render(df, expr, format, output_path)
@@ -176,6 +179,7 @@ def exec_transmission(
176
179
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
177
180
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
178
181
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
182
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
179
183
  ):
180
184
  """Fetch transmission data and evaluate a Python expression.
181
185
 
@@ -200,7 +204,7 @@ def exec_transmission(
200
204
  typer.echo("Error: Provide --from/--to or --border.", err=True)
201
205
  raise typer.Exit(1)
202
206
 
203
- client = get_client(api_key)
207
+ client = get_client(api_key, tz)
204
208
  fn = getattr(client.transmission, method_name)
205
209
  df = fn(s, e, **kwargs)
206
210
  _eval_and_render(df, expr, format, output_path)
@@ -223,6 +227,7 @@ def exec_balancing(
223
227
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
224
228
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
225
229
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
230
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
226
231
  ):
227
232
  """Fetch balancing data and evaluate a Python expression.
228
233
 
@@ -234,7 +239,7 @@ def exec_balancing(
234
239
 
235
240
  method_name = _resolve_method(method, _BALANCING_METHODS)
236
241
  s, e = resolve_dates(start, end, period)
237
- client = get_client(api_key)
242
+ client = get_client(api_key, tz)
238
243
  fn = getattr(client.balancing, method_name)
239
244
  df = fn(s, e, country=resolve_many_required(country))
240
245
  _eval_and_render(df, expr, format, output_path)
@@ -22,12 +22,13 @@ def actual(
22
22
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
23
23
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
24
24
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
25
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
25
26
  ):
26
27
  """Query actual generation output per type."""
27
28
  from entsoe.cli.app import get_client
28
29
 
29
30
  s, e = resolve_dates(start, end, period)
30
- client = get_client(api_key)
31
+ client = get_client(api_key, tz)
31
32
  df = client.generation.actual(s, e, country=resolve_many_required(country), psr_type=resolve_many(psr))
32
33
  output(df, format, title="Actual Generation", output_path=output_path)
33
34
 
@@ -42,12 +43,13 @@ def forecast(
42
43
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
43
44
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
44
45
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
46
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
45
47
  ):
46
48
  """Query day-ahead generation forecast (wind/solar)."""
47
49
  from entsoe.cli.app import get_client
48
50
 
49
51
  s, e = resolve_dates(start, end, period)
50
- client = get_client(api_key)
52
+ client = get_client(api_key, tz)
51
53
  df = client.generation.forecast(s, e, country=resolve_many_required(country), psr_type=resolve_many(psr))
52
54
  output(df, format, title="Generation Forecast", output_path=output_path)
53
55
 
@@ -62,12 +64,13 @@ def capacity(
62
64
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
63
65
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
64
66
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
67
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
65
68
  ):
66
69
  """Query installed generation capacity per type."""
67
70
  from entsoe.cli.app import get_client
68
71
 
69
72
  s, e = resolve_dates(start, end, period)
70
- client = get_client(api_key)
73
+ client = get_client(api_key, tz)
71
74
  df = client.generation.installed_capacity(s, e, country=resolve_many_required(country), psr_type=resolve_many(psr))
72
75
  output(df, format, title="Installed Capacity", output_path=output_path)
73
76
 
@@ -82,12 +85,13 @@ def per_plant(
82
85
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
83
86
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
84
87
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
88
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
85
89
  ):
86
90
  """Query actual generation per production unit."""
87
91
  from entsoe.cli.app import get_client
88
92
 
89
93
  s, e = resolve_dates(start, end, period)
90
- client = get_client(api_key)
94
+ client = get_client(api_key, tz)
91
95
  df = client.generation.per_plant(s, e, country=resolve_many_required(country), psr_type=resolve_many(psr))
92
96
  output(df, format, title="Generation Per Plant", output_path=output_path)
93
97
 
@@ -21,12 +21,13 @@ def actual(
21
21
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
22
22
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
23
23
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
24
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
24
25
  ):
25
26
  """Query actual total system load."""
26
27
  from entsoe.cli.app import get_client
27
28
 
28
29
  s, e = resolve_dates(start, end, period)
29
- client = get_client(api_key)
30
+ client = get_client(api_key, tz)
30
31
  countries = resolve_many_required(country)
31
32
  df = client.load.actual(s, e, country=countries)
32
33
  output(df, format, title="Actual Load", output_path=output_path)
@@ -41,12 +42,13 @@ def forecast(
41
42
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
42
43
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
43
44
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
45
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
44
46
  ):
45
47
  """Query day-ahead load forecast."""
46
48
  from entsoe.cli.app import get_client
47
49
 
48
50
  s, e = resolve_dates(start, end, period)
49
- client = get_client(api_key)
51
+ client = get_client(api_key, tz)
50
52
  countries = resolve_many_required(country)
51
53
  df = client.load.forecast(s, e, country=countries)
52
54
  output(df, format, title="Load Forecast", output_path=output_path)
@@ -22,12 +22,13 @@ def day_ahead(
22
22
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
23
23
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
24
24
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
25
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
25
26
  ):
26
27
  """Query day-ahead electricity prices."""
27
28
  from entsoe.cli.app import get_client
28
29
 
29
30
  s, e = resolve_dates(start, end, period)
30
- client = get_client(api_key)
31
+ client = get_client(api_key, tz)
31
32
  countries = resolve_many_required(country)
32
33
  df = client.prices.day_ahead(s, e, country=countries, sequence=sequence)
33
34
  output(df, format, title="Day-Ahead Prices", output_path=output_path)
@@ -43,13 +43,14 @@ def flows(
43
43
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
44
44
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
45
45
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
46
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
46
47
  ):
47
48
  """Query physical cross-border flows."""
48
49
  from entsoe.cli.app import get_client
49
50
 
50
51
  s, e = resolve_dates(start, end, period)
51
52
  kwargs = _resolve_borders_or_pairs(country_from, country_to, border)
52
- client = get_client(api_key)
53
+ client = get_client(api_key, tz)
53
54
  df = client.transmission.crossborder_flows(s, e, **kwargs)
54
55
  output(df, format, title="Cross-Border Flows", output_path=output_path)
55
56
 
@@ -65,13 +66,14 @@ def exchanges(
65
66
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
66
67
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
67
68
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
69
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
68
70
  ):
69
71
  """Query scheduled commercial exchanges (day-ahead)."""
70
72
  from entsoe.cli.app import get_client
71
73
 
72
74
  s, e = resolve_dates(start, end, period)
73
75
  kwargs = _resolve_borders_or_pairs(country_from, country_to, border)
74
- client = get_client(api_key)
76
+ client = get_client(api_key, tz)
75
77
  df = client.transmission.scheduled_exchanges(s, e, **kwargs)
76
78
  output(df, format, title="Scheduled Exchanges", output_path=output_path)
77
79
 
@@ -87,13 +89,14 @@ def capacity(
87
89
  format: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
88
90
  output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
89
91
  api_key: Optional[str] = typer.Option(None, "--api-key", help="ENTSO-E API key"),
92
+ tz: Optional[str] = typer.Option(None, "--tz", help="Output timezone: UTC, local, or IANA name"),
90
93
  ):
91
94
  """Query day-ahead net transfer capacity."""
92
95
  from entsoe.cli.app import get_client
93
96
 
94
97
  s, e = resolve_dates(start, end, period)
95
98
  kwargs = _resolve_borders_or_pairs(country_from, country_to, border)
96
- client = get_client(api_key)
99
+ client = get_client(api_key, tz)
97
100
  df = client.transmission.net_transfer_capacity(s, e, **kwargs)
98
101
  output(df, format, title="Net Transfer Capacity", output_path=output_path)
99
102
 
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
 
8
8
  from ._http import HttpClient
9
9
  from .cache import CacheConfig, CacheStore
10
+ from .namespaces._base import validate_output_tz
10
11
  from .namespaces import (
11
12
  BalancingNamespace,
12
13
  GenerationNamespace,
@@ -38,6 +39,8 @@ class ENTSOEClient:
38
39
 
39
40
  # Override default timezone
40
41
  client = ENTSOEClient(tz="UTC")
42
+ client = ENTSOEClient(output_tz="local") # results use each zone's local time
43
+ client = ENTSOEClient(output_tz="Europe/Madrid") # fixed output zone
41
44
 
42
45
  # Disable cache
43
46
  client = ENTSOEClient(cache=False)
@@ -60,6 +63,7 @@ class ENTSOEClient:
60
63
  cache: bool = True,
61
64
  cache_dir: str | Path | None = None,
62
65
  cache_recent_ttl: int = 48,
66
+ output_tz: str = "UTC",
63
67
  ) -> None:
64
68
  """Initialize the client.
65
69
 
@@ -71,10 +75,13 @@ class ENTSOEClient:
71
75
  cache: Whether to enable local parquet caching. Defaults to True.
72
76
  cache_dir: Custom cache directory. Defaults to ``~/.cache/entsoe/``.
73
77
  cache_recent_ttl: Hours before data is considered final (default 48).
78
+ output_tz: Result timezone: ``UTC`` (default), ``local`` for the requested area, or an IANA name.
74
79
 
75
80
  Raises:
76
81
  ValueError: If no API key is found.
82
+ InvalidParameterError: If output_tz is not UTC, local, or a valid IANA timezone.
77
83
  """
84
+ output_tz = validate_output_tz(output_tz)
78
85
  resolved_key = api_key or os.environ.get("ENTSOE_API_KEY")
79
86
  if not resolved_key:
80
87
  raise ValueError(
@@ -94,11 +101,11 @@ class ENTSOEClient:
94
101
 
95
102
  cache_store = CacheStore(cache_config) if cache else None
96
103
 
97
- self.load = LoadNamespace(http, tz=tz, cache=cache_store)
98
- self.prices = PricesNamespace(http, tz=tz, cache=cache_store)
99
- self.generation = GenerationNamespace(http, tz=tz, cache=cache_store)
100
- self.transmission = TransmissionNamespace(http, tz=tz, cache=cache_store)
101
- self.balancing = BalancingNamespace(http, tz=tz, cache=cache_store)
104
+ self.load = LoadNamespace(http, tz=tz, output_tz=output_tz, cache=cache_store)
105
+ self.prices = PricesNamespace(http, tz=tz, output_tz=output_tz, cache=cache_store)
106
+ self.generation = GenerationNamespace(http, tz=tz, output_tz=output_tz, cache=cache_store)
107
+ self.transmission = TransmissionNamespace(http, tz=tz, output_tz=output_tz, cache=cache_store)
108
+ self.balancing = BalancingNamespace(http, tz=tz, output_tz=output_tz, cache=cache_store)
102
109
 
103
110
  # Expose for CLI cache commands
104
111
  self._cache = cache_store
@@ -3,12 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
6
7
  from typing import TYPE_CHECKING, Callable, Optional, Union
7
8
 
8
9
  import pandas as pd
9
10
 
10
- from ..exceptions import InvalidParameterError, NoDataError
11
- from .._mappings import country_name, lookup_area
11
+ from ..exceptions import InvalidParameterError
12
+ from .._mappings import AREA_TZ, EIC_TO_ISO, country_name, lookup_area
12
13
 
13
14
  if TYPE_CHECKING:
14
15
  import xml.etree.ElementTree as ET
@@ -28,6 +29,52 @@ OneOrMany = Union[str, list[str]]
28
29
  TimeSeriesFilter = Optional[Callable[["ET.Element"], bool]]
29
30
 
30
31
 
32
+ def validate_output_tz(value: str) -> str:
33
+ """Validate an output timezone without changing its spelling."""
34
+ if value == "local":
35
+ return value
36
+ if not isinstance(value, str):
37
+ raise InvalidParameterError("output_tz must be 'UTC', 'local', or an IANA timezone.")
38
+ try:
39
+ ZoneInfo(value)
40
+ except (ZoneInfoNotFoundError, ValueError):
41
+ raise InvalidParameterError(
42
+ f"Invalid output_tz {value!r}; use 'UTC', 'local', or an IANA timezone."
43
+ ) from None
44
+ return value
45
+
46
+
47
+ def _to_output_tz(df: pd.DataFrame, zones: OneOrMany, output_tz: str) -> pd.DataFrame:
48
+ """Convert a UTC result at the namespace boundary without mutating cached data.
49
+
50
+ For cross-border requests, callers pass the first in_domain zone; local time then follows that zone.
51
+ """
52
+ target = validate_output_tz(output_tz)
53
+ if target == "UTC":
54
+ return df
55
+ if target == "local":
56
+ requested = [zones] if isinstance(zones, str) else zones
57
+ timezones = [AREA_TZ[EIC_TO_ISO[lookup_area(zone)]] for zone in requested]
58
+ instants = df["timestamp"].drop_duplicates() if "timestamp" in df else []
59
+ if not len(instants):
60
+ instants = [pd.Timestamp("2024-01-01", tz="UTC"), pd.Timestamp("2024-07-01", tz="UTC")]
61
+ signatures = {
62
+ zone: tuple(instant.to_pydatetime().astimezone(ZoneInfo(zone)).utcoffset() for instant in instants)
63
+ for zone in set(timezones)
64
+ }
65
+ if len(set(signatures.values())) != 1:
66
+ raise InvalidParameterError(
67
+ f"Local output spans different timezones: {', '.join(sorted(set(timezones)))}. "
68
+ "Use 'UTC' or a fixed IANA timezone."
69
+ )
70
+ target = timezones[0]
71
+ if df.empty or "timestamp" not in df:
72
+ return df.copy()
73
+ result = df.copy()
74
+ result["timestamp"] = result["timestamp"].dt.tz_convert(target)
75
+ return result
76
+
77
+
31
78
  class BaseNamespace:
32
79
  """Base class for domain namespaces.
33
80
 
@@ -40,12 +87,17 @@ class BaseNamespace:
40
87
  http: HttpClient,
41
88
  *,
42
89
  tz: str = "Europe/Brussels",
90
+ output_tz: str = "UTC",
43
91
  cache: CacheStore | None = None,
44
92
  ) -> None:
45
93
  self._http = http
46
94
  self._tz = tz
95
+ self._output_tz = validate_output_tz(output_tz)
47
96
  self._cache = cache
48
97
 
98
+ def _output(self, df: pd.DataFrame, zones: OneOrMany, output_tz: str | None) -> pd.DataFrame:
99
+ return _to_output_tz(df, zones, self._output_tz if output_tz is None else output_tz)
100
+
49
101
  # ------------------------------------------------------------------
50
102
  # Timestamp coercion
51
103
  # ------------------------------------------------------------------
@@ -232,17 +284,14 @@ class BaseNamespace:
232
284
  )
233
285
  new_wide_frames = []
234
286
  gap_meta: dict = {}
287
+ # A failed request propagates: returning the cache without the gap
288
+ # would silently hand back partial or empty data.
235
289
  for gap in valid_gaps:
236
- try:
237
- raw_df = self._query(params, gap.start, gap.end, ts_filter=ts_filter)
238
- wide, meta = self._to_wide(raw_df, pivot_col, value_col)
239
- new_wide_frames.append(wide)
240
- if meta:
241
- gap_meta.update(meta)
242
- except (InvalidParameterError, NoDataError):
243
- raise
244
- except Exception as exc:
245
- logger.warning("Failed to fetch gap %s→%s: %s", gap.start, gap.end, exc)
290
+ raw_df = self._query(params, gap.start, gap.end, ts_filter=ts_filter)
291
+ wide, meta = self._to_wide(raw_df, pivot_col, value_col)
292
+ new_wide_frames.append(wide)
293
+ if meta:
294
+ gap_meta.update(meta)
246
295
 
247
296
  # 4. Merge new data with cache and persist
248
297
  if new_wide_frames: