-
-
Notifications
You must be signed in to change notification settings - Fork 10
/
Copy pathmain.py
483 lines (381 loc) · 14.3 KB
/
main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
"""Main API Routes"""
import logging
import os
import httpx
import pandas as pd
import sentry_sdk
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from fastapi.responses import FileResponse, JSONResponse
from pvlib import irradiance, location, pvsystem
from pvsite_datamodel.read.site import get_all_sites, get_site_by_uuid
from pvsite_datamodel.read.status import get_latest_status
from pvsite_datamodel.sqlmodels import ClientSQL, SiteSQL
from pvsite_datamodel.write.generation import insert_generation_values
from sqlalchemy.orm import Session
import pv_site_api
from ._db_helpers import (
_get_inverters_by_site,
does_site_exist,
get_forecasts_by_sites,
get_generation_by_sites,
site_to_pydantic,
)
from .fake import (
fake_site_uuid,
make_fake_forecast,
make_fake_inverters,
make_fake_pv_generation,
make_fake_site,
make_fake_status,
)
from .pydantic_models import (
ClearskyEstimate,
Forecast,
MultiplePVActual,
PVSiteAPIStatus,
PVSiteMetadata,
PVSites,
)
from .redoc_theme import get_redoc_html_with_theme
from .session import get_session
from .utils import get_inverters_list, get_yesterday_midnight
load_dotenv()
logger = logging.getLogger(__name__)
def traces_sampler(sampling_context):
"""
Filter tracing for sentry logs.
Examine provided context data (including parent decision, if any)
along with anything in the global namespace to compute the sample rate
or sampling decision for this transaction
"""
if os.getenv("ENVIRONMENT") == "local":
return 0.0
elif "error" in sampling_context["transaction_context"]["name"]:
# These are important - take a big sample
return 1.0
elif sampling_context["parent_sampled"] is True:
# These aren't something worth tracking - drop all transactions like this
return 0.0
else:
# Default sample rate
return 0.05
sentry_sdk.init(
dsn=os.getenv("SENTRY_DSN"),
environment=os.getenv("ENVIRONMENT", "local"),
traces_sampler=traces_sampler,
)
app = FastAPI(docs_url="/swagger", redoc_url=None)
title = "Nowcasting PV Site API"
folder = os.path.dirname(os.path.abspath(__file__))
description = """
Description of PV Site API
"""
origins = os.getenv("ORIGINS", "*").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# name the api
# test that the routes are there on swagger
# Following on from #1 now will be good to set out models
# User story
# get list of sites using 'get_sites'
# for each site get the forecast using 'get_forecast'
# Could look at 'get_forecast_metadata' to see when this forecast is made
# get_sites: Clients get the site id that are available to them
@app.get("/sites", response_model=PVSites)
def get_sites(
session: Session = Depends(get_session),
):
"""
### This route returns a list of the user's PV Sites with metadata for each site.
"""
if int(os.environ["FAKE"]):
return make_fake_site()
sites = get_all_sites(session=session)
assert len(sites) > 0
pv_sites = []
for site in sites:
pv_sites.append(site_to_pydantic(site))
return PVSites(site_list=pv_sites)
# post_pv_actual: sends data to us, and we save to database
@app.post("/sites/{site_uuid}/pv_actual")
def post_pv_actual(
site_uuid: str,
pv_actual: MultiplePVActual,
session: Session = Depends(get_session),
):
"""### This route is used to input actual PV generation.
Users will upload actual PV generation
readings at regular intervals throughout a given day.
Currently this route does not return anything.
"""
if int(os.environ["FAKE"]):
print(f"Got {pv_actual.dict()} for site {site_uuid}")
print("Not doing anything with it (yet!)")
return
generations = []
for pv_actual_value in pv_actual.pv_actual_values:
generations.append(
{
"start_utc": pv_actual_value.datetime_utc,
"power_kw": pv_actual_value.actual_generation_kw,
"site_uuid": site_uuid,
}
)
generation_values_df = pd.DataFrame(generations)
logger.debug(f"Adding {len(generation_values_df)} generation values")
insert_generation_values(session, generation_values_df)
session.commit()
# Comment this out, until we have security on this
# # put_site_info: client can update a site
# @app.put("/sites/{site_uuid}")
# def put_site_info(site_info: PVSiteMetadata):
# """
# ### This route allows a user to update site information for a single site.
#
# """
#
# if int(os.environ["FAKE"]):
# print(f"Successfully updated {site_info.dict()} for site {site_info.client_site_name}")
# print("Not doing anything with it (yet!)")
# return
#
# raise Exception(NotImplemented)
@app.post("/sites")
def post_site_info(site_info: PVSiteMetadata, session: Session = Depends(get_session)):
"""
### This route allows a user to add a site.
"""
if int(os.environ["FAKE"]):
print(f"Successfully added {site_info.dict()} for site {site_info.client_site_name}")
print("Not doing anything with it (yet!)")
return
# client uuid from name
client = session.query(ClientSQL).first()
assert client is not None
site = SiteSQL(
site_uuid=site_info.site_uuid,
client_uuid=client.client_uuid,
client_site_id=site_info.client_site_id,
client_site_name=site_info.client_site_name,
region=site_info.region,
dno=site_info.dno,
gsp=site_info.gsp,
orientation=site_info.orientation,
tilt=site_info.tilt,
latitude=site_info.latitude,
longitude=site_info.longitude,
capacity_kw=site_info.installed_capacity_kw,
ml_id=1, # TODO remove this once https://github.com/openclimatefix/pvsite-datamodel/issues/27 is complete # noqa
)
# add site
session.add(site)
session.commit()
# get_pv_actual: the client can read pv data from the past
@app.get("/sites/{site_uuid}/pv_actual", response_model=MultiplePVActual)
def get_pv_actual(site_uuid: str, session: Session = Depends(get_session)):
"""### This route returns PV readings from a single PV site.
Currently the route is set to provide a reading
every hour for the previous 24-hour period.
To test the route, you can input any number for the site_uuid (ex. 567)
to generate a list of datetimes and actual kw generation for that site.
"""
return (get_pv_actual_many_sites(site_uuid, session))[0]
@app.get("/sites/pv_actual", response_model=list[MultiplePVActual])
def get_pv_actual_many_sites(
site_uuids: str,
session: Session = Depends(get_session),
):
"""
### Get the actual power generation for a list of sites.
"""
site_uuids_list = site_uuids.split(",")
if int(os.environ["FAKE"]):
return [make_fake_pv_generation(site_uuid) for site_uuid in site_uuids_list]
start_utc = get_yesterday_midnight()
return get_generation_by_sites(session, site_uuids=site_uuids_list, start_utc=start_utc)
# get_forecast: Client gets the forecast for their site
@app.get("/sites/{site_uuid}/pv_forecast", response_model=Forecast)
def get_pv_forecast(site_uuid: str, session: Session = Depends(get_session)):
"""
### This route is where you might say the magic happens.
The user receives a forecast for their PV site.
The forecast is attached to the **site_uuid** and
provides a list of forecast values with a
**target_date_time_utc** and **expected_generation_kw**
reading every half-hour 8-hours into the future.
You can currently input any number for **site_uuid** (ex. 567),
and the route returns a sample forecast.
"""
if int(os.environ.get("FAKE", 0)):
return make_fake_forecast(fake_site_uuid)
site_exists = does_site_exist(session, site_uuid)
if not site_exists:
raise HTTPException(status_code=404)
forecasts = get_pv_forecast_many_sites(site_uuid, session)
if len(forecasts) == 0:
return JSONResponse(status_code=204, content="no data")
return forecasts[0]
@app.get("/sites/pv_forecast")
def get_pv_forecast_many_sites(
site_uuids: str,
session: Session = Depends(get_session),
):
"""
### Get the forecasts for multiple sites.
"""
if int(os.environ.get("FAKE", 0)):
return [make_fake_forecast(fake_site_uuid)]
start_utc = get_yesterday_midnight()
site_uuids_list = site_uuids.split(",")
forecasts = get_forecasts_by_sites(
session, site_uuids=site_uuids_list, start_utc=start_utc, horizon_minutes=0
)
return forecasts
@app.get("/sites/{site_uuid}/clearsky_estimate", response_model=ClearskyEstimate)
def get_pv_estimate_clearsky(site_uuid: str, session: Session = Depends(get_session)):
"""
### Gets a estimate of AC production under a clear sky
"""
if int(os.environ["FAKE"]):
fake_sites = make_fake_site()
site = fake_sites.site_list[0]
else:
site_exists = does_site_exist(session, site_uuid)
if not site_exists:
raise HTTPException(status_code=404)
site = site_to_pydantic(get_site_by_uuid(session, site_uuid))
loc = location.Location(site.latitude, site.longitude)
# Create DatetimeIndex over four days, with a frequency of 15 minutes.
# Starts from midnight yesterday.
times = pd.date_range(start=get_yesterday_midnight(), periods=384, freq="15min", tz="UTC")
clearsky = loc.get_clearsky(times)
solar_position = loc.get_solarposition(times=times)
# Using default tilt of 0 and orientation of 180 from defaults of PVSystem
tilt = site.tilt if site.tilt is not None else 0
orientation = site.orientation if site.orientation is not None else 180
irr = irradiance.get_total_irradiance(
surface_tilt=tilt,
surface_azimuth=orientation,
dni=clearsky["dni"],
ghi=clearsky["ghi"],
dhi=clearsky["dhi"],
solar_zenith=solar_position["apparent_zenith"],
solar_azimuth=solar_position["azimuth"],
)
# Value for "gamma_pdc" is set to the fixed temp. coeff. used in PVWatts V1
# @TODO: allow differing inverter and module capacities
# addressed in https://github.com/openclimatefix/pv-site-api/issues/54
pv_system = pvsystem.PVSystem(
surface_tilt=tilt,
surface_azimuth=orientation,
module_parameters={"pdc0": (1.5 * site.installed_capacity_kw), "gamma_pdc": -0.005},
inverter_parameters={"pdc0": site.installed_capacity_kw},
)
pac = irr.apply(
lambda row: pv_system.get_ac("pvwatts", pv_system.pvwatts_dc(row["poa_global"], 25)), axis=1
)
pac = pac.reset_index()
pac = pac.rename(columns={"index": "target_datetime_utc", 0: "clearsky_generation_kw"})
pac["target_datetime_utc"] = pac["target_datetime_utc"].dt.tz_convert(None)
res = {"clearsky_estimate": pac.to_dict("records")}
return res
@app.get("/inverters")
async def get_inverters(
session: Session = Depends(get_session),
):
if int(os.environ["FAKE"]):
return make_fake_inverters()
client = session.query(ClientSQL).first()
assert client is not None
async with httpx.AsyncClient() as httpxClient:
headers = {"Enode-User-Id": str(client.client_uuid)}
r = (
await httpxClient.get(
"https://enode-api.production.enode.io/inverters", headers=headers
)
).json()
inverter_ids = [str(value) for value in r]
return await get_inverters_list(session, inverter_ids)
@app.get("/sites/{site_uuid}/inverters")
async def get_inverters_by_site(
site_uuid: str,
session: Session = Depends(get_session),
):
if int(os.environ["FAKE"]):
return make_fake_inverters()
inverter_ids = [inverter.client_id for inverter in _get_inverters_by_site(session, site_uuid)]
return await get_inverters_list(session, inverter_ids)
# get_status: get the status of the system
@app.get("/api_status", response_model=PVSiteAPIStatus)
def get_status(session: Session = Depends(get_session)):
"""This route gets the status of the system.
It's mostly used by OCF to
make sure things are running smoothly.
"""
if os.environ["FAKE"]:
return make_fake_status()
status = get_latest_status(session=session)
status = PVSiteAPIStatus(status=status.status, message=status.message)
return status
@app.get("/")
def get_api_information():
"""
#### This route returns basic information about the Nowcasting PV Site API.
"""
logger.info("Route / has been called")
return {
"title": "Nowcasting PV Site API",
"version": pv_site_api.__version__,
"progress": "The Nowcasting PV Site API is still underconstruction.",
}
# @app.get("/favicon.ico", include_in_schema=False)
# def get_favicon() -> FileResponse:
# """Get favicon"""
# return FileResponse(f"/favicon.ico")
@app.get("/nowcasting.png", include_in_schema=False)
def get_nowcasting_logo() -> FileResponse:
"""Get logo"""
return FileResponse(f"{folder}/nowcasting.png")
@app.get("/docs", include_in_schema=False)
def redoc_html():
"""### Render ReDoc with custom theme options included"""
return get_redoc_html_with_theme(
title=title,
)
# OpenAPI (ReDoc) custom theme
def custom_openapi():
"""Make custom redoc theme"""
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=title,
version=pv_site_api.__version__,
description=description,
contact={
"name": "Nowcasting by Open Climate Fix",
"url": "https://nowcasting.io",
"email": "[email protected]",
},
license_info={
"name": "MIT License",
"url": "https://github.com/openclimatefix/nowcasting_api/blob/main/LICENSE",
},
routes=app.routes,
)
openapi_schema["info"]["x-logo"] = {"url": "/nowcasting.png"}
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
if __name__ == "__main__":
logging.basicConfig(
level=getattr(logging, os.getenv("LOGLEVEL", "DEBUG")),
format="[%(asctime)s] {%(pathname)s:%(lineno)d} %(levelname)s - %(message)s",
)