From cc218689689523f38c8aaa0f3b938de8fdf3e0d5 Mon Sep 17 00:00:00 2001 From: Tim Orme Date: Sun, 16 Apr 2023 10:06:23 -0700 Subject: [PATCH] Update graphs --- aqimon/server.py | 27 +++++++--- elm/src/Graph.elm | 71 ++++++++++++++++++++++---- elm/src/Main.elm | 127 ++++++++++++++++++++++++++++++++++++---------- 3 files changed, 182 insertions(+), 43 deletions(-) diff --git a/aqimon/server.py b/aqimon/server.py index efff3d6..914a4af 100644 --- a/aqimon/server.py +++ b/aqimon/server.py @@ -8,7 +8,17 @@ import databases from pathlib import Path from datetime import datetime, timedelta -from .database import get_all_reads, add_read, add_epa_read, get_averaged_reads, create_tables, clean_old, ReadLogEntry +from .database import ( + get_all_reads, + get_all_epa_aqis, + add_read, + add_epa_read, + get_averaged_reads, + create_tables, + clean_old, + ReadLogEntry, + EpaAqiLogEntry, +) from .read import AqiRead, Reader from .read.mock import MockReader from .read.novapm import NovaPmReader @@ -164,9 +174,12 @@ async def read_function() -> None: await repeater(read_function)() -def convert_all_to_view_dict(results: List[ReadLogEntry]): +def convert_all_to_view_dict(reads: List[ReadLogEntry], epas: List[EpaAqiLogEntry]): """Convert data result to dictionary for view.""" - view = [{"t": int(x.event_time.timestamp()), "epa": 123.0, "pm25": x.pm25, "pm10": x.pm10} for x in results] + view = { + "reads": [{"t": int(x.event_time.timestamp()), "pm25": x.pm25, "pm10": x.pm10} for x in reads], + "epas": [{"t": int(x.event_time.timestamp()), "epa": x.epa_aqi} for x in epas], + } return view @@ -190,10 +203,12 @@ async def all_data( elif window == "week": window_delta = timedelta(weeks=1) if window_delta: - all_stats = await get_all_reads(database, datetime.now() - window_delta) + all_reads = await get_all_reads(database, datetime.now() - window_delta) + all_epas = await get_all_epa_aqis(database, datetime.now() - window_delta) else: - all_stats = await get_all_reads(database, None) - all_json = convert_all_to_view_dict(all_stats) + all_reads = await get_all_reads(database, None) + all_epas = await get_all_epa_aqis(database, None) + all_json = convert_all_to_view_dict(all_reads, all_epas) return all_json diff --git a/elm/src/Graph.elm b/elm/src/Graph.elm index d3b58a3..db9439a 100644 --- a/elm/src/Graph.elm +++ b/elm/src/Graph.elm @@ -9,31 +9,46 @@ import Html.Attributes exposing (style) import Time exposing (..) -{-| A graph model, including all data for the graph, as well as the current hover state. +{-| A graph model, including all data for the read graph, as well as the current hover state. -} -type alias GraphModel = - { graphData : List GraphData - , currentHover : List (CI.One GraphData CI.Dot) +type alias GraphReadModel = + { graphData : List GraphReadData + , currentHover : List (CI.One GraphReadData CI.Dot) + } + + +{-| A graph model, including all data for the EPA graph, as well as the current hover state. +-} +type alias GraphEpaModel = + { graphData : List GraphEpaData + , currentHover : List (CI.One GraphEpaData CI.Dot) } {-| Graph data, representing a point on the graph. -} -type alias GraphData = +type alias GraphReadData = { time : Float - , epa : Float , pm25 : Float , pm10 : Float } +{-| Graph data, representing a point on the graph. +-} +type alias GraphEpaData = + { time : Float + , epa : Float + } + + {-| Get a chart of read data. Accepts a model of graph data, and an event that occurs on graph hover. -} -getChart : GraphModel -> (List (CI.One GraphData CI.Dot) -> msg) -> Html msg -getChart graphModel onHover = +getReadChart : GraphReadModel -> (List (CI.One GraphReadData CI.Dot) -> msg) -> Html msg +getReadChart graphModel onHover = C.chart [ CA.height 300 , CA.width 1000 @@ -43,8 +58,7 @@ getChart graphModel onHover = [ C.xLabels [ CA.moveDown 25, CA.withGrid, CA.rotate 60, CA.format formatTime ] , C.yLabels [ CA.withGrid ] , C.series .time - [ C.interpolated .epa [ CA.monotone, CA.color CA.blue ] [ CA.circle, CA.size 3 ] |> C.named "EPA" - , C.interpolated .pm25 [ CA.monotone, CA.color CA.yellow ] [ CA.circle, CA.size 3 ] |> C.named "PM2.5" + [ C.interpolated .pm25 [ CA.monotone, CA.color CA.yellow ] [ CA.circle, CA.size 3 ] |> C.named "PM2.5" , C.interpolated .pm10 [ CA.monotone, CA.color CA.red ] [ CA.circle, CA.size 3 ] |> C.named "PM10" ] graphModel.graphData @@ -66,6 +80,43 @@ getChart graphModel onHover = ] +{-| Get a chart of read data. + +Accepts a model of graph data, and an event that occurs on graph hover. + +-} +getEpaChart : GraphEpaModel -> (List (CI.One GraphEpaData CI.Dot) -> msg) -> Html msg +getEpaChart graphModel onHover = + C.chart + [ CA.height 300 + , CA.width 1000 + , CE.onMouseMove onHover (CE.getNearest CI.dots) + , CE.onMouseLeave (onHover []) + ] + [ C.xLabels [ CA.moveDown 25, CA.withGrid, CA.rotate 60, CA.format formatTime ] + , C.yLabels [ CA.withGrid ] + , C.series .time + [ C.interpolated .epa [ CA.monotone, CA.color CA.blue ] [ CA.circle, CA.size 3 ] |> C.named "EPA" + ] + graphModel.graphData + , C.each graphModel.currentHover <| + \p item -> + [ C.tooltip item [] [] [] ] + , C.legendsAt .min + .max + [ CA.row -- Appear as column instead of row + , CA.alignLeft -- Anchor legends to the right + , CA.spacing 5 -- Spacing between legends + , CA.background "Azure" -- Color background + , CA.border "gray" -- Add border + , CA.borderWidth 1 -- Set border width + , CA.htmlAttrs [ style "padding" "0px 4px" ] + ] + [ CA.fontSize 12 -- Change font size + ] + ] + + {-| Format a unix timestamp as a string like MM/DD HH:MM:SS -} formatTime : Float -> String diff --git a/elm/src/Main.elm b/elm/src/Main.elm index 3a28df2..aa44e28 100644 --- a/elm/src/Main.elm +++ b/elm/src/Main.elm @@ -10,10 +10,10 @@ import Browser import Chart.Item as CI import DeviceStatus as DS exposing (..) import Graph as G exposing (..) -import Html exposing (Attribute, Html, div, h1, h5, text) +import Html exposing (Attribute, Html, div, h1, h2, h5, text) import Html.Attributes exposing (class, style) import Http -import Json.Decode exposing (Decoder, andThen, fail, field, float, int, list, map3, map4, maybe, string, succeed) +import Json.Decode exposing (Decoder, andThen, fail, field, float, int, list, map2, map3, map4, maybe, string, succeed) import Task import Time exposing (..) @@ -42,16 +42,42 @@ type WindowDuration | Week +{-| Lastest data point +-} +type alias LatestData = + { readTime : Float + , pm25 : Float + , pm10 : Float + , epaTime : Float + , epa : Float + } + + {-| Read data from the device -} type alias ReadData = { time : Float - , epa : Float , pm25 : Float , pm10 : Float } +{-| EPA AQI data +-} +type alias EpaData = + { time : Float + , epa : Float + } + + +{-| All data wrapper +-} +type alias AllData = + { reads : List ReadData + , epas : List EpaData + } + + {-| Hard error model. In cases where we have unexpected failures. -} type alias ErrorData = @@ -67,11 +93,13 @@ type alias Model = { currentTime : Maybe Posix , lastStatusPoll : Maybe Posix , readerState : DeviceInfo - , lastReads : ReadData + , lastReads : LatestData , allReads : List ReadData + , allEpas : List EpaData , windowDuration : WindowDuration , dataLoading : Bool - , hovering : List (CI.One ReadData CI.Dot) + , hoveringReads : List (CI.One ReadData CI.Dot) + , hoveringEpas : List (CI.One EpaData CI.Dot) , errorData : ErrorData } @@ -83,11 +111,13 @@ init _ = ( { currentTime = Nothing , lastStatusPoll = Nothing , readerState = { state = Idle, lastException = Nothing, currentTime = Nothing, nextSchedule = Nothing } - , lastReads = { time = 0, epa = 0, pm25 = 0.0, pm10 = 0.0 } + , lastReads = { readTime = 0, pm25 = 0.0, pm10 = 0.0, epaTime = 0, epa = 0.0 } , allReads = [] + , allEpas = [] , windowDuration = Hour , dataLoading = True - , hovering = [] + , hoveringReads = [] + , hoveringEpas = [] , errorData = { hasError = False, errorTitle = "", errorMessage = "" } } , Task.perform FetchData Time.now @@ -115,7 +145,7 @@ getData windowDuration = in Http.get { url = "/api/sensor_data?window=" ++ stringDuration - , expect = Http.expectJson GotData dataDecoder + , expect = Http.expectJson GotData allDataDecoder } @@ -136,10 +166,11 @@ getStatus = type Msg = FetchData Posix | FetchStatus Posix - | GotData (Result Http.Error (List ReadData)) + | GotData (Result Http.Error AllData) | GotStatus (Result Http.Error DeviceInfoResponse) | ChangeWindow WindowDuration - | OnHover (List (CI.One ReadData CI.Dot)) + | OnReadHover (List (CI.One ReadData CI.Dot)) + | OnEpaHover (List (CI.One EpaData CI.Dot)) | Tick Posix @@ -152,7 +183,7 @@ update msg model = -- On Data received case result of Ok data -> - ( { model | lastReads = getLastListItem data, allReads = data, errorData = { hasError = False, errorTitle = "", errorMessage = "" } }, Cmd.none ) + ( { model | lastReads = getLastListItem data, allReads = data.reads, allEpas = data.epas, errorData = { hasError = False, errorTitle = "", errorMessage = "" } }, Cmd.none ) Err e -> ( { model | errorData = { hasError = True, errorTitle = "Failed to retrieve read data", errorMessage = errorToString e } }, Cmd.none ) @@ -185,8 +216,13 @@ update msg model = -- Window duration changed ( { model | windowDuration = window }, Task.perform FetchData Time.now ) - OnHover hovering -> - ( { model | hovering = hovering }, Cmd.none ) + OnReadHover hovering -> + -- Hover over a datapoint on the read graph + ( { model | hoveringReads = hovering }, Cmd.none ) + + OnEpaHover hovering -> + -- Hover over a datapoint on the EPA graph + ( { model | hoveringEpas = hovering }, Cmd.none ) Tick newTime -> let @@ -265,10 +301,18 @@ view model = ] ] ] - , Grid.row [ Row.attrs [ style "padding-top" "1em" ], Row.centerMd ] + , Grid.row [] [ Grid.col [] [ h2 [] [ text "EPA AQI" ] ] ] + , Grid.row [ Row.attrs [ style "padding-top" "1em", style "padding-bottom" "3em" ], Row.centerMd ] [ Grid.col [ Col.lg ] [ div [ style "height" "400px" ] - [ G.getChart { graphData = model.allReads, currentHover = model.hovering } OnHover ] + [ G.getEpaChart { graphData = model.allEpas, currentHover = model.hoveringEpas } OnEpaHover ] + ] + ] + , Grid.row [] [ Grid.col [] [ h2 [] [ text "PM2.5/PM10 Reads" ] ] ] + , Grid.row [ Row.attrs [ style "padding-top" "1em", style "padding-bottom" "3em" ], Row.centerMd ] + [ Grid.col [ Col.lg ] + [ div [ style "height" "400px" ] + [ G.getReadChart { graphData = model.allReads, currentHover = model.hoveringReads } OnReadHover ] ] ] ] @@ -319,17 +363,34 @@ viewBigNumber value numberType = {-| Decoder function for JSON read data -} -dataDecoder : Decoder (List ReadData) -dataDecoder = +readDataDecoder : Decoder (List ReadData) +readDataDecoder = list - (map4 ReadData + (map3 ReadData (field "t" float) - (field "epa" float) (field "pm25" float) (field "pm10" float) ) +{-| Decoder function for JSON epa data +-} +epaDataDecoder : Decoder (List EpaData) +epaDataDecoder = + list + (map2 EpaData + (field "t" float) + (field "epa" float) + ) + + +allDataDecoder : Decoder AllData +allDataDecoder = + map2 AllData + (field "reads" readDataDecoder) + (field "epas" epaDataDecoder) + + type alias DeviceInfoResponse = { readerStatus : DS.DeviceState , readerException : Maybe String @@ -380,14 +441,26 @@ getLastListItem [ ] = [{time = 3, epa = 3, pm25 = 3, pm 10 = 3}] -} -getLastListItem : List ReadData -> ReadData -getLastListItem myList = - case List.head (List.reverse myList) of - Just a -> - a - - Nothing -> - { time = 0, epa = 0, pm25 = 0, pm10 = 0 } +getLastListItem : AllData -> LatestData +getLastListItem allData = + let + lastReads = + case List.head (List.reverse allData.reads) of + Just a -> + a + + Nothing -> + { time = 0, pm25 = 0, pm10 = 0 } + + lastEpas = + case List.head (List.reverse allData.epas) of + Just a -> + a + + Nothing -> + { time = 0, epa = 0 } + in + { readTime = lastReads.time, pm25 = lastReads.pm25, pm10 = lastReads.pm10, epaTime = lastEpas.time, epa = lastEpas.epa } {-| Convert HTTP error to a string.