From e5bb316873c46e109e8a5c0580b504a966e08d2a Mon Sep 17 00:00:00 2001 From: crosstyan Date: Tue, 11 Feb 2025 15:22:37 +0800 Subject: [PATCH] feat: Implement data visualization and export for MAX-BAND device --- .vscode/settings.json | 5 +- app/model/__init__.py | 4 ++ main.py | 129 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 120 insertions(+), 18 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index c0e8e39..75ac3fa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,7 @@ { "python.analysis.autoImportCompletions": true, - "python.analysis.typeCheckingMode": "standard" + "python.analysis.typeCheckingMode": "standard", + "cSpell.words": [ + "timescape" + ] } \ No newline at end of file diff --git a/app/model/__init__.py b/app/model/__init__.py index f457c88..25ed71c 100644 --- a/app/model/__init__.py +++ b/app/model/__init__.py @@ -67,21 +67,25 @@ class AlgoModelData(BaseModel): _FORMAT: ClassVar[LiteralString] = " float: """Heart rate in beats per minute""" return self.hr / 10.0 @computed_field + @property def spo2_f(self) -> float: """SpO2 percentage""" return self.spo2 / 10.0 @computed_field + @property def r_f(self) -> float: """SpO2 R value""" return self.r / 1000.0 @computed_field + @property def rr_f(self) -> float: """RR interval in milliseconds""" return self.rr / 10.0 diff --git a/main.py b/main.py index d787394..0efc8b8 100644 --- a/main.py +++ b/main.py @@ -31,12 +31,21 @@ from app.model import AlgoReport from collections import deque +class AppHistory(TypedDict): + timescape: deque[datetime] + hr_data: deque[float] + hr_conf: deque[int] # in % + accel_x_data: deque[int] + accel_y_data: deque[int] + accel_z_data: deque[int] + + # https://handmadesoftware.medium.com/streamlit-asyncio-and-mongodb-f85f77aea825 class AppState(TypedDict): worker_thread: Thread message_queue: MemoryObjectReceiveStream[bytes] task_group: TaskGroup - history: deque[AlgoReport] + history: AppHistory UDP_SERVER_HOST: Final[str] = "localhost" @@ -80,40 +89,126 @@ def resource(params: Any = None): "worker_thread": tr, "message_queue": rx, "task_group": unwrap(tg), - "history": deque(maxlen=MAX_LENGTH), + "history": { + "timescape": deque(maxlen=MAX_LENGTH), + "hr_data": deque(maxlen=MAX_LENGTH), + "hr_conf": deque(maxlen=MAX_LENGTH), + "accel_x_data": deque(maxlen=MAX_LENGTH), + "accel_y_data": deque(maxlen=MAX_LENGTH), + "accel_z_data": deque(maxlen=MAX_LENGTH), + }, } + logger.info("Resource created") return state def main(): state = resource() - logger.info("Resource created") history = state["history"] def on_export(): - raise NotImplementedError + file_name = f"history_{datetime.now().strftime('%Y%m%d_%H%M%S')}.parquet" + logger.info(f"Exporting to {file_name}") + rec = ak.Record(history) + ak.to_parquet(rec, file_name) def on_clear(): - raise NotImplementedError + nonlocal history + logger.info("Clearing history") + history["timescape"].clear() + history["hr_data"].clear() + history["hr_conf"].clear() + history["accel_x_data"].clear() + history["accel_y_data"].clear() + history["accel_z_data"].clear() + + # https://docs.streamlit.io/develop/api-reference/layout + st.title("MAX-BAND Visualizer") + with st.container(border=True): + c1, c2 = st.columns(2) + with c1: + st.button( + "Export", + help="Export the current data to a parquet file", + on_click=on_export, + ) + with c2: + st.button( + "Clear", + help="Clear the current data", + on_click=on_clear, + ) + + placeholder = st.empty() - st.button( - "Export", help="Export the current data to a parquet file", on_click=on_export - ) - st.button("Clear", help="Clear the current data", on_click=on_clear) - pannel = st.empty() while True: try: message = state["message_queue"].receive_nowait() except anyio.WouldBlock: continue - report = AlgoReport.unmarshal(message) - logger.info("Report: {}", report) - # TODO: plot - # fig = go.Figure(scatters) - # pannel.plotly_chart(fig) + with placeholder.container(): + report = AlgoReport.unmarshal(message) + history["timescape"].append(datetime.now()) + history["hr_data"].append(report.data.hr_f) + history["hr_conf"].append(report.data.hr_conf) + history["accel_x_data"].append(report.accel_x) + history["accel_y_data"].append(report.accel_y) + history["accel_z_data"].append(report.accel_z) + + # with st.container(): + # c1, c2 = st.columns(2) + # with c1: + # c1.write(f"HR: {report.data.hr_f}") + # with c2: + # c2.write(f"HR Confidence: {report.data.hr_conf}") + + fig_hr, fig_accel = st.tabs(["Heart Rate", "Accelerometer"]) + + with fig_hr: + fig_hr.plotly_chart( + go.Figure( + data=[ + go.Scatter( + x=list(history["timescape"]), + y=list(history["hr_data"]), + mode="lines", + name="HR", + ), + go.Scatter( + x=list(history["timescape"]), + y=list(history["hr_conf"]), + mode="lines", + name="HR Confidence", + ), + ] + ) + ) + with fig_accel: + fig_accel.plotly_chart( + go.Figure( + data=[ + go.Scatter( + x=list(history["timescape"]), + y=list(history["accel_x_data"]), + mode="lines", + name="x", + ), + go.Scatter( + x=list(history["timescape"]), + y=list(history["accel_y_data"]), + mode="lines", + name="y", + ), + go.Scatter( + x=list(history["timescape"]), + y=list(history["accel_z_data"]), + mode="lines", + name="z", + ), + ] + ) + ) if __name__ == "__main__": main() - -# 1659A202