Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create inverter accessing points #80

Merged
merged 24 commits into from
Apr 21, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
anyaparekh marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"python.formatting.provider": "black"
}
16 changes: 8 additions & 8 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion pv_site_api/_db_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.is_(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.
Expand Down
27 changes: 27 additions & 0 deletions pv_site_api/fake.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

from .pydantic_models import (
Forecast,
InverterInformation,
InverterLocation,
InverterProductionState,
Inverters,
InverterValues,
MultiplePVActual,
PVActualValue,
PVSiteAPIStatus,
Expand All @@ -19,6 +24,28 @@
fake_client_uuid = "c97f68cd-50e0-49bb-a850-108d4a9f7b7e"


def make_fake_inverters() -> Inverters:
"""Make fake inverters"""
inverter = InverterValues(
id="0",
vendor="Test",
chargingLocationId="0",
lastSeen="never",
isReachable="false",
productionState=InverterProductionState(
productionRate=0.1, isProducing=False, totalLifetimeProduction=0.5, lastUpdated="never"
),
information=InverterInformation(
id="0", brand="tesla", model="x", siteName="ocf", installationDate="1-23-4567"
),
location=InverterLocation(latitude=0.0, longitude=0.1),
)
inverters_list = Inverters(
inverters=[inverter],
)
neha-vard marked this conversation as resolved.
Show resolved Hide resolved
return inverters_list


def make_fake_site() -> PVSites:
"""Make a fake site"""
pv_site = PVSiteMetadata(
Expand Down
57 changes: 57 additions & 0 deletions pv_site_api/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Main API Routes"""
import asyncio
import logging
import os

import httpx
import pandas as pd
import sentry_sdk
from dotenv import load_dotenv
Expand All @@ -19,6 +21,7 @@
import pv_site_api

from ._db_helpers import (
_get_inverters_by_site,
does_site_exist,
get_forecasts_by_sites,
get_generation_by_sites,
Expand All @@ -27,13 +30,16 @@
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,
Inverters,
InverterValues,
MultiplePVActual,
PVSiteAPIStatus,
PVSiteMetadata,
Expand Down Expand Up @@ -355,6 +361,57 @@ def get_pv_estimate_clearsky(site_uuid: str, session: Session = Depends(get_sess
return res


async def get_inverters_helper(session, inverter_ids):
if int(os.environ["FAKE"]):
return make_fake_inverters()
anyaparekh marked this conversation as resolved.
Show resolved Hide resolved

client = session.query(ClientSQL).first()
assert client is not None

async with httpx.AsyncClient() as httpxClient:
headers = {"Enode-User-Id": client.client_uuid}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

if not inverter_ids.length:
return None
anyaparekh marked this conversation as resolved.
Show resolved Hide resolved
anyaparekh marked this conversation as resolved.
Show resolved Hide resolved
inverters_raw = await asyncio.gather(
anyaparekh marked this conversation as resolved.
Show resolved Hide resolved
*[
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]
anyaparekh marked this conversation as resolved.
Show resolved Hide resolved

return Inverters(inverters)
anyaparekh marked this conversation as resolved.
Show resolved Hide resolved


@app.get("/inverters")
async def get_inverters(
session: Session = Depends(get_session),
):
client = session.query(ClientSQL).first()
assert client is not None

async with httpx.AsyncClient() as httpxClient:
headers = {"Enode-User-Id": 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 get_inverters_helper(session, inverter_ids)
anyaparekh marked this conversation as resolved.
Show resolved Hide resolved


@app.get("/sites/{site_uuid}/inverters")
async def get_inverters_by_site(
site_uuid: str,
session: Session = Depends(get_session),
):
inverter_ids = [inverter.inverter_uuid for inverter in _get_inverters_by_site(site_uuid)]

return get_inverters_helper(session, inverter_ids)
anyaparekh marked this conversation as resolved.
Show resolved Hide resolved


# get_status: get the status of the system
@app.get("/api_status", response_model=PVSiteAPIStatus)
def get_status(session: Session = Depends(get_session)):
Expand Down
66 changes: 66 additions & 0 deletions pv_site_api/pydantic_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,69 @@ 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: float = Field(..., description="The current production rate in kW")
isProducing: bool = Field(
..., description="Whether the solar inverter is actively producing energy or not"
)
totalLifetimeProduction: float = Field(..., description="The total lifetime production in kWh")
lastUpdated: 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: float = Field(..., description="Longitude in degrees")
latitude: 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: 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: str = 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")
17 changes: 17 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
ForecastSQL,
ForecastValueSQL,
GenerationSQL,
InverterSQL,
SiteSQL,
)
from sqlalchemy import create_engine
Expand Down Expand Up @@ -89,6 +90,22 @@ def sites(db_session, clients):
return sites


@pytest.fixture()
def inverters(db_session, sites):
"""Create some fake inverters"""
inverters = []
num_inverters = 3
for site in sites:
for j in range(num_inverters):
inverter = InverterSQL(site_uuid=site.site_uuid, client_id="test")
inverters.append(inverter)

db_session.add_all(inverters)
db_session.commit()

return inverters


@pytest.fixture()
def generations(db_session, sites):
"""Create some fake generations"""
Expand Down
19 changes: 19 additions & 0 deletions tests/test_inverters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
""" Test for main app """

from pv_site_api.pydantic_models import Inverters


def test_get_inverters_fake(client, fake):
response = client.get("/inverters")
assert response.status_code == 200

inverters = Inverters(**response.json())
assert len(inverters.inverters) > 0


# def test_get_inverters(db_session, client, forecast_values):
# response = client.get(f"/sites/{site_uuid}/clearsky_estimate")
# assert response.status_code == 200

# clearsky_estimate = ClearskyEstimate(**response.json())
# assert len(clearsky_estimate.clearsky_estimate) > 0
Copy link
Contributor

@AndrewLester AndrewLester Apr 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next, we'll want to uncomment this test (and change the endpoint in it). Here's the issue: your endpoints will end up trying to make a request to Enode, but we shouldn't do that in the test. Instead, you'll use https://pypi.org/project/pytest-httpx/ to have the Enode API requests get intercepted and swapped with fake data. Read the docs there for info, then add the library via poetry add -D pytest-httpx. You'll first need to bump the version of httpx in the pyproject.toml to 0.24.0.

Then, add this code to conftest.py:

@pytest.fixture
def non_mocked_hosts() -> list:
    return ["testserver"]

Then, in your tests, add httpx_mock as a parameter (to the non fake ones) and use it to mock a Enode request:

httpx_mock.add_response(url="https://enode-api.production.enode.io/inverters")

To actually set the data in the mocked response, follow the docs for the library.