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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any