Skip to content

Commit 25cd8d3

Browse files
authored
Merge branch 'main' into feature/site-group-management-and-details
2 parents d7c3926 + 6079d1b commit 25cd8d3

File tree

4 files changed

+751
-0
lines changed

4 files changed

+751
-0
lines changed

src/pvsite_datamodel/read/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
get_user_details,
1010
validate_email,
1111
)
12+
13+
from .forecast_value import get_forecast_values_fast
14+
1215
from .generation import get_pv_generation_by_sites, get_pv_generation_by_user_uuids
1316
from .latest_forecast_values import get_latest_forecast_values_by_site
1417
from .model import get_or_create_model
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
""" Read Foreacsts from database """
2+
from datetime import datetime
3+
from pvsite_datamodel import ForecastSQL, ForecastValueSQL
4+
from pvsite_datamodel.sqlmodels import MLModelSQL
5+
import uuid
6+
import logging
7+
8+
9+
log = logging.getLogger(__name__)
10+
11+
12+
def get_last_forecast_uuid(
13+
session,
14+
site_uuid: str | uuid.UUID,
15+
start_utc: datetime | None = None,
16+
created_after: datetime | None = None,
17+
created_before: datetime | None = None,
18+
end_utc: datetime | None = None,
19+
model_name: str | None = None,
20+
horizon_minutes: int | None = None,
21+
) -> list[str | uuid.UUID] | None:
22+
"""Get the last forecast UUIDs
23+
24+
:param session: database session
25+
:param site_uuid: UUID of the site for which to get the forecast
26+
:param start_utc: optional filter on start datetime
27+
:param created_after: optional filter on creation datetime (inclusive)
28+
:param created_before: optional filter on creation datetime (exclusive)
29+
:param end_utc: optional filter on end datetime (exclusive)
30+
:param model_name: optional filter on model name
31+
:param horizon_minutes: optional filter on forecast horizon in minutes
32+
:return: list of forecast UUIDs or None if no forecasts found
33+
"""
34+
35+
query = session.query(ForecastValueSQL.forecast_uuid)
36+
query = query.join(ForecastSQL)
37+
query = query.filter(ForecastSQL.location_uuid == site_uuid)
38+
39+
if created_after is not None:
40+
query = query.filter(ForecastSQL.created_utc >= created_after)
41+
query = query.filter(ForecastSQL.timestamp_utc >= created_after)
42+
43+
if created_before is not None:
44+
query = query.filter(ForecastSQL.created_utc < created_before)
45+
query = query.filter(ForecastSQL.timestamp_utc < created_before)
46+
47+
if model_name is not None:
48+
query = query.join(MLModelSQL, ForecastValueSQL.ml_model_uuid == MLModelSQL.model_uuid)
49+
query = query.filter(MLModelSQL.name == model_name)
50+
51+
if start_utc is not None:
52+
query = query.filter(ForecastValueSQL.start_utc >= start_utc)
53+
54+
if end_utc is not None:
55+
query = query.filter(ForecastValueSQL.start_utc < end_utc)
56+
57+
if horizon_minutes is not None:
58+
query = query.filter(ForecastValueSQL.horizon_minutes == horizon_minutes)
59+
60+
query = query.order_by(ForecastSQL.timestamp_utc.desc())
61+
query = query.limit(1)
62+
63+
rows = query.all()
64+
65+
if len(rows) == 0:
66+
log.warning(
67+
f"Could not find any forecasts for {site_uuid} at {start_utc} "
68+
f"with {created_after=} and {end_utc=}"
69+
)
70+
return None
71+
72+
return [r[0] for r in rows]
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
""" Get forecast values from the database
2+
3+
This replace latest_forecast_values.py
4+
"""
5+
import datetime as dt
6+
import logging
7+
import uuid
8+
9+
from sqlalchemy import text
10+
from sqlalchemy.orm import Session
11+
12+
from pvsite_datamodel.read.forecast import get_last_forecast_uuid
13+
from pvsite_datamodel.sqlmodels import ForecastSQL, MLModelSQL
14+
from pvsite_datamodel.sqlmodels import ForecastValueSQL
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
def get_forecast_values_fast(
20+
session: Session,
21+
site_uuid: uuid.UUID | str,
22+
start_utc: dt.datetime,
23+
end_utc: dt.datetime | None = None,
24+
created_by: dt.datetime | None = None,
25+
created_after: dt.datetime | None = None,
26+
forecast_horizon_minutes: int | None = None,
27+
model_name: str | None = None,
28+
) -> list[ForecastValueSQL]:
29+
"""
30+
Get forecast values
31+
32+
The ideas is to split this query into separate ones
33+
1. Get the latest forecasts (not the forecast values)
34+
2. Get forecast values uuids in the future, this should be quicker
35+
because only one forecast needs to be loaded
36+
3. Get forecast values uuids in the past
37+
4. Get the actual forecast values
38+
39+
:param session: Database sessions
40+
:param site_uuid: The site UUID for which to fetch forecast values
41+
:param start_utc: filters on forecast values start_utc >= start_utc
42+
:param end_utc: optional filter on forecast values start_utc < end_utc
43+
:param created_by: optional filter on forecast values created time <= created_by
44+
:param created_after: optional filter on forecast values created time >= created_after
45+
:param forecast_horizon_minutes: optional filter on forecast horizon minutes.
46+
:param model_name: optional filter on forecast values with this model name
47+
:return: list of forecast value SQL objects
48+
"""
49+
50+
# TODO add day ahead options
51+
52+
# 1. forecast uuids from the last forecast
53+
forecast_uuids = get_last_forecast_uuid(
54+
session=session,
55+
model_name=model_name,
56+
site_uuid=site_uuid,
57+
created_before=created_by,
58+
start_utc=start_utc,
59+
end_utc=end_utc,
60+
)
61+
logger.debug("Found forecast uuids for future period")
62+
63+
# 2. Get future forecast values
64+
future_forecast_values_uuids = get_forecast_values(
65+
session=session,
66+
site_uuid=site_uuid,
67+
start_utc=start_utc,
68+
end_utc=end_utc,
69+
created_by=created_by,
70+
created_after=created_after,
71+
forecast_horizon_minutes=forecast_horizon_minutes,
72+
model_name=model_name,
73+
forecast_uuids=forecast_uuids,
74+
forecast_value_uuids_only=True,
75+
)
76+
77+
logger.debug(f"{len(future_forecast_values_uuids)=}")
78+
79+
# 3. Get past forecast values
80+
# get the forecast horizon between forecast_horizon_minutes
81+
# and forecast_horizon_minutes + 60
82+
if forecast_horizon_minutes is None:
83+
forecast_horizon_minutes_upper_limit = 60
84+
else:
85+
forecast_horizon_minutes_upper_limit = forecast_horizon_minutes + 60
86+
87+
logger.debug(f"{start_utc} - {end_utc} for past forecasts")
88+
past_forecast_values_uuids = get_forecast_values(
89+
session=session,
90+
site_uuid=site_uuid,
91+
start_utc=start_utc,
92+
end_utc=end_utc,
93+
created_by=created_by,
94+
created_after=created_after,
95+
forecast_horizon_minutes=forecast_horizon_minutes,
96+
forecast_horizon_minutes_upper_limit=forecast_horizon_minutes_upper_limit,
97+
model_name=model_name,
98+
forecast_value_uuids_only=True,
99+
)
100+
101+
# Combine past and future forecast values
102+
forecast_values_uuids = past_forecast_values_uuids + future_forecast_values_uuids
103+
104+
# 4. get the actual forecast values
105+
forecast_values = get_forecast_values(
106+
session=session,
107+
site_uuid=site_uuid,
108+
start_utc=start_utc,
109+
end_utc=end_utc,
110+
created_by=created_by,
111+
created_after=created_after,
112+
forecast_horizon_minutes=forecast_horizon_minutes,
113+
model_name=model_name,
114+
forecast_value_uuids=forecast_values_uuids,
115+
)
116+
117+
return forecast_values
118+
119+
120+
def get_forecast_values(
121+
session: Session,
122+
site_uuid: uuid.UUID | str,
123+
start_utc: dt.datetime,
124+
end_utc: dt.datetime | None = None,
125+
created_by: dt.datetime | None = None,
126+
created_after: dt.datetime | None = None,
127+
forecast_horizon_minutes: int | None = None,
128+
forecast_horizon_minutes_upper_limit: int | None = None,
129+
day_ahead_hours: int | None = None,
130+
day_ahead_timezone_delta_hours: float | None = 0,
131+
model_name: str | None = None,
132+
forecast_uuids: list[uuid.UUID] | None = None,
133+
forecast_value_uuids: list[uuid.UUID] | None = None,
134+
forecast_value_uuids_only: bool = False,
135+
) -> list[uuid.UUID] | list[ForecastValueSQL]:
136+
"""Get the forecast values by input sites, get the latest value.
137+
138+
Return the forecasts after a given date, but keeping only the latest for a given timestamp.
139+
140+
The query looks like:
141+
142+
SELECT
143+
DISTINCT ON (f.site_uuid, fv.start_utc)
144+
f.site_uuid,
145+
fv.forecast_power_kw,
146+
fv.start_utc
147+
FROM forecast_values AS fv
148+
JOIN forecasts AS f
149+
ON f.forecast_uuid = fv.forecast_uuid
150+
WHERE fv.start_utc >= <start_utc>
151+
ORDER BY
152+
f.site_uuid,
153+
fv.start_utc,
154+
f.timestamp_utc DESC
155+
f.created_utc DESC
156+
157+
:param session: The sqlalchemy database session
158+
:param site_uuid: a site_uuid for which to fetch forecast values
159+
:param start_utc: filters on forecast values target_time >= start_utc
160+
:param end_utc: optional, filters on forecast values target_time < end_utc
161+
:param created_by: filter on forecast values created time <= created_by
162+
:param created_after: optional, filter on forecast values created time >= created_after
163+
:param forecast_horizon_minutes, optional, filter on forecast horizon minutes. We
164+
return any forecast with forecast horizon minutes >= this value.
165+
For example, for forecast_horizon_minutes==90, the latest forecast great or equal to
166+
forecast_horizon_minutes=90 will be loaded.
167+
:param forecast_horizon_minutes_upper_limit: optional,
168+
filter on forecast horizon minutes upper limit.
169+
:param day_ahead_hours: optional, filter on forecast values on creation time.
170+
If day_ahead_hours=9, we only get forecasts made before 9 o'clock the day before.
171+
:param day_ahead_timezone_delta_hours: optional, the timezone delta in hours.
172+
As datetimes are stored in UTC, we need to adjust the start_utc when looking at day
173+
ahead forecast. For example a forecast made a 04:00 UTC for 20:00 UTC for India,
174+
is actually a day ahead forcast, as India is 5.5 hours ahead on UTC
175+
:param model_name: optional, filter on forecast values with this model name
176+
:param forecast_uuids: optional, filter on forecast values with these forecast uuids
177+
:param forecast_value_uuids: optional, filter on forecast values with these forecast value uuids
178+
:param forecast_value_uuids_only: if True, only return the forecast value uuids, not the full
179+
"""
180+
181+
if day_ahead_timezone_delta_hours is not None:
182+
# we use mintues and sql cant handle .5 hours (or any decimals)
183+
day_ahead_timezone_delta_minute = int(day_ahead_timezone_delta_hours * 60)
184+
185+
if forecast_value_uuids_only:
186+
# if we only want the forecast value uuids, we can skip the rest of the query
187+
query = session.query(ForecastValueSQL.forecast_value_uuid)
188+
else:
189+
query = session.query(ForecastValueSQL)
190+
191+
query = (
192+
query.distinct(
193+
ForecastValueSQL.start_utc,
194+
)
195+
.join(ForecastSQL)
196+
.filter(
197+
ForecastValueSQL.start_utc >= start_utc,
198+
ForecastSQL.location_uuid == site_uuid,
199+
)
200+
)
201+
202+
# filter on ForecastSQL.timestamp_utc
203+
timestamp_utc_lower_limit = start_utc - dt.timedelta(hours=48)
204+
if forecast_horizon_minutes is not None:
205+
query = query.filter(
206+
ForecastSQL.timestamp_utc
207+
>= timestamp_utc_lower_limit - dt.timedelta(minutes=forecast_horizon_minutes)
208+
)
209+
elif day_ahead_hours:
210+
# if day_ahead_hours is set, we filter on the timestamp_utc as well
211+
query = query.filter(
212+
ForecastSQL.timestamp_utc >= timestamp_utc_lower_limit - dt.timedelta(hours=24)
213+
)
214+
else:
215+
query = query.filter(ForecastSQL.timestamp_utc >= timestamp_utc_lower_limit)
216+
217+
if end_utc is not None:
218+
query = query.filter(ForecastValueSQL.start_utc < end_utc)
219+
query = query.filter(ForecastSQL.timestamp_utc < end_utc)
220+
221+
if created_by is not None:
222+
query = query.filter(ForecastValueSQL.created_utc <= created_by)
223+
query = query.filter(ForecastSQL.created_utc <= created_by)
224+
225+
if created_after is not None:
226+
query = query.filter(ForecastValueSQL.created_utc >= created_after)
227+
query = query.filter(ForecastSQL.created_utc >= created_after)
228+
229+
if forecast_horizon_minutes is not None:
230+
query = query.filter(ForecastValueSQL.horizon_minutes >= forecast_horizon_minutes)
231+
232+
if forecast_horizon_minutes_upper_limit is not None:
233+
query = query.filter(
234+
ForecastValueSQL.horizon_minutes <= forecast_horizon_minutes_upper_limit
235+
)
236+
237+
if day_ahead_hours:
238+
"""Filter on forecast values on creation time for day ahead
239+
240+
For the UK, this means we only get forecasts made before 9 o'clock the day before.
241+
We do this by
242+
1. Getting the start_utc, and taking the date. '2024-04-01 20:00:00' -> '2024-04-01'
243+
2. Minus one day. '2024-04-01' -> '2024-03-31'
244+
3. Add 9 hours for 9 am. '2024-03-31' -> '2024-03-31 09:00:00'
245+
4. Then only filters on forecasts made before this time
246+
247+
For India, which is 5.5 hours ahead of UTC, we need to adjust the timezone delta.
248+
This is important as as forecast for '2024-04-01 20:00:00' UTC can be made before
249+
'2024-04-01 04:30:00' UTC and be a day ahead forecast
250+
"""
251+
252+
query = query.filter(
253+
ForecastValueSQL.created_utc
254+
<= text(
255+
f"date(start_utc + interval '{day_ahead_timezone_delta_minute}' minute "
256+
f"- interval '1' day) + interval '{day_ahead_hours}' hour "
257+
f"- interval '{day_ahead_timezone_delta_minute}' minute",
258+
),
259+
)
260+
261+
if model_name is not None:
262+
# join with MLModelSQL to filter on model_name
263+
query = query.join(MLModelSQL)
264+
query = query.filter(MLModelSQL.name == model_name)
265+
266+
if forecast_uuids is not None:
267+
# filter on forecast_uuids
268+
query = query.filter(ForecastSQL.forecast_uuid.in_(forecast_uuids))
269+
270+
if forecast_value_uuids is not None:
271+
# filter on forecast_value_uuids
272+
query = query.filter(ForecastValueSQL.forecast_value_uuid.in_(forecast_value_uuids))
273+
274+
query = query.order_by(
275+
ForecastValueSQL.start_utc,
276+
ForecastSQL.timestamp_utc.desc(),
277+
ForecastSQL.created_utc.desc(),
278+
)
279+
280+
# query results
281+
if forecast_value_uuids_only:
282+
forecast_values = query.all()
283+
forecast_values_uuids = [row[0] for row in forecast_values]
284+
return forecast_values_uuids
285+
else:
286+
forecast_values: list[ForecastValueSQL] = query.all()
287+
return forecast_values

0 commit comments

Comments
 (0)