diff --git a/poetry.lock b/poetry.lock index 29a6474..53a611c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -515,14 +515,14 @@ numpy = ">=1.14.5" [[package]] name = "httpcore" -version = "0.16.3" +version = "0.17.0" description = "A minimal low-level HTTP client." category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"}, - {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"}, + {file = "httpcore-0.17.0-py3-none-any.whl", hash = "sha256:0fdfea45e94f0c9fd96eab9286077f9ff788dd186635ae61b312693e4d943599"}, + {file = "httpcore-0.17.0.tar.gz", hash = "sha256:cc045a3241afbf60ce056202301b4d8b6af08845e3294055eb26b09913ef903c"}, ] [package.dependencies] @@ -591,25 +591,25 @@ test = ["Cython (>=0.29.24,<0.30.0)"] [[package]] name = "httpx" -version = "0.23.3" +version = "0.24.0" description = "The next generation HTTP client." category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"}, - {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, + {file = "httpx-0.24.0-py3-none-any.whl", hash = "sha256:447556b50c1921c351ea54b4fe79d91b724ed2b027462ab9a329465d147d5a4e"}, + {file = "httpx-0.24.0.tar.gz", hash = "sha256:507d676fc3e26110d41df7d35ebd8b3b8585052450f4097401c9be59d928c63e"}, ] [package.dependencies] certifi = "*" -httpcore = ">=0.15.0,<0.17.0" -rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +httpcore = ">=0.15.0,<0.18.0" +idna = "*" sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] -cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] +cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (>=1.0.0,<2.0.0)"] @@ -1193,6 +1193,25 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +[[package]] +name = "pytest-httpx" +version = "0.22.0" +description = "Send responses to httpx." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest_httpx-0.22.0-py3-none-any.whl", hash = "sha256:cefb7dcf66a4cb0601b0de05e576cca423b6081f3245e7912a4d84c58fa3eae8"}, + {file = "pytest_httpx-0.22.0.tar.gz", hash = "sha256:3a82797f3a9a14d51e8c6b7fa97524b68b847ee801109c062e696b4744f4431c"}, +] + +[package.dependencies] +httpx = ">=0.24.0,<0.25.0" +pytest = ">=6.0,<8.0" + +[package.extras] +testing = ["pytest-asyncio (>=0.20.0,<0.21.0)", "pytest-cov (>=4.0.0,<5.0.0)"] + [[package]] name = "python-dateutil" version = "2.8.2" @@ -1331,24 +1350,6 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] -[[package]] -name = "rfc3986" -version = "1.5.0" -description = "Validating URI References per RFC 3986" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, - {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, -] - -[package.dependencies] -idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} - -[package.extras] -idna2008 = ["idna"] - [[package]] name = "ruff" version = "0.0.253" @@ -1985,4 +1986,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "3f3f08a29e02d314d73afb60d14cf0fddd2f1f474721787d05354162812d7984" +content-hash = "8ab5d4f3fe5ce9195ff51416052fa5c74112a6bb6974fc5a6136fabbae8ba426" diff --git a/pv_site_api/_db_helpers.py b/pv_site_api/_db_helpers.py index c1fd83a..e4d418b 100644 --- a/pv_site_api/_db_helpers.py +++ b/pv_site_api/_db_helpers.py @@ -13,7 +13,7 @@ import sqlalchemy as sa from pvsite_datamodel.read.generation import get_pv_generation_by_sites -from pvsite_datamodel.sqlmodels import ForecastSQL, ForecastValueSQL, SiteSQL +from pvsite_datamodel.sqlmodels import ForecastSQL, ForecastValueSQL, InverterSQL, SiteSQL from sqlalchemy.orm import Session, aliased from .pydantic_models import ( @@ -55,6 +55,12 @@ def _get_forecasts_for_horizon( return list(session.execute(stmt)) +def _get_inverters_by_site(session: Session, site_uuid: str) -> list[Row]: + query = session.query(InverterSQL).filter(InverterSQL.site_uuid == site_uuid) + + return query.all() + + def _get_latest_forecast_by_sites(session: Session, site_uuids: list[str]) -> list[Row]: """Get the latest forecast for given site uuids.""" # Get the latest forecast for each site. diff --git a/pv_site_api/fake.py b/pv_site_api/fake.py index e5511f1..4d26b98 100644 --- a/pv_site_api/fake.py +++ b/pv_site_api/fake.py @@ -6,6 +6,11 @@ from .pydantic_models import ( Forecast, + InverterInformation, + InverterLocation, + InverterProductionState, + Inverters, + InverterValues, MultiplePVActual, PVActualValue, PVSiteAPIStatus, @@ -19,6 +24,35 @@ fake_client_uuid = "c97f68cd-50e0-49bb-a850-108d4a9f7b7e" +def make_fake_inverters() -> Inverters: + """Make fake inverters""" + inverter = InverterValues( + id="string", + vendor="EMA", + chargingLocationId="8d90101b-3f2f-462a-bbb4-1ed320d33bbe", + lastSeen="2020-04-07T17:04:26Z", + isReachable=True, + productionState=InverterProductionState( + productionRate=0, + isProducing=True, + totalLifetimeProduction=100152.56, + lastUpdated="2020-04-07T17:04:26Z", + ), + information=InverterInformation( + id="string", + brand="EMA", + model="Sunny Boy", + siteName="Sunny Plant", + installationDate="2020-04-07T17:04:26Z", + ), + location=InverterLocation(latitude=10.7197486, longitude=59.9173985), + ) + inverters_list = Inverters( + inverters=[inverter], + ) + return inverters_list + + def make_fake_site() -> PVSites: """Make a fake site""" pv_site = PVSiteMetadata( diff --git a/pv_site_api/main.py b/pv_site_api/main.py index 1a51a7f..efdfd58 100644 --- a/pv_site_api/main.py +++ b/pv_site_api/main.py @@ -2,6 +2,7 @@ import logging import os +import httpx import pandas as pd import sentry_sdk from dotenv import load_dotenv @@ -19,6 +20,7 @@ import pv_site_api from ._db_helpers import ( + _get_inverters_by_site, does_site_exist, get_forecasts_by_sites, get_generation_by_sites, @@ -27,6 +29,7 @@ from .fake import ( fake_site_uuid, make_fake_forecast, + make_fake_inverters, make_fake_pv_generation, make_fake_site, make_fake_status, @@ -41,7 +44,7 @@ ) from .redoc_theme import get_redoc_html_with_theme from .session import get_session -from .utils import get_yesterday_midnight +from .utils import get_inverters_list, get_yesterday_midnight load_dotenv() @@ -355,6 +358,41 @@ def get_pv_estimate_clearsky(site_uuid: str, session: Session = Depends(get_sess 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)): diff --git a/pv_site_api/pydantic_models.py b/pv_site_api/pydantic_models.py index 1e7b0e0..d432f19 100644 --- a/pv_site_api/pydantic_models.py +++ b/pv_site_api/pydantic_models.py @@ -109,3 +109,71 @@ class ClearskyEstimate(BaseModel): clearsky_estimate: List[ClearskyEstimateValues] = Field( ..., description="List of times and clearsky estimate" ) + + +class InverterProductionState(BaseModel): + """Production State data for an inverter""" + + productionRate: Optional[float] = Field(..., description="The current production rate in kW") + isProducing: Optional[bool] = Field( + ..., description="Whether the solar inverter is actively producing energy or not" + ) + totalLifetimeProduction: Optional[float] = Field( + ..., description="The total lifetime production in kWh" + ) + lastUpdated: Optional[str] = Field( + ..., description="ISO8601 UTC timestamp of last received production state update" + ) + + +class InverterInformation(BaseModel): + """ "Inverter information""" + + id: str = Field(..., description="Solar inverter vendor ID") + brand: str = Field(..., description="Solar inverter brand") + model: str = Field(..., description="Solar inverter model") + siteName: str = Field( + ..., + description="Name of the site, as set by the user on the device/vendor.", + ) + installationDate: str = Field(..., description="Solar inverter installation date") + + +class InverterLocation(BaseModel): + """ "Longitude and Latitude of inverter""" + + longitude: Optional[float] = Field(..., description="Longitude in degrees") + latitude: Optional[float] = Field(..., description="Latitude in degrees") + + +class InverterValues(BaseModel): + """Inverter Data for a site""" + + id: str = Field(..., description="Solar Inverter ID") + vendor: str = Field( + ..., description="One of EMA ENPHASE FRONIUS GOODWE GROWATT HUAWEI SMA SOLAREDGE SOLIS" + ) + chargingLocationId: Optional[str] = Field( + ..., + description="ID of the charging location the solar inverter is currently positioned at.", + ) + lastSeen: str = Field( + ..., description="The last time the solar inverter was successfully communicated with" + ) + isReachable: bool = Field( + ..., + description="Whether live data from the solar inverter is currently reachable from Enode.", + ) + productionState: InverterProductionState = Field( + ..., description="Descriptive information about the production state" + ) + information: InverterInformation = Field( + ..., description="Descriptive information about the solar inverter" + ) + location: InverterLocation = Field(..., description="Solar inverter's GPS coordinates") + + +class Inverters(BaseModel): + """Return all Inverter Data""" + + inverters: List[InverterValues] = Field(..., description="List of inverter data") diff --git a/pv_site_api/utils.py b/pv_site_api/utils.py index 149d46c..5a81766 100644 --- a/pv_site_api/utils.py +++ b/pv_site_api/utils.py @@ -1,14 +1,39 @@ """ make fake intensity""" +import asyncio import math from datetime import datetime, timedelta, timezone from typing import List +import httpx +from pvsite_datamodel.sqlmodels import ClientSQL + +from .pydantic_models import Inverters, InverterValues + TOTAL_MINUTES_IN_ONE_DAY = 24 * 60 +async def get_inverters_list(session, inverter_ids): + client = session.query(ClientSQL).first() + assert client is not None + + async with httpx.AsyncClient() as httpxClient: + headers = {"Enode-User-Id": str(client.client_uuid)} + inverters_raw = await asyncio.gather( + *[ + httpxClient.get( + f"https://enode-api.production.enode.io/inverters/{id}", headers=headers + ) + for id in inverter_ids + ] + ) + inverters = [InverterValues(**(inverter_raw.json())) for inverter_raw in inverters_raw] + + return Inverters(inverters=inverters) + + def make_fake_intensity(datetime_utc: datetime) -> float: """ - Make a fake intesnity value based on the time of the day + Make a fake intensity value based on the time of the day :param datetime_utc: :return: intensity, between 0 and 1 diff --git a/pyproject.toml b/pyproject.toml index 20bd750..773b965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ psycopg2-binary = "^2.9.5" sqlalchemy = "^1.4.46" pvsite-datamodel = "^0.1.33" fastapi = "^0.92.0" -httpx = "^0.23.3" +httpx = "^0.24.0" sentry-sdk = "^1.16.0" pvlib = "^0.9.5" @@ -26,6 +26,7 @@ pytest = "^7.2.1" pytest-cov = "^4.0.0" testcontainers-postgres = "^0.0.1rc1" ipython = "^8.11.0" +pytest-httpx = "^0.22.0" [build-system] requires = ["poetry-core"] diff --git a/tests/conftest.py b/tests/conftest.py index 3996622..8c9d5d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ ForecastSQL, ForecastValueSQL, GenerationSQL, + InverterSQL, SiteSQL, ) from sqlalchemy import create_engine @@ -20,6 +21,11 @@ from pv_site_api.session import get_session +@pytest.fixture() +def non_mocked_hosts(): + return ["testserver"] + + @pytest.fixture(scope="session") def engine(): """Make database engine""" @@ -89,6 +95,21 @@ def sites(db_session, clients): return sites +@pytest.fixture() +def inverters(db_session, sites): + """Create some fake inverters for site 0""" + inverters = [] + num_inverters = 3 + for j in range(num_inverters): + inverter = InverterSQL(site_uuid=sites[0].site_uuid, client_id=f"id{j+1}") + inverters.append(inverter) + + db_session.add_all(inverters) + db_session.commit() + + return inverters + + @pytest.fixture() def generations(db_session, sites): """Create some fake generations""" diff --git a/tests/test_inverters.py b/tests/test_inverters.py new file mode 100644 index 0000000..a706434 --- /dev/null +++ b/tests/test_inverters.py @@ -0,0 +1,61 @@ +""" Test for main app """ + +from pv_site_api.pydantic_models import Inverters + + +def add_response(id, httpx_mock): + httpx_mock.add_response( + url=f"https://enode-api.production.enode.io/inverters/{id}", + json={ + "id": "string", + "vendor": "EMA", + "chargingLocationId": "8d90101b-3f2f-462a-bbb4-1ed320d33bbe", + "lastSeen": "2020-04-07T17:04:26Z", + "isReachable": True, + "productionState": { + "productionRate": 0, + "isProducing": True, + "totalLifetimeProduction": 100152.56, + "lastUpdated": "2020-04-07T17:04:26Z", + }, + "information": { + "id": "string", + "brand": "EMA", + "model": "Sunny Boy", + "siteName": "Sunny Plant", + "installationDate": "2020-04-07T17:04:26Z", + }, + "location": {"longitude": 10.7197486, "latitude": 59.9173985}, + }, + ) + + +def test_get_inverters_from_site(client, sites, inverters, httpx_mock): + add_response("id1", httpx_mock) + add_response("id2", httpx_mock) + add_response("id3", httpx_mock) + + response = client.get(f"/sites/{sites[0].site_uuid}/inverters") + assert response.status_code == 200 + + response_inverters = Inverters(**response.json()) + assert len(inverters) == len(response_inverters.inverters) + + +def test_get_inverters_fake(client, fake): + response = client.get("/inverters") + assert response.status_code == 200 + + response_inverters = Inverters(**response.json()) + assert len(response_inverters.inverters) > 0 + + +def test_get_inverters(client, httpx_mock, clients): + httpx_mock.add_response(url="https://enode-api.production.enode.io/inverters", json=["id1"]) + add_response("id1", httpx_mock) + + response = client.get("/inverters") + assert response.status_code == 200 + + inverters = Inverters(**response.json()) + assert len(inverters.inverters) > 0