Skip to content

Commit

Permalink
Add new water use summary (#117)
Browse files Browse the repository at this point in the history
* Add new water use summary.

* Address feedback
  • Loading branch information
thomaskistler authored Jan 14, 2024
1 parent 64e2d79 commit e4ef526
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 25 deletions.
134 changes: 109 additions & 25 deletions pydrawise/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .exceptions import MutationError
from .schema import (
Controller,
ControllerWaterUseSummary,
DateTime,
LocalizedValueType,
Sensor,
Expand All @@ -40,6 +41,36 @@ def _get_schema() -> GraphQLSchema:
return build_ast_schema(parse(schema_text))


def _prune_watering_report_entries(
entries: list[WateringReportEntry], start: datetime, end: datetime
) -> list[WateringReportEntry]:
"""Prune watering report entries to make sure they all fall inside the [start, end] time interval.
The call to watering() can return events outside of the provided time interval.
Filter out events that happen before or after the provided time interval.
"""
return list(
filter(
lambda entry: entry.run_event is not None
and entry.run_event.reported_start_time is not None
and entry.run_event.reported_end_time is not None
and (
(
start.timestamp()
<= entry.run_event.reported_start_time.timestamp()
<= end.timestamp()
)
or (
start.timestamp()
<= entry.run_event.reported_end_time.timestamp()
<= end.timestamp()
)
),
entries,
)
)


class Hydrawise(HydrawiseBase):
"""Client library for interacting with Hydrawise sprinkler controllers.
Expand Down Expand Up @@ -301,8 +332,8 @@ async def get_water_flow_summary(
self._schema.Controller.sensors.select(
*get_selectors(self._schema, Sensor),
self._schema.Sensor.flowSummary(
start=DateTime.to_json(start).timestamp,
end=DateTime.to_json(end).timestamp,
start=int(start.timestamp()),
end=int(end.timestamp()),
).select(*get_selectors(self._schema, SensorFlowSummary)),
),
)
Expand Down Expand Up @@ -345,28 +376,81 @@ async def get_watering_report(
),
)
result = await self._query(selector)
entries = deserialize(
list[WateringReportEntry], result["controller"]["reports"]["watering"]
return _prune_watering_report_entries(
deserialize(
list[WateringReportEntry], result["controller"]["reports"]["watering"]
),
start,
end,
)
# The call to watering() can return events outside of the provided time interval.
# Filter out events that happen before or after the provided time interval.
return list(
filter(
lambda entry: entry.run_event is not None
and entry.run_event.reported_start_time is not None
and entry.run_event.reported_end_time is not None
and (
(
start.timestamp()
<= entry.run_event.reported_start_time.timestamp()
<= end.timestamp()
)
or (
start.timestamp()
<= entry.run_event.reported_end_time.timestamp()
<= end.timestamp()
)
),
entries,
)

async def get_water_use_summary(
self, controller: Controller, start: datetime, end: datetime
) -> ControllerWaterUseSummary:
"""Calculate the water use for the given controller and time period.
:param controller: The controller whose water use to report.
:param start: Start time
:param end: End time."""
selector = self._schema.Query.controller(controllerId=controller.id).select(
self._schema.Controller.sensors.select(
*get_selectors(self._schema, Sensor),
self._schema.Sensor.flowSummary(
start=int(start.timestamp()),
end=int(end.timestamp()),
).select(*get_selectors(self._schema, SensorFlowSummary)),
),
self._schema.Controller.reports.select(
self._schema.Reports.watering(
**{
"from": int(start.timestamp()),
"until": int(end.timestamp()),
}
).select(*get_selectors(self._schema, WateringReportEntry)),
),
)
result = await self._query(selector)
summary = ControllerWaterUseSummary()

# watering report entries
entries = _prune_watering_report_entries(
deserialize(
list[WateringReportEntry], result["controller"]["reports"]["watering"]
),
start,
end,
)

# total active water use
for entry in entries:
if (
entry.run_event is not None
and entry.run_event.zone is not None
and entry.run_event.reported_water_usage is not None
):
active_use = entry.run_event.reported_water_usage.value
if summary.unit == "":
summary.unit = entry.run_event.reported_water_usage.unit
summary.total_active_use += active_use
summary.active_use_by_zone_id.setdefault(entry.run_event.zone.id, 0)
summary.active_use_by_zone_id[entry.run_event.zone.id] += active_use

# total active and inactive water use
for sensor in result["controller"]["sensors"]:
if (
"FLOW" in sensor["model"]["sensorType"]
and "flowSummary" in sensor
and (flow_summary := sensor["flowSummary"]) is not None
):
summary.total_use += flow_summary["totalWaterVolume"]["value"]
if summary.unit == "":
summary.unit = flow_summary["totalWaterVolume"]["unit"]

# Correct for inaccuracies. The watering report and flow summaries are not always
# updated with the same frequency.
if summary.total_use > summary.total_active_use:
summary.total_inactive_use = summary.total_use - summary.total_active_use
else:
summary.total_use = summary.total_active_use

return summary
25 changes: 25 additions & 0 deletions pydrawise/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,15 @@ class ControllerHardware:
firmware: list[ControllerFirmware] = field(default_factory=list)


class CustomSensorTypeEnum(AutoEnum):
"""A value for a sensor type."""

LEVEL_OPEN = auto()
LEVEL_CLOSED = auto()
FLOW = auto()
THRESHOLD = auto()


@dataclass
class SensorModel:
"""Information about a sensor model."""
Expand All @@ -477,6 +486,7 @@ class SensorModel:
)
divisor: float = 0.0
flow_rate: float = 0.0
sensor_type: Optional[CustomSensorTypeEnum] = None


@dataclass
Expand Down Expand Up @@ -629,3 +639,18 @@ class DaysOfWeekEnum(AutoEnum):
THURSDAY = auto()
FRIDAY = auto()
SATURDAY = auto()


@dataclass
class ControllerWaterUseSummary:
"""Water use summary for a controller.
Active use means water use during a scheduled or manual zone run.
Inactive use means water use when no zone was actively running. This can happen when
faucets (i.e., garden hoses) are installed downstream of the flow meter."""

total_use: float = 0.0
total_active_use: float = 0.0
total_inactive_use: float = 0.0
active_use_by_zone_id: dict[int, float] = field(default_factory=dict)
unit: str = ""
35 changes: 35 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def rain_sensor_json():
"delay": 0,
"divisor": 0,
"flowRate": 0,
"sensorType": "LEVEL_CLOSED",
},
"status": {
"waterFlow": None,
Expand All @@ -75,6 +76,7 @@ def flow_sensor_json():
"delay": 0,
"divisor": 0.52834,
"flowRate": 3.7854,
"sensorType": "FLOW",
},
"status": {
"waterFlow": {
Expand Down Expand Up @@ -551,3 +553,36 @@ async def test_get_watering_report(
assert "reports" in query
assert "watering" in query
assert len(report) == 1


@pytest.mark.parametrize("flow_summary_json", (True, False), indirect=True)
async def test_get_water_use_summary(
api: Hydrawise,
mock_session,
controller_json,
watering_report_json,
flow_sensor_json,
flow_summary_json,
):
mock_session.execute.return_value = {
"controller": {
"reports": watering_report_json,
"sensors": [flow_sensor_json | {"flowSummary": flow_summary_json}],
}
}
ctrl = deserialize(Controller, controller_json)
summary = await api.get_water_use_summary(
ctrl, datetime(2023, 12, 1, 0, 0, 0), datetime(2023, 12, 4, 0, 0, 0)
)
mock_session.execute.assert_awaited_once()
[selector] = mock_session.execute.await_args.args
query = print_ast(selector)
assert "reports" in query
assert "watering" in query
assert "flowSummary(" in query
assert summary.active_use_by_zone_id[5955343] == 34.000263855044786
assert summary.total_active_use == 34.000263855044786
assert summary.total_inactive_use == (
23100.679266065246 if flow_summary_json else 0.0
)
assert summary.unit == "gal"

0 comments on commit e4ef526

Please sign in to comment.