From 4554c2441ba8fee45a5450d5fa6295a9ae445547 Mon Sep 17 00:00:00 2001 From: Andrew Poe Date: Thu, 19 Mar 2026 09:40:24 -0400 Subject: [PATCH 1/2] feat: added the grid/meter usage/demand fields to the get_main_services query. These are needed for some solar data queries. Also updated the resource type enum so you can properly filter the service types --- contxt/generated/nionic_queries.py | 24 +++++ contxt/models/ems.py | 5 + graphql/nionic_queries.graphql | 28 +++++ tests/services/test_nionic.py | 157 +++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 tests/services/test_nionic.py diff --git a/contxt/generated/nionic_queries.py b/contxt/generated/nionic_queries.py index 530be96e..b7f9a940 100644 --- a/contxt/generated/nionic_queries.py +++ b/contxt/generated/nionic_queries.py @@ -63,6 +63,30 @@ def query_main_services(): _op_main_services_nodes_demand.name() _op_main_services_nodes_demand.alias() _op_main_services_nodes_demand.data_type() + _op_main_services_nodes_meter_usage = _op_main_services_nodes.meter_usage() + _op_main_services_nodes_meter_usage.id() + _op_main_services_nodes_meter_usage.data_source_name() + _op_main_services_nodes_meter_usage.name() + _op_main_services_nodes_meter_usage.alias() + _op_main_services_nodes_meter_usage.data_type() + _op_main_services_nodes_meter_demand = _op_main_services_nodes.meter_demand() + _op_main_services_nodes_meter_demand.id() + _op_main_services_nodes_meter_demand.data_source_name() + _op_main_services_nodes_meter_demand.name() + _op_main_services_nodes_meter_demand.alias() + _op_main_services_nodes_meter_demand.data_type() + _op_main_services_nodes_grid_usage = _op_main_services_nodes.grid_usage() + _op_main_services_nodes_grid_usage.id() + _op_main_services_nodes_grid_usage.data_source_name() + _op_main_services_nodes_grid_usage.name() + _op_main_services_nodes_grid_usage.alias() + _op_main_services_nodes_grid_usage.data_type() + _op_main_services_nodes_grid_demand = _op_main_services_nodes.grid_demand() + _op_main_services_nodes_grid_demand.id() + _op_main_services_nodes_grid_demand.data_source_name() + _op_main_services_nodes_grid_demand.name() + _op_main_services_nodes_grid_demand.alias() + _op_main_services_nodes_grid_demand.data_type() _op_main_services_nodes.created_at() _op_main_services_nodes.updated_at() return _op diff --git a/contxt/models/ems.py b/contxt/models/ems.py index 894cbe11..8ad211a0 100644 --- a/contxt/models/ems.py +++ b/contxt/models/ems.py @@ -8,9 +8,14 @@ class ResourceType(Enum): + AIRFLOW_ELECTRIC_HIGH = "airflow_electric_high" + AIRFLOW_ELECTRIC_LOW = "airflow_electric_low" + AIRFLOW_HIGH = "airflow_high" + AIRFLOW_LOW = "airflow_low" COMBINED = "combined" ELECTRIC = "electric" GAS = "gas" + SOLAR = "solar" WATER = "water" diff --git a/graphql/nionic_queries.graphql b/graphql/nionic_queries.graphql index 9f98031a..b743f916 100644 --- a/graphql/nionic_queries.graphql +++ b/graphql/nionic_queries.graphql @@ -61,6 +61,34 @@ query mainServices($facilityId: Int!) { alias dataType } + meterUsage { + id + dataSourceName + name + alias + dataType + } + meterDemand { + id + dataSourceName + name + alias + dataType + } + gridUsage { + id + dataSourceName + name + alias + dataType + } + gridDemand { + id + dataSourceName + name + alias + dataType + } createdAt updatedAt } diff --git a/tests/services/test_nionic.py b/tests/services/test_nionic.py new file mode 100644 index 00000000..b7b99fc7 --- /dev/null +++ b/tests/services/test_nionic.py @@ -0,0 +1,157 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from contxt.generated.nionic_queries import Operations as nionic_operations +from contxt.models.ems import ResourceType +from contxt.services.nionic import NionicService + + +def _make_datapoint(id, name, data_source_name="ds1", alias=None, data_type="FLOAT"): + return { + "id": id, + "dataSourceName": data_source_name, + "name": name, + "alias": alias or name, + "dataType": data_type, + } + + +def _make_main_service( + id, + facility_id, + name, + type_, + usage=None, + demand=None, + meter_usage=None, + meter_demand=None, + grid_usage=None, + grid_demand=None, +): + return { + "id": id, + "facilityId": facility_id, + "name": name, + "type": type_, + "usage": usage, + "demand": demand, + "meterUsage": meter_usage, + "meterDemand": meter_demand, + "gridUsage": grid_usage, + "gridDemand": grid_demand, + "createdAt": "2024-01-01T00:00:00", + "updatedAt": "2024-01-01T00:00:00", + } + + +ELECTRIC_SERVICE = _make_main_service( + id=1, + facility_id=100, + name="Main Electric", + type_="ELECTRIC", + usage=_make_datapoint(10, "electric_usage"), + demand=_make_datapoint(11, "electric_demand"), + meter_usage=_make_datapoint(12, "electric_meter_usage"), + meter_demand=_make_datapoint(13, "electric_meter_demand"), + grid_usage=_make_datapoint(14, "electric_grid_usage"), + grid_demand=_make_datapoint(15, "electric_grid_demand"), +) + +SOLAR_SERVICE = _make_main_service( + id=2, + facility_id=100, + name="Main Solar", + type_="SOLAR", + usage=_make_datapoint(20, "solar_usage"), + demand=_make_datapoint(21, "solar_demand"), + meter_usage=None, + meter_demand=None, + grid_usage=None, + grid_demand=None, +) + + +def _mock_run_response(services): + """Build the raw JSON dict that NionicService.run() would return from the endpoint.""" + return {"data": {"mainServices": {"nodes": services}}} + + +@pytest.fixture +def nionic_service(): + """Create a NionicService with mocked auth, bypassing real HTTP setup.""" + mock_auth = MagicMock() + mock_auth.get_token.return_value = "fake-token" + with patch.object(NionicService, "__init__", lambda self, *a, **kw: None): + svc = NionicService.__new__(NionicService) + return svc + + +class TestGetMainServices: + def test_returns_all_services(self, nionic_service): + response = _mock_run_response([ELECTRIC_SERVICE, SOLAR_SERVICE]) + with patch.object(nionic_service, "run", return_value=response): + results = nionic_service.get_main_services(facility_id=100) + + assert len(results) == 2 + assert results[0].name == "Main Electric" + assert results[1].name == "Main Solar" + + def test_all_datapoint_fields_populated(self, nionic_service): + response = _mock_run_response([ELECTRIC_SERVICE]) + with patch.object(nionic_service, "run", return_value=response): + results = nionic_service.get_main_services(facility_id=100) + + svc = results[0] + assert svc.usage.id == 10 + assert svc.demand.id == 11 + assert svc.meter_usage.id == 12 + assert svc.meter_demand.id == 13 + assert svc.grid_usage.id == 14 + assert svc.grid_demand.id == 15 + + def test_datapoint_subfields(self, nionic_service): + response = _mock_run_response([ELECTRIC_SERVICE]) + with patch.object(nionic_service, "run", return_value=response): + results = nionic_service.get_main_services(facility_id=100) + + dp = results[0].meter_usage + assert dp.data_source_name == "ds1" + assert dp.name == "electric_meter_usage" + assert dp.alias == "electric_meter_usage" + assert dp.data_type == "FLOAT" + + def test_filters_by_resource_type(self, nionic_service): + response = _mock_run_response([ELECTRIC_SERVICE, SOLAR_SERVICE]) + with patch.object(nionic_service, "run", return_value=response): + results = nionic_service.get_main_services( + facility_id=100, resource_type=ResourceType.ELECTRIC + ) + + assert len(results) == 1 + assert results[0].name == "Main Electric" + + def test_filters_by_solar_type(self, nionic_service): + response = _mock_run_response([ELECTRIC_SERVICE, SOLAR_SERVICE]) + with patch.object(nionic_service, "run", return_value=response): + results = nionic_service.get_main_services( + facility_id=100, resource_type=ResourceType.SOLAR + ) + + assert len(results) == 1 + assert results[0].name == "Main Solar" + + def test_null_datapoints(self, nionic_service): + """When meter/grid fields are null, sgqlc returns empty falsy DataPoint objects.""" + response = _mock_run_response([SOLAR_SERVICE]) + with patch.object(nionic_service, "run", return_value=response): + results = nionic_service.get_main_services(facility_id=100) + + svc = results[0] + assert svc.usage.id == 20 + assert svc.demand.id == 21 + # sgqlc deserializes null relationships as empty DataPoint objects (falsy, no attributes) + assert not svc.meter_usage + assert not svc.meter_demand + assert not svc.grid_usage + assert not svc.grid_demand From 22dc4f0fa519273462a7fc7ac5b1ef139bbc3cca Mon Sep 17 00:00:00 2001 From: Kurt Kikendall Date: Thu, 19 Mar 2026 10:30:34 -0400 Subject: [PATCH 2/2] Linting --- tests/services/test_nionic.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/services/test_nionic.py b/tests/services/test_nionic.py index b7b99fc7..11d71382 100644 --- a/tests/services/test_nionic.py +++ b/tests/services/test_nionic.py @@ -2,7 +2,6 @@ import pytest -from contxt.generated.nionic_queries import Operations as nionic_operations from contxt.models.ems import ResourceType from contxt.services.nionic import NionicService @@ -134,9 +133,7 @@ def test_filters_by_resource_type(self, nionic_service): def test_filters_by_solar_type(self, nionic_service): response = _mock_run_response([ELECTRIC_SERVICE, SOLAR_SERVICE]) with patch.object(nionic_service, "run", return_value=response): - results = nionic_service.get_main_services( - facility_id=100, resource_type=ResourceType.SOLAR - ) + results = nionic_service.get_main_services(facility_id=100, resource_type=ResourceType.SOLAR) assert len(results) == 1 assert results[0].name == "Main Solar"