ogre-sdk 0.1.0__py3-none-any.whl
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.
- ogre_sdk/__init__.py +75 -0
- ogre_sdk/_http.py +44 -0
- ogre_sdk/_resource.py +83 -0
- ogre_sdk/client.py +72 -0
- ogre_sdk/exceptions.py +16 -0
- ogre_sdk/models.py +192 -0
- ogre_sdk/py.typed +0 -0
- ogre_sdk/resources/__init__.py +19 -0
- ogre_sdk/resources/consumers.py +226 -0
- ogre_sdk/resources/forecast_settings.py +64 -0
- ogre_sdk/resources/forecasts.py +75 -0
- ogre_sdk/resources/ingestion_settings.py +55 -0
- ogre_sdk/resources/meteo_locations.py +81 -0
- ogre_sdk/resources/meteo_sources.py +19 -0
- ogre_sdk/resources/training.py +81 -0
- ogre_sdk-0.1.0.dist-info/METADATA +14 -0
- ogre_sdk-0.1.0.dist-info/RECORD +18 -0
- ogre_sdk-0.1.0.dist-info/WHEEL +4 -0
ogre_sdk/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Ogre SDK — Python client for the Ogre forecasting APIs.
|
|
2
|
+
|
|
3
|
+
Example::
|
|
4
|
+
|
|
5
|
+
from ogre_sdk import Ogre
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
client = Ogre(api_key="<your-key>")
|
|
9
|
+
|
|
10
|
+
consumer = client.consumers.create(
|
|
11
|
+
"My Site", 53.35, -6.26,
|
|
12
|
+
project_id="...", dso_id="...", profile_id="...", external_id="...",
|
|
13
|
+
)
|
|
14
|
+
client.meteo_locations.ingest_historical(consumer.meteo_location,
|
|
15
|
+
start_date=datetime(2016, 1, 1), end_date=datetime(2017, 12, 31))
|
|
16
|
+
client.training.run(consumer, consumer.forecast_settings, project_id="...",
|
|
17
|
+
start_date=datetime(2016, 1, 1), end_date=datetime(2017, 12, 31))
|
|
18
|
+
client.forecasts.run(consumer, consumer.forecast_settings, project_id="...",
|
|
19
|
+
start_date=datetime(2018, 1, 1), end_date=datetime(2018, 2, 1))
|
|
20
|
+
result = client.forecasts.get(consumer, consumer.forecast_settings, project_id="...",
|
|
21
|
+
start_date=datetime(2018, 1, 1), end_date=datetime(2018, 2, 1))
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from ._resource import PendingJob
|
|
25
|
+
from .client import Ogre
|
|
26
|
+
from .exceptions import OgreAPIError, OgreError
|
|
27
|
+
from .models import (
|
|
28
|
+
Consumer,
|
|
29
|
+
Dso,
|
|
30
|
+
ForecastResult,
|
|
31
|
+
ForecastSettings,
|
|
32
|
+
IngestionSetting,
|
|
33
|
+
MeteoLocation,
|
|
34
|
+
MeteoSource,
|
|
35
|
+
Page,
|
|
36
|
+
Profile,
|
|
37
|
+
TrainedModel,
|
|
38
|
+
)
|
|
39
|
+
from .resources import (
|
|
40
|
+
Consumers,
|
|
41
|
+
Forecasts,
|
|
42
|
+
IngestionSettings,
|
|
43
|
+
MeteoLocations,
|
|
44
|
+
MeteoSources,
|
|
45
|
+
Training,
|
|
46
|
+
)
|
|
47
|
+
from .resources import (
|
|
48
|
+
ForecastSettings as ForecastSettingsResource,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__version__ = "0.1.0"
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"Consumer",
|
|
55
|
+
"Consumers",
|
|
56
|
+
"Dso",
|
|
57
|
+
"ForecastResult",
|
|
58
|
+
"ForecastSettings",
|
|
59
|
+
"ForecastSettingsResource",
|
|
60
|
+
"Forecasts",
|
|
61
|
+
"IngestionSetting",
|
|
62
|
+
"IngestionSettings",
|
|
63
|
+
"MeteoLocation",
|
|
64
|
+
"MeteoLocations",
|
|
65
|
+
"MeteoSource",
|
|
66
|
+
"MeteoSources",
|
|
67
|
+
"Ogre",
|
|
68
|
+
"OgreAPIError",
|
|
69
|
+
"OgreError",
|
|
70
|
+
"Page",
|
|
71
|
+
"PendingJob",
|
|
72
|
+
"Profile",
|
|
73
|
+
"TrainedModel",
|
|
74
|
+
"Training",
|
|
75
|
+
]
|
ogre_sdk/_http.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Internal HTTP session wrapper for authenticated API requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .exceptions import OgreAPIError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _Session:
|
|
13
|
+
def __init__(self, api_key: str, base_url: str, timeout: int = 30) -> None:
|
|
14
|
+
self._session = requests.Session()
|
|
15
|
+
if api_key:
|
|
16
|
+
self._session.headers.update({"ApiKey": api_key})
|
|
17
|
+
self.base_url = base_url.rstrip("/")
|
|
18
|
+
self.timeout = timeout
|
|
19
|
+
|
|
20
|
+
def _url(self, path: str) -> str:
|
|
21
|
+
return f"{self.base_url}{path}"
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def _raise_for_status(response: requests.Response) -> None:
|
|
25
|
+
if not response.ok:
|
|
26
|
+
try:
|
|
27
|
+
detail = response.json().get("detail", response.reason)
|
|
28
|
+
except ValueError:
|
|
29
|
+
detail = response.reason
|
|
30
|
+
raise OgreAPIError(
|
|
31
|
+
status_code=response.status_code,
|
|
32
|
+
message=detail,
|
|
33
|
+
response_body=response.text,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
37
|
+
r = self._session.get(self._url(path), timeout=self.timeout, **kwargs)
|
|
38
|
+
_Session._raise_for_status(r)
|
|
39
|
+
return r.json()
|
|
40
|
+
|
|
41
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
42
|
+
r = self._session.post(self._url(path), timeout=self.timeout, **kwargs)
|
|
43
|
+
_Session._raise_for_status(r)
|
|
44
|
+
return r.json()
|
ogre_sdk/_resource.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Base class shared by all resource clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._http import _Session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PendingJob[T]:
|
|
14
|
+
"""A submitted async API operation; call .wait() to block until it completes."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
raw: dict[str, Any],
|
|
19
|
+
*,
|
|
20
|
+
wait_fn: Callable[[int, int], T],
|
|
21
|
+
poll_interval: int = 20,
|
|
22
|
+
timeout: int = 600,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._raw = raw
|
|
25
|
+
self._wait_fn = wait_fn
|
|
26
|
+
self._poll_interval = poll_interval
|
|
27
|
+
self._timeout = timeout
|
|
28
|
+
|
|
29
|
+
def wait(self, *, poll_interval: int | None = None, timeout: int | None = None) -> T:
|
|
30
|
+
"""Block until the operation completes."""
|
|
31
|
+
return self._wait_fn(
|
|
32
|
+
poll_interval if poll_interval is not None else self._poll_interval,
|
|
33
|
+
timeout if timeout is not None else self._timeout,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def __getitem__(self, key: str) -> Any:
|
|
37
|
+
return self._raw[key]
|
|
38
|
+
|
|
39
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
40
|
+
return self._raw.get(key, default)
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
return f"PendingJob({self._raw!r})"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _BaseResource:
|
|
47
|
+
def __init__(self, http: _Session, *, project_id: str | None = None) -> None:
|
|
48
|
+
self._http = http
|
|
49
|
+
self._project_id = project_id
|
|
50
|
+
self._log = logging.getLogger(f"ogre.{type(self).__name__.lower()}")
|
|
51
|
+
|
|
52
|
+
def _resolve_project_id(self, project_id: str | None) -> str:
|
|
53
|
+
resolved = project_id or self._project_id
|
|
54
|
+
if not resolved:
|
|
55
|
+
msg = "project_id must be provided either at the client level (Ogre(project_id=...)) or per-call."
|
|
56
|
+
raise ValueError(msg)
|
|
57
|
+
return resolved
|
|
58
|
+
|
|
59
|
+
def _sleep(self, seconds: int, reason: str) -> None:
|
|
60
|
+
if seconds > 0:
|
|
61
|
+
self._log.info("Waiting %ds for %s...", seconds, reason)
|
|
62
|
+
time.sleep(seconds)
|
|
63
|
+
|
|
64
|
+
def _poll(
|
|
65
|
+
self,
|
|
66
|
+
check: Callable[[], Any],
|
|
67
|
+
*,
|
|
68
|
+
poll_interval: int,
|
|
69
|
+
timeout: int,
|
|
70
|
+
description: str,
|
|
71
|
+
) -> Any:
|
|
72
|
+
deadline = time.monotonic() + timeout
|
|
73
|
+
while True:
|
|
74
|
+
result = check()
|
|
75
|
+
if result:
|
|
76
|
+
self._log.info("%s complete", description)
|
|
77
|
+
return result
|
|
78
|
+
remaining = deadline - time.monotonic()
|
|
79
|
+
if remaining <= 0:
|
|
80
|
+
msg = f"{description} not available after {timeout}s"
|
|
81
|
+
raise TimeoutError(msg)
|
|
82
|
+
self._log.info("Not ready yet, retrying in %ds (%s)...", poll_interval, description)
|
|
83
|
+
time.sleep(min(poll_interval, remaining))
|
ogre_sdk/client.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Top-level Ogre client for accessing all Ogre API resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._http import _Session
|
|
6
|
+
from .resources import (
|
|
7
|
+
Consumers,
|
|
8
|
+
Forecasts,
|
|
9
|
+
ForecastSettings,
|
|
10
|
+
IngestionSettings,
|
|
11
|
+
MeteoLocations,
|
|
12
|
+
MeteoSources,
|
|
13
|
+
Training,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Ogre:
|
|
18
|
+
"""Python client for the Ogre forecasting APIs.
|
|
19
|
+
|
|
20
|
+
Example::
|
|
21
|
+
|
|
22
|
+
client = Ogre(api_key="<your-key>", project_id="<your-project-id>")
|
|
23
|
+
# Optional overrides: demand_url=..., project_id can also be passed per-call
|
|
24
|
+
|
|
25
|
+
consumer = client.consumers.create(
|
|
26
|
+
"My Site", 53.35, -6.26,
|
|
27
|
+
dso_id="...", profile_id="...", external_id="...",
|
|
28
|
+
)
|
|
29
|
+
client.meteo_locations.ingest_historical(location, start_date=..., end_date=...)
|
|
30
|
+
client.training.run(consumer, fs, start_date=..., end_date=...)
|
|
31
|
+
client.forecasts.run(consumer, fs, start_date=..., end_date=...)
|
|
32
|
+
result = client.forecasts.get(consumer, fs, start_date=..., end_date=...)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
DEFAULT_DEMAND_URL = "https://api.dev.ogre.ai/demand"
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
api_key: str,
|
|
40
|
+
*,
|
|
41
|
+
demand_url: str = DEFAULT_DEMAND_URL,
|
|
42
|
+
project_id: str | None = None,
|
|
43
|
+
http_timeout: int = 30,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Initialize the client.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
api_key: Your Ogre API key.
|
|
49
|
+
demand_url: Base URL for the demand service. Defaults to the production endpoint.
|
|
50
|
+
project_id: Default project ID used for all demand requests. Can be overridden
|
|
51
|
+
per-call by passing ``project_id=`` to individual methods.
|
|
52
|
+
http_timeout: Request timeout in seconds.
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
demand_http = _Session(api_key, demand_url, timeout=http_timeout)
|
|
56
|
+
|
|
57
|
+
self.meteo_sources = MeteoSources(demand_http)
|
|
58
|
+
self.meteo_locations = MeteoLocations(demand_http, project_id=project_id)
|
|
59
|
+
self.ingestion_settings = IngestionSettings(demand_http, self.meteo_sources)
|
|
60
|
+
self.forecast_settings = ForecastSettings(demand_http, project_id=project_id)
|
|
61
|
+
self.training = Training(demand_http, project_id=project_id)
|
|
62
|
+
self.forecasts = Forecasts(demand_http, project_id=project_id)
|
|
63
|
+
self.consumers = Consumers(
|
|
64
|
+
demand_http,
|
|
65
|
+
project_id=project_id,
|
|
66
|
+
meteo_locations=self.meteo_locations,
|
|
67
|
+
ingestion_settings=self.ingestion_settings,
|
|
68
|
+
meteo_sources=self.meteo_sources,
|
|
69
|
+
forecast_settings=self.forecast_settings,
|
|
70
|
+
training=self.training,
|
|
71
|
+
forecasts=self.forecasts,
|
|
72
|
+
)
|
ogre_sdk/exceptions.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Exception types raised by the Ogre SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OgreError(Exception):
|
|
5
|
+
"""Base class for all Ogre SDK errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OgreAPIError(OgreError):
|
|
9
|
+
"""Raised when the API returns a non-2xx response."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, status_code: int, message: str, response_body: str) -> None:
|
|
12
|
+
"""Initialize with the HTTP status code, error message, and raw response body."""
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
self.message = message
|
|
15
|
+
self.response_body = response_body
|
|
16
|
+
super().__init__(f"HTTP {status_code}: {message}")
|
ogre_sdk/models.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Pydantic model definitions for Ogre API response objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, PrivateAttr
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MeteoLocation(BaseModel):
|
|
13
|
+
"""A geographic location used for meteorological data ingestion."""
|
|
14
|
+
|
|
15
|
+
id: str
|
|
16
|
+
name: str
|
|
17
|
+
latitude: float
|
|
18
|
+
longitude: float
|
|
19
|
+
meteo_type: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MeteoSource(BaseModel):
|
|
23
|
+
"""An external meteorological data provider."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IngestionSetting(BaseModel):
|
|
30
|
+
"""Configuration for ingesting data from a meteo source into a location."""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
enabled: bool
|
|
34
|
+
meteo_source_id: str
|
|
35
|
+
params: list[dict[str, Any]]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Dso(BaseModel):
|
|
39
|
+
"""A distribution system operator."""
|
|
40
|
+
|
|
41
|
+
id: str
|
|
42
|
+
name: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Profile(BaseModel):
|
|
46
|
+
"""A load profile attached to a consumer."""
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
name: str
|
|
50
|
+
description: str | None = None
|
|
51
|
+
external_id: str | None = None
|
|
52
|
+
dso_id: str | None = None
|
|
53
|
+
date_added: str | None = None
|
|
54
|
+
last_updated: str | None = None
|
|
55
|
+
project_id: str | None = None
|
|
56
|
+
total_consumers: int | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Consumer(BaseModel):
|
|
60
|
+
"""An electricity demand consumer (e.g. a site or meter)."""
|
|
61
|
+
|
|
62
|
+
id: str
|
|
63
|
+
name: str
|
|
64
|
+
external_id: str
|
|
65
|
+
latitude: float | None = None
|
|
66
|
+
longitude: float | None = None
|
|
67
|
+
has_realtime_data: bool
|
|
68
|
+
dso: Dso
|
|
69
|
+
profile: Profile | None = None
|
|
70
|
+
location: str | None = None
|
|
71
|
+
country: str | None = None
|
|
72
|
+
location_type: str | None = None
|
|
73
|
+
industry: str | None = None
|
|
74
|
+
voltage_type: str | None = None
|
|
75
|
+
type: str | None = None
|
|
76
|
+
expiry_date: str | None = None
|
|
77
|
+
date_added: str | None = None
|
|
78
|
+
consumer_forecast: float | None = None
|
|
79
|
+
meta: dict[str, Any] | None = None
|
|
80
|
+
profile_name: str | None = None
|
|
81
|
+
cluster_ids: list[str] | None = None
|
|
82
|
+
created_at: str
|
|
83
|
+
updated_at: str
|
|
84
|
+
first_historical_timestamp: str | None = None
|
|
85
|
+
last_historical_timestamp: str | None = None
|
|
86
|
+
meteo_location: MeteoLocation | None = None
|
|
87
|
+
forecast_settings: ForecastSettings | None = None
|
|
88
|
+
|
|
89
|
+
_meteo_locations: Any = PrivateAttr(default=None)
|
|
90
|
+
_consumers_resource: Any = PrivateAttr(default=None)
|
|
91
|
+
_training: Any = PrivateAttr(default=None)
|
|
92
|
+
_forecasts: Any = PrivateAttr(default=None)
|
|
93
|
+
|
|
94
|
+
def _require_bound(self, name: str) -> None:
|
|
95
|
+
if self._consumers_resource is None:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"Consumer.{name}() requires the consumer to be fetched via the client "
|
|
98
|
+
"(client.consumers.create() or client.consumers.get())."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def ingest_meteo(self, start_date: datetime, end_date: datetime, *, time_resolution: int = 15) -> Any:
|
|
102
|
+
"""Trigger historical meteo data ingestion for this consumer's location."""
|
|
103
|
+
self._require_bound("ingest_meteo")
|
|
104
|
+
if self.meteo_location is None:
|
|
105
|
+
raise RuntimeError("Consumer has no meteo_location attached.")
|
|
106
|
+
return self._meteo_locations.ingest_historical(
|
|
107
|
+
self.meteo_location, start_date, end_date, time_resolution=time_resolution
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def import_historical(
|
|
111
|
+
self,
|
|
112
|
+
csv_path: str | Path,
|
|
113
|
+
*,
|
|
114
|
+
start_date: datetime | None = None,
|
|
115
|
+
end_date: datetime | None = None,
|
|
116
|
+
**kwargs: Any,
|
|
117
|
+
) -> Any:
|
|
118
|
+
"""Upload a historical consumption CSV for this consumer."""
|
|
119
|
+
self._require_bound("import_historical")
|
|
120
|
+
return self._consumers_resource.import_historical(
|
|
121
|
+
csv_path,
|
|
122
|
+
consumer_id=self.id,
|
|
123
|
+
start_date=start_date,
|
|
124
|
+
end_date=end_date,
|
|
125
|
+
**kwargs,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def get_historical(self, start_date: datetime, end_date: datetime) -> list[dict[str, Any]]:
|
|
129
|
+
"""Return historical consumption data points for this consumer."""
|
|
130
|
+
self._require_bound("get_historical")
|
|
131
|
+
return self._consumers_resource.get_historical(self.id, start_date, end_date)
|
|
132
|
+
|
|
133
|
+
def train(self, start_date: datetime, end_date: datetime) -> Any:
|
|
134
|
+
"""Submit a training job for this consumer."""
|
|
135
|
+
self._require_bound("train")
|
|
136
|
+
if self.forecast_settings is None:
|
|
137
|
+
raise RuntimeError("Consumer has no forecast_settings attached.")
|
|
138
|
+
return self._training.run(self, self.forecast_settings, start_date, end_date)
|
|
139
|
+
|
|
140
|
+
def run_forecast(self, start_date: datetime, end_date: datetime) -> Any:
|
|
141
|
+
"""Submit a forecast run for this consumer."""
|
|
142
|
+
self._require_bound("run_forecast")
|
|
143
|
+
if self.forecast_settings is None:
|
|
144
|
+
raise RuntimeError("Consumer has no forecast_settings attached.")
|
|
145
|
+
return self._forecasts.run(self, self.forecast_settings, start_date, end_date)
|
|
146
|
+
|
|
147
|
+
def get_forecast(self, start_date: datetime, end_date: datetime) -> Any:
|
|
148
|
+
"""Download forecast results for this consumer."""
|
|
149
|
+
self._require_bound("get_forecast")
|
|
150
|
+
if self.forecast_settings is None:
|
|
151
|
+
raise RuntimeError("Consumer has no forecast_settings attached.")
|
|
152
|
+
return self._forecasts.get(self, self.forecast_settings, start_date, end_date)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ForecastSettings(BaseModel):
|
|
156
|
+
"""Forecast configuration attached to a consumer."""
|
|
157
|
+
|
|
158
|
+
id: str
|
|
159
|
+
name: str
|
|
160
|
+
time_resolution: int
|
|
161
|
+
enabled: bool
|
|
162
|
+
meteo_sources: list[str]
|
|
163
|
+
meteo_locations_ingestion_settings: list[str]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ForecastResult(BaseModel):
|
|
167
|
+
"""Forecast output returned by the API."""
|
|
168
|
+
|
|
169
|
+
data: list[dict[str, Any]]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TrainedModel(BaseModel):
|
|
173
|
+
"""A trained demand forecast model."""
|
|
174
|
+
|
|
175
|
+
id: str
|
|
176
|
+
revision_meta: dict[str, Any]
|
|
177
|
+
bucket_path: str
|
|
178
|
+
created_at: str
|
|
179
|
+
training_timestamp: str | None
|
|
180
|
+
requested_interval: list[Any] | None
|
|
181
|
+
available_interval: list[Any] | None
|
|
182
|
+
kpis: dict[str, Any] | None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Page(BaseModel):
|
|
186
|
+
"""A paginated API response."""
|
|
187
|
+
|
|
188
|
+
items: list[Any]
|
|
189
|
+
total: int
|
|
190
|
+
page: int
|
|
191
|
+
size: int
|
|
192
|
+
pages: int
|
ogre_sdk/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Resource classes exposing each section of the Ogre API."""
|
|
2
|
+
|
|
3
|
+
from .consumers import Consumers
|
|
4
|
+
from .forecast_settings import ForecastSettings
|
|
5
|
+
from .forecasts import Forecasts
|
|
6
|
+
from .ingestion_settings import IngestionSettings
|
|
7
|
+
from .meteo_locations import MeteoLocations
|
|
8
|
+
from .meteo_sources import MeteoSources
|
|
9
|
+
from .training import Training
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Consumers",
|
|
13
|
+
"ForecastSettings",
|
|
14
|
+
"Forecasts",
|
|
15
|
+
"IngestionSettings",
|
|
16
|
+
"MeteoLocations",
|
|
17
|
+
"MeteoSources",
|
|
18
|
+
"Training",
|
|
19
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Resource client for electricity demand consumers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .._http import _Session
|
|
12
|
+
from .._resource import PendingJob, _BaseResource
|
|
13
|
+
from ..exceptions import OgreError
|
|
14
|
+
from ..models import Consumer, MeteoLocation, MeteoSource, Page
|
|
15
|
+
from .forecast_settings import ForecastSettings as ForecastSettingsResource
|
|
16
|
+
from .forecasts import Forecasts
|
|
17
|
+
from .ingestion_settings import IngestionSettings
|
|
18
|
+
from .meteo_locations import MeteoLocations
|
|
19
|
+
from .meteo_sources import MeteoSources
|
|
20
|
+
from .training import Training
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Consumers(_BaseResource):
|
|
24
|
+
"""Manage electricity demand consumers."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
http: _Session,
|
|
29
|
+
*,
|
|
30
|
+
project_id: str | None = None,
|
|
31
|
+
meteo_locations: MeteoLocations,
|
|
32
|
+
ingestion_settings: IngestionSettings,
|
|
33
|
+
meteo_sources: MeteoSources,
|
|
34
|
+
forecast_settings: ForecastSettingsResource,
|
|
35
|
+
training: Training,
|
|
36
|
+
forecasts: Forecasts,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialize the resource with an authenticated HTTP session."""
|
|
39
|
+
super().__init__(http, project_id=project_id)
|
|
40
|
+
self._meteo_locations = meteo_locations
|
|
41
|
+
self._ingestion_settings = ingestion_settings
|
|
42
|
+
self._meteo_sources = meteo_sources
|
|
43
|
+
self._forecast_settings = forecast_settings
|
|
44
|
+
self._training = training
|
|
45
|
+
self._forecasts = forecasts
|
|
46
|
+
|
|
47
|
+
def list(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
project_id: str | None = None,
|
|
51
|
+
search: str | None = None,
|
|
52
|
+
page: int = 1,
|
|
53
|
+
size: int = 50,
|
|
54
|
+
) -> Page:
|
|
55
|
+
"""Return a paginated list of consumers for a project."""
|
|
56
|
+
pid = self._resolve_project_id(project_id)
|
|
57
|
+
params: dict[str, Any] = {"page": page, "size": size}
|
|
58
|
+
if search is not None:
|
|
59
|
+
params["search"] = search
|
|
60
|
+
return Page.model_validate(self._http.get(f"/electricity/demand/{pid}/consumers", params=params))
|
|
61
|
+
|
|
62
|
+
def _bind(self, consumer: Consumer) -> Consumer:
|
|
63
|
+
consumer._meteo_locations = self._meteo_locations
|
|
64
|
+
consumer._consumers_resource = self
|
|
65
|
+
consumer._training = self._training
|
|
66
|
+
consumer._forecasts = self._forecasts
|
|
67
|
+
return consumer
|
|
68
|
+
|
|
69
|
+
def get(self, consumer_id: str, *, project_id: str | None = None) -> Consumer:
|
|
70
|
+
"""Return a single consumer by ID, with meteo_location and forecast_settings populated."""
|
|
71
|
+
pid = self._resolve_project_id(project_id)
|
|
72
|
+
consumer = Consumer.model_validate(self._http.get(f"/electricity/demand/{pid}/consumers/{consumer_id}"))
|
|
73
|
+
if consumer.latitude is not None and consumer.longitude is not None:
|
|
74
|
+
consumer.meteo_location = self._find_location_by_coords(consumer.latitude, consumer.longitude)
|
|
75
|
+
settings = self._forecast_settings.list(consumer, project_id=pid)
|
|
76
|
+
consumer.forecast_settings = settings[0] if settings else None
|
|
77
|
+
return self._bind(consumer)
|
|
78
|
+
|
|
79
|
+
def get_historical(
|
|
80
|
+
self,
|
|
81
|
+
consumer_id: str,
|
|
82
|
+
start_date: datetime,
|
|
83
|
+
end_date: datetime,
|
|
84
|
+
*,
|
|
85
|
+
project_id: str | None = None,
|
|
86
|
+
) -> list[dict[str, Any]]:
|
|
87
|
+
"""Return historical consumption data points for a consumer."""
|
|
88
|
+
pid = self._resolve_project_id(project_id)
|
|
89
|
+
return self._http.get(
|
|
90
|
+
f"/electricity/demand/{pid}/consumers/{consumer_id}/historical",
|
|
91
|
+
params={
|
|
92
|
+
"from_datetime": start_date.isoformat(),
|
|
93
|
+
"to_datetime": end_date.isoformat(),
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def create(
|
|
98
|
+
self,
|
|
99
|
+
name: str,
|
|
100
|
+
latitude: float,
|
|
101
|
+
longitude: float,
|
|
102
|
+
dso_id: str,
|
|
103
|
+
profile_id: str,
|
|
104
|
+
*,
|
|
105
|
+
project_id: str | None = None,
|
|
106
|
+
external_id: str,
|
|
107
|
+
has_realtime_data: bool = False,
|
|
108
|
+
meteo_sources: list[MeteoSource] | None = None,
|
|
109
|
+
existing_location: bool = True,
|
|
110
|
+
forecast_resolution: int = 15,
|
|
111
|
+
) -> Consumer:
|
|
112
|
+
"""Create a consumer with auto-provisioned meteo location, ingestion settings, and forecast settings.
|
|
113
|
+
|
|
114
|
+
When *existing_location* is True the method searches for a meteo location
|
|
115
|
+
that already has the same coordinates; if one is found it is reused. Either
|
|
116
|
+
way, fresh ingestion settings are always created. If no match is found, or
|
|
117
|
+
when the flag is False, a new meteo location is created as well.
|
|
118
|
+
|
|
119
|
+
Note: when a location is reused, calling ``ingest_historical`` on it will
|
|
120
|
+
run ingestion for *all* enabled ingestion settings on that location, not only
|
|
121
|
+
the ones created here. This may ingest more meteo data than strictly
|
|
122
|
+
necessary, but it does not affect forecast accuracy — the forecast settings
|
|
123
|
+
returned here reference only the newly created ingestion settings.
|
|
124
|
+
"""
|
|
125
|
+
pid = self._resolve_project_id(project_id)
|
|
126
|
+
if existing_id := self._find_by_external_id(external_id, pid):
|
|
127
|
+
raise OgreError(f"A consumer with external_id {external_id!r} already exists (id={existing_id}).")
|
|
128
|
+
payload = {
|
|
129
|
+
"name": name,
|
|
130
|
+
"external_id": external_id,
|
|
131
|
+
"latitude": latitude,
|
|
132
|
+
"longitude": longitude,
|
|
133
|
+
"has_realtime_data": has_realtime_data,
|
|
134
|
+
"dso_id": dso_id,
|
|
135
|
+
"profile_id": profile_id,
|
|
136
|
+
}
|
|
137
|
+
consumer = Consumer.model_validate(self._http.post(f"/electricity/demand/{pid}/consumers", json=payload))
|
|
138
|
+
self._log.info("Created consumer %r (id=%s)", consumer.name, consumer.id)
|
|
139
|
+
|
|
140
|
+
sources = meteo_sources if meteo_sources is not None else self._meteo_sources.list()
|
|
141
|
+
|
|
142
|
+
if existing_location:
|
|
143
|
+
location = self._find_location_by_coords(latitude, longitude)
|
|
144
|
+
if location is not None:
|
|
145
|
+
self._log.info("Reusing existing meteo location (id=%s)", location.id)
|
|
146
|
+
else:
|
|
147
|
+
location = self._meteo_locations.create(consumer, project_id=pid)
|
|
148
|
+
else:
|
|
149
|
+
location = self._meteo_locations.create(consumer, project_id=pid)
|
|
150
|
+
|
|
151
|
+
ingest_settings = self._ingestion_settings.create(location, sources)
|
|
152
|
+
|
|
153
|
+
forecast_settings = self._forecast_settings.create(
|
|
154
|
+
consumer, ingest_settings, sources, project_id=pid, time_resolution=forecast_resolution
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
consumer.meteo_location = location
|
|
158
|
+
consumer.forecast_settings = forecast_settings
|
|
159
|
+
return self._bind(consumer)
|
|
160
|
+
|
|
161
|
+
def _find_by_external_id(self, external_id: str, project_id: str) -> str | None:
|
|
162
|
+
"""Return the id of the consumer whose external_id is an exact match, or None."""
|
|
163
|
+
page = 1
|
|
164
|
+
while True:
|
|
165
|
+
result = self.list(search=external_id, project_id=project_id, page=page, size=50)
|
|
166
|
+
for item in result.items:
|
|
167
|
+
if item["external_id"] == external_id:
|
|
168
|
+
return item["id"]
|
|
169
|
+
if page >= result.pages:
|
|
170
|
+
break
|
|
171
|
+
page += 1
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
def _find_location_by_coords(self, latitude: float, longitude: float) -> MeteoLocation | None:
|
|
175
|
+
"""Return the first meteo location matching the given coordinates, or None."""
|
|
176
|
+
page = 1
|
|
177
|
+
while True:
|
|
178
|
+
result = self._meteo_locations.list(page=page, size=50)
|
|
179
|
+
for loc in result.items:
|
|
180
|
+
if loc["latitude"] == latitude and loc["longitude"] == longitude:
|
|
181
|
+
return MeteoLocation.model_validate(loc)
|
|
182
|
+
if page >= result.pages:
|
|
183
|
+
break
|
|
184
|
+
page += 1
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def import_historical(
|
|
188
|
+
self,
|
|
189
|
+
csv_path: str | Path,
|
|
190
|
+
*,
|
|
191
|
+
project_id: str | None = None,
|
|
192
|
+
consumer_id: str | None = None,
|
|
193
|
+
start_date: datetime | None = None,
|
|
194
|
+
end_date: datetime | None = None,
|
|
195
|
+
pattern: str = "DEFAULT_HISTORICAL_OGRE_1",
|
|
196
|
+
timestamp_column: str = "Timestamp",
|
|
197
|
+
anomaly_detection_enabled: bool = False,
|
|
198
|
+
service: str = "S3",
|
|
199
|
+
) -> PendingJob[None]:
|
|
200
|
+
"""Upload historical consumption CSV; call .wait() on the result to poll for completion."""
|
|
201
|
+
pid = self._resolve_project_id(project_id)
|
|
202
|
+
csv_path = Path(csv_path)
|
|
203
|
+
files = {"file": (csv_path.name, io.BytesIO(csv_path.read_bytes()), "text/csv")}
|
|
204
|
+
params = {
|
|
205
|
+
"pattern": pattern,
|
|
206
|
+
"columns": json.dumps({"timestamp": timestamp_column}),
|
|
207
|
+
"anomaly_detection_enabled": str(anomaly_detection_enabled).lower(),
|
|
208
|
+
"service": service,
|
|
209
|
+
}
|
|
210
|
+
result = self._http.post(
|
|
211
|
+
f"/electricity/demand/{pid}/consumers/historical",
|
|
212
|
+
params=params,
|
|
213
|
+
files=files,
|
|
214
|
+
)
|
|
215
|
+
self._log.info("Historical import submitted (job_id=%s)", result.get("import_job_id"))
|
|
216
|
+
|
|
217
|
+
def _wait(poll_interval: int, timeout: int) -> None:
|
|
218
|
+
if consumer_id is not None and start_date is not None and end_date is not None:
|
|
219
|
+
self._poll(
|
|
220
|
+
lambda: self.get_historical(consumer_id, start_date, end_date, project_id=pid),
|
|
221
|
+
poll_interval=poll_interval,
|
|
222
|
+
timeout=timeout,
|
|
223
|
+
description="historical import",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return PendingJob(result, wait_fn=_wait, poll_interval=20, timeout=300)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Resource client for forecast configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .._http import _Session
|
|
6
|
+
from .._resource import _BaseResource
|
|
7
|
+
from ..models import Consumer, IngestionSetting, MeteoSource
|
|
8
|
+
from ..models import ForecastSettings as ForecastSettingsModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ForecastSettings(_BaseResource):
|
|
12
|
+
"""Manage forecast configurations for consumers."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http: _Session, *, project_id: str | None = None) -> None:
|
|
15
|
+
"""Initialize the resource with an authenticated HTTP session."""
|
|
16
|
+
super().__init__(http, project_id=project_id)
|
|
17
|
+
|
|
18
|
+
def list(self, consumer: Consumer, *, project_id: str | None = None) -> list[ForecastSettingsModel]:
|
|
19
|
+
"""Return all forecast settings for a consumer."""
|
|
20
|
+
pid = self._resolve_project_id(project_id)
|
|
21
|
+
return [
|
|
22
|
+
ForecastSettingsModel.model_validate(s)
|
|
23
|
+
for s in self._http.get(f"/electricity/demand/{pid}/consumers/{consumer.id}/forecast-settings")
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
def get(
|
|
27
|
+
self, consumer: Consumer, forecast_settings_id: str, *, project_id: str | None = None
|
|
28
|
+
) -> ForecastSettingsModel:
|
|
29
|
+
"""Return a single forecast settings object by ID."""
|
|
30
|
+
pid = self._resolve_project_id(project_id)
|
|
31
|
+
return ForecastSettingsModel.model_validate(
|
|
32
|
+
self._http.get(
|
|
33
|
+
f"/electricity/demand/{pid}/consumers/{consumer.id}/forecast-settings/{forecast_settings_id}"
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def create(
|
|
38
|
+
self,
|
|
39
|
+
consumer: Consumer,
|
|
40
|
+
ingestion_settings: list[IngestionSetting],
|
|
41
|
+
meteo_sources: list[MeteoSource],
|
|
42
|
+
*,
|
|
43
|
+
project_id: str | None = None,
|
|
44
|
+
name: str = "Forecast 15min",
|
|
45
|
+
time_resolution: int = 15,
|
|
46
|
+
use_realtime_data: bool = False,
|
|
47
|
+
horizon: int | None = None,
|
|
48
|
+
) -> ForecastSettingsModel:
|
|
49
|
+
"""Attach forecast configuration to a consumer."""
|
|
50
|
+
payload = {
|
|
51
|
+
"name": name,
|
|
52
|
+
"time_resolution": time_resolution,
|
|
53
|
+
"use_realtime_data": use_realtime_data,
|
|
54
|
+
"enabled": True,
|
|
55
|
+
"meteo_sources": [ms.id for ms in meteo_sources],
|
|
56
|
+
"use_external_meteo_data": False,
|
|
57
|
+
"horizon": horizon,
|
|
58
|
+
"meteo_locations_ingestion_settings": [str(s.id) for s in ingestion_settings],
|
|
59
|
+
}
|
|
60
|
+
pid = self._resolve_project_id(project_id)
|
|
61
|
+
path = f"/electricity/demand/{pid}/consumers/{consumer.id}/forecast-settings"
|
|
62
|
+
result = ForecastSettingsModel.model_validate(self._http.post(path, json=payload))
|
|
63
|
+
self._log.info("Created forecast settings %r (id=%s)", result.name, result.id)
|
|
64
|
+
return result
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Resource client for running and retrieving demand forecasts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .._http import _Session
|
|
9
|
+
from .._resource import PendingJob, _BaseResource
|
|
10
|
+
from ..models import Consumer, ForecastResult, ForecastSettings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Forecasts(_BaseResource):
|
|
14
|
+
"""Trigger and retrieve demand forecast runs."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, http: _Session, *, project_id: str | None = None) -> None:
|
|
17
|
+
"""Initialize the resource with an authenticated HTTP session."""
|
|
18
|
+
super().__init__(http, project_id=project_id)
|
|
19
|
+
|
|
20
|
+
def run(
|
|
21
|
+
self,
|
|
22
|
+
consumer: Consumer,
|
|
23
|
+
forecast_settings: ForecastSettings,
|
|
24
|
+
start_date: datetime,
|
|
25
|
+
end_date: datetime,
|
|
26
|
+
*,
|
|
27
|
+
project_id: str | None = None,
|
|
28
|
+
triggered_by: str = "external",
|
|
29
|
+
) -> PendingJob[ForecastResult]:
|
|
30
|
+
"""Submit a forecast run; call .wait() on the result to block until data is ready."""
|
|
31
|
+
pid = self._resolve_project_id(project_id)
|
|
32
|
+
payload: dict[str, Any] = {
|
|
33
|
+
"start_date": start_date.isoformat(),
|
|
34
|
+
"end_date": end_date.isoformat(),
|
|
35
|
+
"triggered_by": triggered_by,
|
|
36
|
+
}
|
|
37
|
+
path = (
|
|
38
|
+
f"/electricity/demand/{pid}/consumers/{consumer.id}/forecast-settings/{forecast_settings.id}/run-forecast"
|
|
39
|
+
)
|
|
40
|
+
result = self._http.post(path, json=payload)
|
|
41
|
+
self._log.info("Forecast run submitted: %s", result)
|
|
42
|
+
|
|
43
|
+
def _wait(poll_interval: int, timeout: int) -> ForecastResult:
|
|
44
|
+
return self._poll(
|
|
45
|
+
lambda: self.get(consumer, forecast_settings, start_date, end_date, project_id=pid),
|
|
46
|
+
poll_interval=poll_interval,
|
|
47
|
+
timeout=timeout,
|
|
48
|
+
description="forecast",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return PendingJob(result, wait_fn=_wait, poll_interval=20, timeout=600)
|
|
52
|
+
|
|
53
|
+
def get(
|
|
54
|
+
self,
|
|
55
|
+
consumer: Consumer,
|
|
56
|
+
forecast_settings: ForecastSettings,
|
|
57
|
+
start_date: datetime,
|
|
58
|
+
end_date: datetime,
|
|
59
|
+
*,
|
|
60
|
+
project_id: str | None = None,
|
|
61
|
+
) -> ForecastResult:
|
|
62
|
+
"""Download forecast results for a given period."""
|
|
63
|
+
pid = self._resolve_project_id(project_id)
|
|
64
|
+
path = f"/electricity/demand/{pid}/consumers/{consumer.id}/forecast-settings/{forecast_settings.id}/forecast"
|
|
65
|
+
result = self._http.get(
|
|
66
|
+
path,
|
|
67
|
+
params={
|
|
68
|
+
"from_datetime": start_date.isoformat(),
|
|
69
|
+
"to_datetime": end_date.isoformat(),
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
count = len(result)
|
|
73
|
+
if count:
|
|
74
|
+
self._log.info("Forecast downloaded (%d records)", count)
|
|
75
|
+
return result
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Resource client for meteo data ingestion settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .._http import _Session
|
|
6
|
+
from .._resource import _BaseResource
|
|
7
|
+
from ..models import IngestionSetting, MeteoLocation, MeteoSource
|
|
8
|
+
from .meteo_sources import MeteoSources
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IngestionSettings(_BaseResource):
|
|
12
|
+
"""Configure meteo source ingestion for locations."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, http: _Session, meteo_sources: MeteoSources) -> None:
|
|
15
|
+
"""Initialize the resource with an HTTP session and a MeteoSources client."""
|
|
16
|
+
super().__init__(http)
|
|
17
|
+
self._meteo_sources = meteo_sources
|
|
18
|
+
|
|
19
|
+
def list(self, location: MeteoLocation) -> list[IngestionSetting]:
|
|
20
|
+
"""Return all ingestion settings for a meteo location."""
|
|
21
|
+
return [
|
|
22
|
+
IngestionSetting.model_validate(s)
|
|
23
|
+
for s in self._http.get(f"/meteo/locations/{location.id}/ingestion-settings")
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
def create(
|
|
27
|
+
self,
|
|
28
|
+
location: MeteoLocation,
|
|
29
|
+
meteo_sources: list[MeteoSource] | None = None,
|
|
30
|
+
*,
|
|
31
|
+
preset: str = "demand_electricity",
|
|
32
|
+
) -> list[IngestionSetting]:
|
|
33
|
+
"""Configure which meteo sources feed data into a location."""
|
|
34
|
+
sources = meteo_sources if meteo_sources is not None else self._meteo_sources.list()
|
|
35
|
+
if preset == "demand_electricity":
|
|
36
|
+
sources = [s for s in sources if s.name == "meteomatics-ecmwf-ifs"]
|
|
37
|
+
settings: list[IngestionSetting] = []
|
|
38
|
+
for source in sources:
|
|
39
|
+
params = self._http.get(
|
|
40
|
+
f"/meteo/sources/{source.id}/params",
|
|
41
|
+
params={"preset": preset},
|
|
42
|
+
)
|
|
43
|
+
result = IngestionSetting.model_validate(
|
|
44
|
+
self._http.post(
|
|
45
|
+
f"/meteo/locations/{location.id}/ingestion-settings",
|
|
46
|
+
json={"enabled": True, "params": params, "meteo_source_id": source.id},
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
settings.append(result)
|
|
50
|
+
self._log.info(
|
|
51
|
+
"Created ingestion setting for source %r (id=%s)",
|
|
52
|
+
source.name,
|
|
53
|
+
result.id,
|
|
54
|
+
)
|
|
55
|
+
return settings
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Resource client for meteorological locations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .._http import _Session
|
|
9
|
+
from .._resource import PendingJob, _BaseResource
|
|
10
|
+
from ..models import Consumer, MeteoLocation, Page
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MeteoLocations(_BaseResource):
|
|
14
|
+
"""Manage meteorological locations."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, http: _Session, *, project_id: str | None = None) -> None:
|
|
17
|
+
"""Initialize the resource with an authenticated HTTP session."""
|
|
18
|
+
super().__init__(http, project_id=project_id)
|
|
19
|
+
|
|
20
|
+
def list(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
search: str | None = None,
|
|
24
|
+
meteo_type: str | None = None,
|
|
25
|
+
page: int = 1,
|
|
26
|
+
size: int = 50,
|
|
27
|
+
) -> Page:
|
|
28
|
+
"""Return a paginated list of meteo locations."""
|
|
29
|
+
params: dict[str, Any] = {"page": page, "size": size}
|
|
30
|
+
if search is not None:
|
|
31
|
+
params["search"] = search
|
|
32
|
+
if meteo_type is not None:
|
|
33
|
+
params["meteo_type"] = meteo_type
|
|
34
|
+
return Page.model_validate(self._http.get("/meteo/locations", params=params))
|
|
35
|
+
|
|
36
|
+
def create(
|
|
37
|
+
self,
|
|
38
|
+
consumer: Consumer,
|
|
39
|
+
*,
|
|
40
|
+
project_id: str | None = None,
|
|
41
|
+
name: str | None = None,
|
|
42
|
+
meteo_type: str = "LOAD_CITY",
|
|
43
|
+
should_ingest_forecast: bool = True,
|
|
44
|
+
) -> MeteoLocation:
|
|
45
|
+
"""Create a meteorological location tied to a consumer's coordinates."""
|
|
46
|
+
pid = self._resolve_project_id(project_id)
|
|
47
|
+
payload = {
|
|
48
|
+
"latitude": consumer.latitude,
|
|
49
|
+
"longitude": consumer.longitude,
|
|
50
|
+
"name": name or f"{consumer.name} location",
|
|
51
|
+
"meteo_type": meteo_type,
|
|
52
|
+
"should_ingest_forecast": should_ingest_forecast,
|
|
53
|
+
"is_external": False,
|
|
54
|
+
"project_id": pid,
|
|
55
|
+
}
|
|
56
|
+
result = MeteoLocation.model_validate(self._http.post("/meteo/locations", json=payload))
|
|
57
|
+
self._log.info("Created meteo location (id=%s)", result.id)
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
def ingest_historical(
|
|
61
|
+
self,
|
|
62
|
+
location: MeteoLocation,
|
|
63
|
+
start_date: datetime,
|
|
64
|
+
end_date: datetime,
|
|
65
|
+
*,
|
|
66
|
+
time_resolution: int = 15,
|
|
67
|
+
) -> PendingJob[None]:
|
|
68
|
+
"""Trigger historical meteo data ingestion; call .wait() on the result to sleep until done."""
|
|
69
|
+
payload = {
|
|
70
|
+
"start_date": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
71
|
+
"end_date": end_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
72
|
+
"time_resolution": time_resolution,
|
|
73
|
+
}
|
|
74
|
+
result = self._http.post(f"/meteo/locations/{location.id}/ingest-meteo", json=payload)
|
|
75
|
+
self._log.info("Historical meteo ingest submitted (task=%s)", result)
|
|
76
|
+
|
|
77
|
+
def _wait(_: int, timeout: int) -> None:
|
|
78
|
+
self._sleep(timeout, "historical meteo ingestion to complete")
|
|
79
|
+
self._log.info("historical meteo ingestion complete")
|
|
80
|
+
|
|
81
|
+
return PendingJob(result, wait_fn=_wait, poll_interval=0, timeout=600)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Resource client for meteorological data sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .._http import _Session
|
|
6
|
+
from .._resource import _BaseResource
|
|
7
|
+
from ..models import MeteoSource
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MeteoSources(_BaseResource):
|
|
11
|
+
"""Browse available meteorological data sources."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, http: _Session) -> None:
|
|
14
|
+
"""Initialize the resource with an authenticated HTTP session."""
|
|
15
|
+
super().__init__(http)
|
|
16
|
+
|
|
17
|
+
def list(self) -> list[MeteoSource]:
|
|
18
|
+
"""Return all meteo sources available to your project."""
|
|
19
|
+
return [MeteoSource.model_validate(s) for s in self._http.get("/meteo/sources")]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Resource client for model training."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .._http import _Session
|
|
9
|
+
from .._resource import PendingJob, _BaseResource
|
|
10
|
+
from ..models import Consumer, ForecastSettings, TrainedModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Training(_BaseResource):
|
|
14
|
+
"""Trigger and inspect demand forecast model training."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, http: _Session, *, project_id: str | None = None) -> None:
|
|
17
|
+
"""Initialize the resource with an authenticated HTTP session."""
|
|
18
|
+
super().__init__(http, project_id=project_id)
|
|
19
|
+
|
|
20
|
+
def get_model(
|
|
21
|
+
self,
|
|
22
|
+
consumer: Consumer,
|
|
23
|
+
forecast_settings: ForecastSettings,
|
|
24
|
+
*,
|
|
25
|
+
project_id: str | None = None,
|
|
26
|
+
) -> TrainedModel:
|
|
27
|
+
"""Return the trained model associated with a forecast settings object."""
|
|
28
|
+
pid = self._resolve_project_id(project_id)
|
|
29
|
+
path = (
|
|
30
|
+
f"/electricity/demand/{pid}/consumers/{consumer.id}"
|
|
31
|
+
f"/trained-models/by-forecast-settings/{forecast_settings.id}"
|
|
32
|
+
)
|
|
33
|
+
return TrainedModel.model_validate(self._http.get(path))
|
|
34
|
+
|
|
35
|
+
def run(
|
|
36
|
+
self,
|
|
37
|
+
consumer: Consumer,
|
|
38
|
+
forecast_settings: ForecastSettings,
|
|
39
|
+
start_date: datetime,
|
|
40
|
+
end_date: datetime,
|
|
41
|
+
*,
|
|
42
|
+
project_id: str | None = None,
|
|
43
|
+
) -> PendingJob[dict[str, Any]]:
|
|
44
|
+
"""Submit a training job; call .wait() on the result to block until the model is ready."""
|
|
45
|
+
pid = self._resolve_project_id(project_id)
|
|
46
|
+
payload = {
|
|
47
|
+
"start_date": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
48
|
+
"end_date": end_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
49
|
+
"forecast_settings_id": forecast_settings.id,
|
|
50
|
+
}
|
|
51
|
+
path = f"/electricity/demand/{pid}/consumers/{consumer.id}/train"
|
|
52
|
+
result = self._http.post(path, json=payload)
|
|
53
|
+
self._log.info("Training submitted (task=%s)", result)
|
|
54
|
+
|
|
55
|
+
start_naive = start_date.replace(tzinfo=None).isoformat()
|
|
56
|
+
end_naive = end_date.replace(tzinfo=None).isoformat()
|
|
57
|
+
model_path = (
|
|
58
|
+
f"/electricity/demand/{pid}/consumers/{consumer.id}"
|
|
59
|
+
f"/trained-models/by-forecast-settings/{forecast_settings.id}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _check_model() -> dict | None:
|
|
63
|
+
models = self._http.get(model_path)
|
|
64
|
+
if not isinstance(models, list):
|
|
65
|
+
models = [models]
|
|
66
|
+
for model in models:
|
|
67
|
+
interval = model.get("requested_interval")
|
|
68
|
+
if interval and interval[0] <= start_naive and interval[1] >= end_naive:
|
|
69
|
+
self._log.info("Trained model found: %s", model["id"])
|
|
70
|
+
return model
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def _wait(poll_interval: int, timeout: int) -> dict[str, Any]:
|
|
74
|
+
return self._poll(
|
|
75
|
+
_check_model,
|
|
76
|
+
poll_interval=poll_interval,
|
|
77
|
+
timeout=timeout,
|
|
78
|
+
description="model training",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return PendingJob(result, wait_fn=_wait, poll_interval=30, timeout=600)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ogre-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the ogre.ai API
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Requires-Dist: pydantic>=2.0
|
|
7
|
+
Requires-Dist: requests>=2.32
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: jupyterlab>=4.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
11
|
+
Requires-Dist: pre-commit; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
14
|
+
Requires-Dist: types-requests; extra == 'dev'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
ogre_sdk/__init__.py,sha256=wTYJ_3rHoxzFlGl3fPojoLAGP_WZx_cK9ARcquEy4Ns,1895
|
|
2
|
+
ogre_sdk/_http.py,sha256=STr3DQKL_9N4v_Em-q2fizvXJsrWjXKKbmwV9oQ3aNk,1396
|
|
3
|
+
ogre_sdk/_resource.py,sha256=nNtlpyCfT2YKWvvBSTQU7lCBIkhjejjQJLwE5PLjVAM,2667
|
|
4
|
+
ogre_sdk/client.py,sha256=fmJc6OMalbcVCHPLR9HzKmL8QaTtjhWMqSXALg14dvQ,2589
|
|
5
|
+
ogre_sdk/exceptions.py,sha256=iEUNQzrgYgXlfjy_KlPMY0BHF0A5SOieAWqk1D1t7FU,560
|
|
6
|
+
ogre_sdk/models.py,sha256=-PhLn2ArcWsBH4i6iLOoDI37y7JGuYksMfBXspoH82Q,5936
|
|
7
|
+
ogre_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
ogre_sdk/resources/__init__.py,sha256=AJSB2M8sgfFFPxVgB0wYUmrp3RmJ4BZ1Q6RgQgcchUs,498
|
|
9
|
+
ogre_sdk/resources/consumers.py,sha256=iHhVITSDLiCyVvWevQAsBVAl8gigAd0-0XU2WJMPn7I,9323
|
|
10
|
+
ogre_sdk/resources/forecast_settings.py,sha256=dtbKsCSwQFRi-KkQZ6EzsWiAkzd2v23Tdd6oKOXVZao,2654
|
|
11
|
+
ogre_sdk/resources/forecasts.py,sha256=fXl0D5RUF8spTCuA1mnVOmA9jk_YBdZBltE9bYDVA4M,2699
|
|
12
|
+
ogre_sdk/resources/ingestion_settings.py,sha256=h2Iszb8VaSXTeEj55AzJubv2vFN5cP1EkBuTz3F1AWY,2138
|
|
13
|
+
ogre_sdk/resources/meteo_locations.py,sha256=v5mVy2oU5sRVpXDZ50_rJKez17ei0EyIdvki6wJADVA,2972
|
|
14
|
+
ogre_sdk/resources/meteo_sources.py,sha256=-7W2rMx_VZafBPePcGrx4aII8KefXyyrboBlXnu26JY,636
|
|
15
|
+
ogre_sdk/resources/training.py,sha256=L4MCLNPsickNl7vfBHJgPYJ-8dRlhGh1n7b_0dyXGpk,3072
|
|
16
|
+
ogre_sdk-0.1.0.dist-info/METADATA,sha256=zlGyd1xqCZyqldu14gm77xQVQO3crUiyP2oJDpKt3lQ,440
|
|
17
|
+
ogre_sdk-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
18
|
+
ogre_sdk-0.1.0.dist-info/RECORD,,
|