diff --git a/.env.example b/.env.example index f90fcc3..64f507d 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,12 @@ SOMNI_MONGO_URI=mongodb://user:password@host:27017/Somni SOMNI_MONGO_DB=Somni # 答卷集合(对齐 BioNode quiz_answers 结构) SOMNI_MONGO_ANSWERS_COLLECTION=somni_quiz_answers +# 量产报告相关集合 +SOMNI_MONGO_DEVICES_COLLECTION=somni_devices +SOMNI_MONGO_TELEMETRY_COLLECTION=somni_telemetry +SOMNI_MONGO_RECORDS_COLLECTION=somni_records +SOMNI_MONGO_SLEEP_REPORTS_COLLECTION=somni_sleep_reports +SOMNI_MONGO_EVENTS_COLLECTION=somni_events # 检索参数 SIM_THRESHOLD=0.7 diff --git a/app/core/config.py b/app/core/config.py index 57d87ce..75b3210 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -53,6 +53,11 @@ class Settings(BaseSettings): somni_mongo_materials_collection: str = "somni_audio_materials" somni_mongo_tag_dictionary_collection: str = "somni_audio_tag_dictionary" somni_mongo_answers_collection: str = "somni_quiz_answers" + somni_mongo_devices_collection: str = "somni_devices" + somni_mongo_telemetry_collection: str = "somni_telemetry" + somni_mongo_records_collection: str = "somni_records" + somni_mongo_sleep_reports_collection: str = "somni_sleep_reports" + somni_mongo_events_collection: str = "somni_events" sim_threshold: float = 0.7 # 内容形态向量模糊命中阈值(规范 §五-2) # GetAudio query_text 与内容形态标签(含二级)向量相似度下限 diff --git a/app/main.py b/app/main.py index b40c2b5..e05b4f9 100644 --- a/app/main.py +++ b/app/main.py @@ -180,7 +180,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: logger.warning("未配置 SOMNI_MONGO_URI,量产问卷与音频查询将不可用") _app_state.somni_quiz_service = SomniQuizService(somni_mongo, settings) - _app_state.somni_report_service = SomniReportService() + _app_state.somni_report_service = SomniReportService(somni_mongo, settings) somni_es_client = create_es_client( settings, node=settings.effective_somni_es_node, diff --git a/app/server/somni/report/calc.py b/app/server/somni/report/calc.py new file mode 100644 index 0000000..2141958 --- /dev/null +++ b/app/server/somni/report/calc.py @@ -0,0 +1,114 @@ +"""量产报告纯计算:本地日窗口、卧床/阶段分钟、均值 floor。""" + +from __future__ import annotations + +import math +from collections.abc import Iterable +from datetime import UTC, date, datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +REPORT_TZ = ZoneInfo("Asia/Shanghai") + + +def parse_record_date(record_date: str) -> date: + return date.fromisoformat(record_date) + + +def local_day_utc_range(record_date: str) -> tuple[datetime, datetime]: + """record_date 本地日 [00:00, 次日 00:00) 转 UTC 感知时间。""" + day = parse_record_date(record_date) + start_local = datetime(day.year, day.month, day.day, tzinfo=REPORT_TZ) + end_local = start_local + timedelta(days=1) + return start_local.astimezone(UTC), end_local.astimezone(UTC) + + +def ensure_aware(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value + + +def to_report_tz(value: datetime) -> datetime: + return ensure_aware(value).astimezone(REPORT_TZ) + + +def minutes_between(start: datetime | None, end: datetime | None) -> int: + if start is None or end is None: + return 0 + delta = to_report_tz(end) - to_report_tz(start) + return max(0, math.floor(delta.total_seconds() / 60)) + + +def stage_minutes(bed_minutes: int, ratio: int | float | None) -> int: + if bed_minutes <= 0 or ratio is None: + return 0 + return math.floor(bed_minutes * float(ratio) / 100) + + +def as_int(value: Any, default: int = 0) -> int: + if value is None: + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + +def as_float(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def floor_avg(values: Iterable[float]) -> int: + nums = list(values) + if not nums: + return 0 + return math.floor(sum(nums) / len(nums)) + + +def floor_metric_stats(values: Iterable[float]) -> dict[str, int]: + nums = list(values) + if not nums: + return {"value": 0, "min": 0, "max": 0} + return { + "value": math.floor(sum(nums) / len(nums)), + "min": math.floor(min(nums)), + "max": math.floor(max(nums)), + } + + +def format_hhmm(value: datetime | None) -> str: + if value is None: + return "" + return to_report_tz(value).strftime("%H:%M") + + +def format_collected_at(value: datetime | None) -> str: + if value is None: + return "" + return to_report_tz(value).isoformat() + + +def sleep_stage_parts(raw: dict[str, Any]) -> dict[str, Any]: + bed = minutes_between(raw.get("bed_time"), raw.get("wake_up_time")) + awake_ratio = as_int(raw.get("awake_ratio")) + rem_ratio = as_int(raw.get("rem_ratio")) + light_ratio = as_int(raw.get("light_sleep_ratio")) + deep_ratio = as_int(raw.get("deep_sleep_ratio")) + awake = stage_minutes(bed, awake_ratio) + rem = stage_minutes(bed, rem_ratio) + light = stage_minutes(bed, light_ratio) + deep = stage_minutes(bed, deep_ratio) + return { + "bed_minutes": bed, + "awake": {"minutes": awake, "percent": awake_ratio}, + "rem_sleep": {"minutes": rem, "percent": rem_ratio}, + "light_sleep": {"minutes": light, "percent": light_ratio}, + "deep_sleep": {"minutes": deep, "percent": deep_ratio}, + "total_minutes": deep + light + rem, + } diff --git a/app/server/somni/report/rpc.py b/app/server/somni/report/rpc.py index 61d45a1..fbe5d68 100644 --- a/app/server/somni/report/rpc.py +++ b/app/server/somni/report/rpc.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from app.core.exceptions import ServiceNotReadyError from app.server.errors import abort_from_app_error, abort_invalid, run_rpc_call @@ -11,42 +11,38 @@ if TYPE_CHECKING: from app.server.somni.report.service import ReportService -_RPC_RES = { - "get_summary": uburnode_somni_pb2.GetSummaryRes, - "get_events": uburnode_somni_pb2.GetEventsRes, - "get_environment": uburnode_somni_pb2.GetEnvironmentRes, - "get_structure": uburnode_somni_pb2.GetStructureRes, - "get_sleep_quality": uburnode_somni_pb2.GetSleepQualityRes, -} - class ReportRpc(uburnode_somni_pb2_grpc.ReportServiceServicer): def __init__(self, service: ReportService | None) -> None: self._service = service async def GetSummary(self, request, context): - return await self._call(request, context, "get_summary") + return await self._call(request, context, "get_summary", _to_summary_res) async def GetEvents(self, request, context): - return await self._call(request, context, "get_events") + return await self._call(request, context, "get_events", _to_events_res) async def GetEnvironment(self, request, context): - return await self._call(request, context, "get_environment") + return await self._call(request, context, "get_environment", _to_environment_res) async def GetStructure(self, request, context): - return await self._call(request, context, "get_structure") + return await self._call(request, context, "get_structure", _to_structure_res) async def GetSleepQuality(self, request, context): - return await self._call(request, context, "get_sleep_quality") + return await self._call( + request, context, "get_sleep_quality", _to_sleep_quality_res + ) - async def _call(self, request, context, method_name: str): + async def _call(self, request, context, method_name: str, to_res): if not request.uid.strip() or not request.record_date.strip(): await abort_invalid(context, "uid 与 record_date 均不能为空") service = await self._require(context) async def _do(): - await getattr(service, method_name)(request.uid, request.record_date) - return _RPC_RES[method_name]() + payload = await getattr(service, method_name)( + request.uid, request.record_date + ) + return to_res(payload) return await run_rpc_call(context, _do) @@ -54,3 +50,150 @@ async def _require(self, context) -> ReportService: if self._service is None: await abort_from_app_error(context, ServiceNotReadyError()) return self._service # type: ignore[return-value] + + +def _to_summary_res(payload: dict[str, Any]) -> uburnode_somni_pb2.GetSummaryRes: + item = payload.get("sleep_summary") or {} + return uburnode_somni_pb2.GetSummaryRes( + sleep_summary=uburnode_somni_pb2.SleepSummary( + body_battery=int(item.get("body_battery") or 0), + body_battery_status=str(item.get("body_battery_status") or ""), + total_minutes=int(item.get("total_minutes") or 0), + deep_sleep_minutes=int(item.get("deep_sleep_minutes") or 0), + avg_heart_rate=int(item.get("avg_heart_rate") or 0), + avg_respiratory_rate=int(item.get("avg_respiratory_rate") or 0), + ) + ) + + +def _to_environment_res( + payload: dict[str, Any], +) -> uburnode_somni_pb2.GetEnvironmentRes: + summary = payload.get("environment_summary") or {} + return uburnode_somni_pb2.GetEnvironmentRes( + environment_summary=uburnode_somni_pb2.EnvironmentSummary( + temperature=_env_metric(summary.get("temperature")), + humidity=_env_metric(summary.get("humidity")), + illuminance=_env_metric(summary.get("illuminance")), + noise=_env_metric(summary.get("noise")), + ) + ) + + +def _env_metric(item: Any) -> uburnode_somni_pb2.EnvMetric: + data = item if isinstance(item, dict) else {} + return uburnode_somni_pb2.EnvMetric( + value=int(data.get("value") or 0), + min=int(data.get("min") or 0), + max=int(data.get("max") or 0), + ) + + +def _to_structure_res(payload: dict[str, Any]) -> uburnode_somni_pb2.GetStructureRes: + structure = payload.get("sleep_structure") or {} + return uburnode_somni_pb2.GetStructureRes( + sleep_structure=uburnode_somni_pb2.SleepStructure( + awake=_stage_part(structure.get("awake")), + rem_sleep=_stage_part(structure.get("rem_sleep")), + light_sleep=_stage_part(structure.get("light_sleep")), + deep_sleep=_stage_part(structure.get("deep_sleep")), + ) + ) + + +def _stage_part(item: Any) -> uburnode_somni_pb2.SleepStagePart: + data = item if isinstance(item, dict) else {} + return uburnode_somni_pb2.SleepStagePart( + minutes=int(data.get("minutes") or 0), + percent=int(data.get("percent") or 0), + ) + + +def _to_sleep_quality_res( + payload: dict[str, Any], +) -> uburnode_somni_pb2.GetSleepQualityRes: + item = payload.get("sleep_quality") or {} + return uburnode_somni_pb2.GetSleepQualityRes( + sleep_quality=uburnode_somni_pb2.SleepQuality( + time_in_bed_minutes=int(item.get("time_in_bed_minutes") or 0), + sleep_onset_latency_minutes=int( + item.get("sleep_onset_latency_minutes") or 0 + ), + sleep_efficiency=int(item.get("sleep_efficiency") or 0), + bedtime=str(item.get("bedtime") or ""), + wake_up_time=str(item.get("wake_up_time") or ""), + awake_after_onset_minutes=int(item.get("awake_after_onset_minutes") or 0), + ) + ) + + +def _to_events_res(payload: dict[str, Any]) -> uburnode_somni_pb2.GetEventsRes: + res = uburnode_somni_pb2.GetEventsRes( + record_date=str(payload.get("record_date") or ""), + event_count=int(payload.get("event_count") or 0), + abnormal_count=int(payload.get("abnormal_count") or 0), + intervention_count=int(payload.get("intervention_count") or 0), + ) + for item in payload.get("sleep_events") or []: + res.sleep_events.append(_sleep_event_item(item)) + for item in payload.get("idf_data") or []: + res.idf_data.append( + uburnode_somni_pb2.IdfStage( + stage=str(item.get("stage") or ""), + start=str(item.get("start") or ""), + end=str(item.get("end") or ""), + ) + ) + for item in payload.get("physio_data") or []: + metrics = item.get("metrics") or {} + res.physio_data.append( + uburnode_somni_pb2.PhysioDataPoint( + collected_at=str(item.get("collected_at") or ""), + metrics=uburnode_somni_pb2.PhysioMetrics( + heart_rate=int(metrics.get("heart_rate") or 0), + respiration_rate=int(metrics.get("respiration_rate") or 0), + ), + ) + ) + for item in payload.get("env_data") or []: + res.env_data.append( + uburnode_somni_pb2.EnvDataPoint( + collected_at=str(item.get("collected_at") or ""), + temperature=int(item.get("temperature") or 0), + humidity=int(item.get("humidity") or 0), + illuminance=int(item.get("illuminance") or 0), + noise=int(item.get("noise") or 0), + ) + ) + return res + + +def _sleep_event_item(item: dict[str, Any]) -> uburnode_somni_pb2.SleepEventItem: + event = uburnode_somni_pb2.SleepEventItem( + event_time=str(item.get("event_time") or ""), + type=str(item.get("type") or ""), + code=str(item.get("code") or ""), + ) + for detail in item.get("events") or []: + event.events.append( + uburnode_somni_pb2.SleepEventDetail( + event_type=str(detail.get("event_type") or ""), + duration=str(detail.get("duration") or ""), + trigger_cause=str(detail.get("trigger_cause") or ""), + action_taken=str(detail.get("action_taken") or ""), + result_summary=str(detail.get("result_summary") or ""), + ) + ) + intervention = item.get("intervention") or {} + event.intervention.CopyFrom( + uburnode_somni_pb2.Intervention( + type=str(intervention.get("type") or ""), + event_time=str(intervention.get("event_time") or ""), + event_type=str(intervention.get("event_type") or ""), + duration=str(intervention.get("duration") or ""), + trigger_cause=str(intervention.get("trigger_cause") or ""), + action_taken=str(intervention.get("action_taken") or ""), + result_summary=str(intervention.get("result_summary") or ""), + ) + ) + return event diff --git a/app/server/somni/report/service.py b/app/server/somni/report/service.py index cb67fae..6aa53eb 100644 --- a/app/server/somni/report/service.py +++ b/app/server/somni/report/service.py @@ -1,20 +1,256 @@ -"""量产睡眠报告:按 uid + record_date 查询(实现待补)。""" +"""量产睡眠报告:按 uid + record_date 聚合。""" from __future__ import annotations +from typing import Any + +from loguru import logger +from motor.motor_asyncio import AsyncIOMotorClient + +from app.core.config import Settings +from app.server.somni.report import calc +from app.server.somni.report.store import ReportStore + +_INTERVENTION_FIELDS = ( + "type", + "event_time", + "event_type", + "duration", + "trigger_cause", + "action_taken", + "result_summary", +) + class ReportService: - async def get_summary(self, uid: str, record_date: str) -> None: - del uid, record_date + def __init__( + self, + client: AsyncIOMotorClient | None, + settings: Settings, + store: ReportStore | None = None, + ) -> None: + self._store = store or ReportStore(client, settings) + + async def get_summary(self, uid: str, record_date: str) -> dict[str, Any]: + record = await self._store.find_record(uid, record_date) + report = await self._store.find_sleep_report(uid, record_date) + parts = calc.sleep_stage_parts(_raw_data(record)) + hr_vals, br_vals = await self._sleep_hr_br(uid, record_date) + summary = (report or {}).get("sleep_summary") or {} + return { + "sleep_summary": { + "body_battery": calc.as_int(summary.get("body_battery")), + "body_battery_status": str(summary.get("body_battery_status") or ""), + "total_minutes": int(parts["total_minutes"]), + "deep_sleep_minutes": int(parts["deep_sleep"]["minutes"]), + "avg_heart_rate": calc.floor_avg(hr_vals), + "avg_respiratory_rate": calc.floor_avg(br_vals), + } + } + + async def get_environment(self, uid: str, record_date: str) -> dict[str, Any]: + docs = await self._telemetry(uid, record_date, "env") + return { + "environment_summary": { + "temperature": calc.floor_metric_stats(_data_floats(docs, "temp")), + "humidity": calc.floor_metric_stats(_data_floats(docs, "humi")), + "illuminance": calc.floor_metric_stats(_data_floats(docs, "lux")), + "noise": calc.floor_metric_stats(_data_floats(docs, "noise_db")), + } + } + + async def get_events(self, uid: str, record_date: str) -> dict[str, Any]: + events = await self._store.list_events(uid, record_date) + sleep_events, abnormal_count, intervention_count = _assemble_sleep_events(events) + record = await self._store.find_record(uid, record_date) + sleep_docs = await self._telemetry(uid, record_date, "sleep") + env_docs = await self._telemetry(uid, record_date, "env") + return { + "record_date": record_date, + "sleep_events": sleep_events, + "event_count": abnormal_count, + "abnormal_count": abnormal_count, + "intervention_count": intervention_count, + "idf_data": _idf_data(record), + "physio_data": _physio_data(sleep_docs), + "env_data": _env_data(env_docs), + } + + async def get_structure(self, uid: str, record_date: str) -> dict[str, Any]: + record = await self._store.find_record(uid, record_date) + parts = calc.sleep_stage_parts(_raw_data(record)) + return { + "sleep_structure": { + "awake": parts["awake"], + "rem_sleep": parts["rem_sleep"], + "light_sleep": parts["light_sleep"], + "deep_sleep": parts["deep_sleep"], + } + } + + async def get_sleep_quality(self, uid: str, record_date: str) -> dict[str, Any]: + record = await self._store.find_record(uid, record_date) + raw = _raw_data(record) + bed = raw.get("bed_time") + wake = raw.get("wake_time") + wake_up = raw.get("wake_up_time") + return { + "sleep_quality": { + "time_in_bed_minutes": calc.minutes_between(bed, wake_up), + "sleep_onset_latency_minutes": calc.as_int(raw.get("sleep_latency")), + "sleep_efficiency": calc.as_int(raw.get("sleep_efficiency")), + "bedtime": calc.format_hhmm(bed), + "wake_up_time": calc.format_hhmm(wake_up), + "awake_after_onset_minutes": calc.minutes_between(wake, wake_up), + } + } + + async def _telemetry( + self, uid: str, record_date: str, metric: str + ) -> list[dict[str, Any]]: + device_id = await self._store.find_device_id(uid) + if not device_id: + return [] + return await self._store.list_telemetry(device_id, metric, record_date) + + async def _sleep_hr_br( + self, uid: str, record_date: str + ) -> tuple[list[float], list[float]]: + docs = await self._telemetry(uid, record_date, "sleep") + return _data_floats(docs, "hr"), _data_floats(docs, "br") + + +def _raw_data(record: dict[str, Any] | None) -> dict[str, Any]: + if not record: + return {} + raw = record.get("raw_data") or {} + return raw if isinstance(raw, dict) else {} + + +def _data_floats(docs: list[dict[str, Any]], key: str) -> list[float]: + values: list[float] = [] + for doc in docs: + data = doc.get("data") or {} + if not isinstance(data, dict): + continue + num = calc.as_float(data.get(key)) + if num is not None: + values.append(num) + return values + + +def _idf_data(record: dict[str, Any] | None) -> list[dict[str, str]]: + if not record: + return [] + items = record.get("idf_data") or [] + result: list[dict[str, str]] = [] + for item in items: + if not isinstance(item, dict): + continue + result.append( + { + "stage": str(item.get("stage") or ""), + "start": str(item.get("start") or ""), + "end": str(item.get("end") or ""), + } + ) + return result + + +def _physio_data(docs: list[dict[str, Any]]) -> list[dict[str, Any]]: + points: list[dict[str, Any]] = [] + for doc in docs: + data = doc.get("data") or {} + hr = calc.as_float(data.get("hr") if isinstance(data, dict) else None) + br = calc.as_float(data.get("br") if isinstance(data, dict) else None) + points.append( + { + "collected_at": calc.format_collected_at(doc.get("ts")), + "metrics": { + "heart_rate": 0 if hr is None else calc.floor_avg([hr]), + "respiration_rate": 0 if br is None else calc.floor_avg([br]), + }, + } + ) + return points + + +def _env_data(docs: list[dict[str, Any]]) -> list[dict[str, Any]]: + points: list[dict[str, Any]] = [] + for doc in docs: + data = doc.get("data") if isinstance(doc.get("data"), dict) else {} + points.append( + { + "collected_at": calc.format_collected_at(doc.get("ts")), + "temperature": _floor_one(data.get("temp")), + "humidity": _floor_one(data.get("humi")), + "illuminance": _floor_one(data.get("lux")), + "noise": _floor_one(data.get("noise_db")), + } + ) + return points + + +def _floor_one(value: Any) -> int: + num = calc.as_float(value) + return 0 if num is None else calc.floor_avg([num]) + + +def _assemble_sleep_events( + events: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], int, int]: + abnormals: list[dict[str, Any]] = [] + interventions: list[dict[str, Any]] = [] + for doc in events: + event_type = str(doc.get("type") or "") + if event_type == "abnormal": + abnormals.append(doc) + elif event_type == "intervention": + interventions.append(doc) + + by_id = {_event_id(doc): _to_sleep_event_item(doc) for doc in abnormals} + used_parents: set[str] = set() + for doc in interventions: + parent_id = str(doc.get("related_event_id") or "") + parent = by_id.get(parent_id) + if parent is None: + logger.debug("intervention 找不到父级 related_event_id={}", parent_id) + continue + if parent_id in used_parents: + logger.debug("父级已有 intervention,忽略多余条 parent_id={}", parent_id) + continue + parent["intervention"] = _to_intervention(doc) + used_parents.add(parent_id) + + return list(by_id.values()), len(abnormals), len(interventions) + + +def _event_id(doc: dict[str, Any]) -> str: + return str(doc.get("_id") or doc.get("id") or "") - async def get_events(self, uid: str, record_date: str) -> None: - del uid, record_date - async def get_environment(self, uid: str, record_date: str) -> None: - del uid, record_date +def _to_sleep_event_item(doc: dict[str, Any]) -> dict[str, Any]: + details = [] + for item in doc.get("events") or []: + if not isinstance(item, dict): + continue + details.append( + { + "event_type": str(item.get("event_type") or ""), + "duration": str(item.get("duration") or ""), + "trigger_cause": str(item.get("trigger_cause") or ""), + "action_taken": str(item.get("action_taken") or ""), + "result_summary": str(item.get("result_summary") or ""), + } + ) + return { + "event_time": str(doc.get("event_time") or ""), + "type": str(doc.get("type") or ""), + "code": str(doc.get("code") or ""), + "events": details, + "intervention": {field: "" for field in _INTERVENTION_FIELDS}, + } - async def get_structure(self, uid: str, record_date: str) -> None: - del uid, record_date - async def get_sleep_quality(self, uid: str, record_date: str) -> None: - del uid, record_date +def _to_intervention(doc: dict[str, Any]) -> dict[str, str]: + return {field: str(doc.get(field) or "") for field in _INTERVENTION_FIELDS} diff --git a/app/server/somni/report/store.py b/app/server/somni/report/store.py new file mode 100644 index 0000000..5f7f3ba --- /dev/null +++ b/app/server/somni/report/store.py @@ -0,0 +1,71 @@ +"""量产报告 Mongo 查询。""" + +from __future__ import annotations + +from typing import Any + +from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase + +from app.core.config import Settings +from app.core.exceptions import MongoNotConfiguredError +from app.server.somni.report.calc import local_day_utc_range + + +class ReportStore: + def __init__(self, client: AsyncIOMotorClient | None, settings: Settings) -> None: + self._client = client + self._settings = settings + + def _db(self) -> AsyncIOMotorDatabase: + if self._client is None: + raise MongoNotConfiguredError() + return self._client[self._settings.somni_mongo_db] + + async def find_device_id(self, uid: str) -> str | None: + doc = await self._db()[self._settings.somni_mongo_devices_collection].find_one( + {"bind_uid": uid}, + {"device_id": 1}, + ) + if not doc: + return None + device_id = doc.get("device_id") + return str(device_id) if device_id else None + + async def list_telemetry( + self, + device_id: str, + metric: str, + record_date: str, + ) -> list[dict[str, Any]]: + start, end = local_day_utc_range(record_date) + cursor = self._db()[self._settings.somni_mongo_telemetry_collection].find( + { + "device_id": device_id, + "metric": metric, + "ts": {"$gte": start, "$lt": end}, + } + ).sort("ts", 1) + return await cursor.to_list(length=10_000) + + async def find_record(self, uid: str, record_date: str) -> dict[str, Any] | None: + return await self._db()[self._settings.somni_mongo_records_collection].find_one( + {"uid": uid, "record_date": record_date} + ) + + async def find_sleep_report( + self, uid: str, record_date: str + ) -> dict[str, Any] | None: + return await self._db()[ + self._settings.somni_mongo_sleep_reports_collection + ].find_one({"uid": uid, "record_date": record_date}) + + async def list_events(self, uid: str, record_date: str) -> list[dict[str, Any]]: + doc = await self._db()[self._settings.somni_mongo_events_collection].find_one( + {"uid": uid, "record_date": record_date} + ) + if not doc: + return [] + nested = doc.get("sleep_events") + if isinstance(nested, list): + return [item for item in nested if isinstance(item, dict)] + return [doc] diff --git a/app/uburnode_grpc/grpc_gen/uburnode_somni_pb2.py b/app/uburnode_grpc/grpc_gen/uburnode_somni_pb2.py index 75b958f..911ec47 100644 --- a/app/uburnode_grpc/grpc_gen/uburnode_somni_pb2.py +++ b/app/uburnode_grpc/grpc_gen/uburnode_somni_pb2.py @@ -25,7 +25,7 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14uburnode_somni.proto\x12\x11uburnode.somni.v1\x1a\x1cgoogle/protobuf/struct.proto\".\n\x0cGetAnswerReq\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\x11\n\tanswer_id\x18\x02 \x01(\t\"\x1f\n\x0cGetAnswerRes\x12\x0f\n\x07\x61nswers\x18\x01 \x01(\t\"1\n\rReportDateReq\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\x13\n\x0brecord_date\x18\x02 \x01(\t\"\x0f\n\rGetSummaryRes\"\x0e\n\x0cGetEventsRes\"\x13\n\x11GetEnvironmentRes\"\x11\n\x0fGetStructureRes\"\x14\n\x12GetSleepQualityRes\"\xc1\x01\n\x0bGetAudioReq\x12\x11\n\x04page\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x16\n\tpage_size\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x16\n\tfetch_all\x18\x03 \x01(\x08H\x02\x88\x01\x01\x12\x17\n\nquery_text\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x15\n\x08tag_code\x18\x05 \x01(\tH\x04\x88\x01\x01\x42\x07\n\x05_pageB\x0c\n\n_page_sizeB\x0c\n\n_fetch_allB\r\n\x0b_query_textB\x0b\n\t_tag_code\"\xdf\x01\n\rAudioListItem\x12\x0f\n\x02id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x17\n\naudio_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\taudio_url\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x16\n\tcover_url\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x10\n\x03vip\x18\x06 \x01(\x05H\x05\x88\x01\x01\x42\x05\n\x03_idB\r\n\x0b_audio_nameB\x0c\n\n_audio_urlB\x0c\n\n_cover_urlB\x0e\n\x0c_descriptionB\x06\n\x04_vip\"m\n\x0bGetAudioRes\x12.\n\x04list\x18\x01 \x03(\x0b\x32 .uburnode.somni.v1.AudioListItem\x12\x0c\n\x04page\x18\x02 \x01(\x05\x12\x11\n\tpage_size\x18\x03 \x01(\x05\x12\r\n\x05total\x18\x04 \x01(\x05\"\x10\n\x0eGetAudioTagReq\"\x93\x01\n\x0bTagDictItem\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0c\n\x04\x63ode\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x0f\n\x07name_en\x18\x04 \x01(\t\x12\n\n\x02id\x18\x05 \x01(\t\x12-\n\rparent_tag_id\x18\x06 \x01(\x0b\x32\x16.google.protobuf.Value\x12\x0e\n\x06status\x18\x07 \x01(\t\">\n\x0eGetAudioTagRes\x12,\n\x04tags\x18\x01 \x03(\x0b\x32\x1e.uburnode.somni.v1.TagDictItem\"\x0b\n\tGetHotReq\",\n\nHotKeyword\x12\x0f\n\x07keyword\x18\x01 \x01(\t\x12\r\n\x05score\x18\x02 \x01(\x03\"9\n\tGetHotRes\x12,\n\x05items\x18\x01 \x03(\x0b\x32\x1d.uburnode.somni.v1.HotKeyword2\\\n\x0bQuizService\x12M\n\tGetAnswer\x12\x1f.uburnode.somni.v1.GetAnswerReq\x1a\x1f.uburnode.somni.v1.GetAnswerRes2\xbd\x03\n\rReportService\x12P\n\nGetSummary\x12 .uburnode.somni.v1.ReportDateReq\x1a .uburnode.somni.v1.GetSummaryRes\x12N\n\tGetEvents\x12 .uburnode.somni.v1.ReportDateReq\x1a\x1f.uburnode.somni.v1.GetEventsRes\x12X\n\x0eGetEnvironment\x12 .uburnode.somni.v1.ReportDateReq\x1a$.uburnode.somni.v1.GetEnvironmentRes\x12T\n\x0cGetStructure\x12 .uburnode.somni.v1.ReportDateReq\x1a\".uburnode.somni.v1.GetStructureRes\x12Z\n\x0fGetSleepQuality\x12 .uburnode.somni.v1.ReportDateReq\x1a%.uburnode.somni.v1.GetSleepQualityRes2\xf5\x01\n\x0c\x41udioService\x12J\n\x08GetAudio\x12\x1e.uburnode.somni.v1.GetAudioReq\x1a\x1e.uburnode.somni.v1.GetAudioRes\x12S\n\x0bGetAudioTag\x12!.uburnode.somni.v1.GetAudioTagReq\x1a!.uburnode.somni.v1.GetAudioTagRes\x12\x44\n\x06GetHot\x12\x1c.uburnode.somni.v1.GetHotReq\x1a\x1c.uburnode.somni.v1.GetHotResb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14uburnode_somni.proto\x12\x11uburnode.somni.v1\x1a\x1cgoogle/protobuf/struct.proto\".\n\x0cGetAnswerReq\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\x11\n\tanswer_id\x18\x02 \x01(\t\"\x1f\n\x0cGetAnswerRes\x12\x0f\n\x07\x61nswers\x18\x01 \x01(\t\"1\n\rReportDateReq\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\x13\n\x0brecord_date\x18\x02 \x01(\t\"\xaa\x01\n\x0cSleepSummary\x12\x14\n\x0c\x62ody_battery\x18\x01 \x01(\x05\x12\x1b\n\x13\x62ody_battery_status\x18\x02 \x01(\t\x12\x15\n\rtotal_minutes\x18\x03 \x01(\x05\x12\x1a\n\x12\x64\x65\x65p_sleep_minutes\x18\x04 \x01(\x05\x12\x16\n\x0e\x61vg_heart_rate\x18\x05 \x01(\x05\x12\x1c\n\x14\x61vg_respiratory_rate\x18\x06 \x01(\x05\"G\n\rGetSummaryRes\x12\x36\n\rsleep_summary\x18\x01 \x01(\x0b\x32\x1f.uburnode.somni.v1.SleepSummary\"}\n\x10SleepEventDetail\x12\x12\n\nevent_type\x18\x01 \x01(\t\x12\x10\n\x08\x64uration\x18\x02 \x01(\t\x12\x15\n\rtrigger_cause\x18\x03 \x01(\t\x12\x14\n\x0c\x61\x63tion_taken\x18\x04 \x01(\t\x12\x16\n\x0eresult_summary\x18\x05 \x01(\t\"\x9b\x01\n\x0cIntervention\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x12\n\nevent_time\x18\x02 \x01(\t\x12\x12\n\nevent_type\x18\x03 \x01(\t\x12\x10\n\x08\x64uration\x18\x04 \x01(\t\x12\x15\n\rtrigger_cause\x18\x05 \x01(\t\x12\x14\n\x0c\x61\x63tion_taken\x18\x06 \x01(\t\x12\x16\n\x0eresult_summary\x18\x07 \x01(\t\"\xac\x01\n\x0eSleepEventItem\x12\x12\n\nevent_time\x18\x01 \x01(\t\x12\x0c\n\x04type\x18\x02 \x01(\t\x12\x0c\n\x04\x63ode\x18\x03 \x01(\t\x12\x33\n\x06\x65vents\x18\x04 \x03(\x0b\x32#.uburnode.somni.v1.SleepEventDetail\x12\x35\n\x0cintervention\x18\x05 \x01(\x0b\x32\x1f.uburnode.somni.v1.Intervention\"5\n\x08IdfStage\x12\r\n\x05stage\x18\x01 \x01(\t\x12\r\n\x05start\x18\x02 \x01(\t\x12\x0b\n\x03\x65nd\x18\x03 \x01(\t\"=\n\rPhysioMetrics\x12\x12\n\nheart_rate\x18\x01 \x01(\x05\x12\x18\n\x10respiration_rate\x18\x02 \x01(\x05\"Z\n\x0fPhysioDataPoint\x12\x14\n\x0c\x63ollected_at\x18\x01 \x01(\t\x12\x31\n\x07metrics\x18\x02 \x01(\x0b\x32 .uburnode.somni.v1.PhysioMetrics\"o\n\x0c\x45nvDataPoint\x12\x14\n\x0c\x63ollected_at\x18\x01 \x01(\t\x12\x13\n\x0btemperature\x18\x02 \x01(\x05\x12\x10\n\x08humidity\x18\x03 \x01(\x05\x12\x13\n\x0billuminance\x18\x04 \x01(\x05\x12\r\n\x05noise\x18\x05 \x01(\x05\"\xc0\x02\n\x0cGetEventsRes\x12\x13\n\x0brecord_date\x18\x01 \x01(\t\x12\x37\n\x0csleep_events\x18\x02 \x03(\x0b\x32!.uburnode.somni.v1.SleepEventItem\x12\x13\n\x0b\x65vent_count\x18\x03 \x01(\x05\x12\x16\n\x0e\x61\x62normal_count\x18\x04 \x01(\x05\x12\x1a\n\x12intervention_count\x18\x05 \x01(\x05\x12-\n\x08idf_data\x18\x06 \x03(\x0b\x32\x1b.uburnode.somni.v1.IdfStage\x12\x37\n\x0bphysio_data\x18\x07 \x03(\x0b\x32\".uburnode.somni.v1.PhysioDataPoint\x12\x31\n\x08\x65nv_data\x18\x08 \x03(\x0b\x32\x1f.uburnode.somni.v1.EnvDataPoint\"4\n\tEnvMetric\x12\r\n\x05value\x18\x01 \x01(\x05\x12\x0b\n\x03min\x18\x02 \x01(\x05\x12\x0b\n\x03max\x18\x03 \x01(\x05\"\xd7\x01\n\x12\x45nvironmentSummary\x12\x31\n\x0btemperature\x18\x01 \x01(\x0b\x32\x1c.uburnode.somni.v1.EnvMetric\x12.\n\x08humidity\x18\x02 \x01(\x0b\x32\x1c.uburnode.somni.v1.EnvMetric\x12\x31\n\x0billuminance\x18\x03 \x01(\x0b\x32\x1c.uburnode.somni.v1.EnvMetric\x12+\n\x05noise\x18\x04 \x01(\x0b\x32\x1c.uburnode.somni.v1.EnvMetric\"W\n\x11GetEnvironmentRes\x12\x42\n\x13\x65nvironment_summary\x18\x01 \x01(\x0b\x32%.uburnode.somni.v1.EnvironmentSummary\"2\n\x0eSleepStagePart\x12\x0f\n\x07minutes\x18\x01 \x01(\x05\x12\x0f\n\x07percent\x18\x02 \x01(\x05\"\xe7\x01\n\x0eSleepStructure\x12\x30\n\x05\x61wake\x18\x01 \x01(\x0b\x32!.uburnode.somni.v1.SleepStagePart\x12\x34\n\trem_sleep\x18\x02 \x01(\x0b\x32!.uburnode.somni.v1.SleepStagePart\x12\x36\n\x0blight_sleep\x18\x03 \x01(\x0b\x32!.uburnode.somni.v1.SleepStagePart\x12\x35\n\ndeep_sleep\x18\x04 \x01(\x0b\x32!.uburnode.somni.v1.SleepStagePart\"M\n\x0fGetStructureRes\x12:\n\x0fsleep_structure\x18\x01 \x01(\x0b\x32!.uburnode.somni.v1.SleepStructure\"\xb4\x01\n\x0cSleepQuality\x12\x1b\n\x13time_in_bed_minutes\x18\x01 \x01(\x05\x12#\n\x1bsleep_onset_latency_minutes\x18\x02 \x01(\x05\x12\x18\n\x10sleep_efficiency\x18\x03 \x01(\x05\x12\x0f\n\x07\x62\x65\x64time\x18\x04 \x01(\t\x12\x14\n\x0cwake_up_time\x18\x05 \x01(\t\x12!\n\x19\x61wake_after_onset_minutes\x18\x06 \x01(\x05\"L\n\x12GetSleepQualityRes\x12\x36\n\rsleep_quality\x18\x01 \x01(\x0b\x32\x1f.uburnode.somni.v1.SleepQuality\"\xc1\x01\n\x0bGetAudioReq\x12\x11\n\x04page\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x16\n\tpage_size\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x16\n\tfetch_all\x18\x03 \x01(\x08H\x02\x88\x01\x01\x12\x17\n\nquery_text\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x15\n\x08tag_code\x18\x05 \x01(\tH\x04\x88\x01\x01\x42\x07\n\x05_pageB\x0c\n\n_page_sizeB\x0c\n\n_fetch_allB\r\n\x0b_query_textB\x0b\n\t_tag_code\"\xdf\x01\n\rAudioListItem\x12\x0f\n\x02id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x17\n\naudio_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\taudio_url\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x16\n\tcover_url\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x10\n\x03vip\x18\x06 \x01(\x05H\x05\x88\x01\x01\x42\x05\n\x03_idB\r\n\x0b_audio_nameB\x0c\n\n_audio_urlB\x0c\n\n_cover_urlB\x0e\n\x0c_descriptionB\x06\n\x04_vip\"m\n\x0bGetAudioRes\x12.\n\x04list\x18\x01 \x03(\x0b\x32 .uburnode.somni.v1.AudioListItem\x12\x0c\n\x04page\x18\x02 \x01(\x05\x12\x11\n\tpage_size\x18\x03 \x01(\x05\x12\r\n\x05total\x18\x04 \x01(\x05\"\x10\n\x0eGetAudioTagReq\"\x93\x01\n\x0bTagDictItem\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0c\n\x04\x63ode\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x0f\n\x07name_en\x18\x04 \x01(\t\x12\n\n\x02id\x18\x05 \x01(\t\x12-\n\rparent_tag_id\x18\x06 \x01(\x0b\x32\x16.google.protobuf.Value\x12\x0e\n\x06status\x18\x07 \x01(\t\">\n\x0eGetAudioTagRes\x12,\n\x04tags\x18\x01 \x03(\x0b\x32\x1e.uburnode.somni.v1.TagDictItem\"\x0b\n\tGetHotReq\",\n\nHotKeyword\x12\x0f\n\x07keyword\x18\x01 \x01(\t\x12\r\n\x05score\x18\x02 \x01(\x03\"9\n\tGetHotRes\x12,\n\x05items\x18\x01 \x03(\x0b\x32\x1d.uburnode.somni.v1.HotKeyword2\\\n\x0bQuizService\x12M\n\tGetAnswer\x12\x1f.uburnode.somni.v1.GetAnswerReq\x1a\x1f.uburnode.somni.v1.GetAnswerRes2\xbd\x03\n\rReportService\x12P\n\nGetSummary\x12 .uburnode.somni.v1.ReportDateReq\x1a .uburnode.somni.v1.GetSummaryRes\x12N\n\tGetEvents\x12 .uburnode.somni.v1.ReportDateReq\x1a\x1f.uburnode.somni.v1.GetEventsRes\x12X\n\x0eGetEnvironment\x12 .uburnode.somni.v1.ReportDateReq\x1a$.uburnode.somni.v1.GetEnvironmentRes\x12T\n\x0cGetStructure\x12 .uburnode.somni.v1.ReportDateReq\x1a\".uburnode.somni.v1.GetStructureRes\x12Z\n\x0fGetSleepQuality\x12 .uburnode.somni.v1.ReportDateReq\x1a%.uburnode.somni.v1.GetSleepQualityRes2\xf5\x01\n\x0c\x41udioService\x12J\n\x08GetAudio\x12\x1e.uburnode.somni.v1.GetAudioReq\x1a\x1e.uburnode.somni.v1.GetAudioRes\x12S\n\x0bGetAudioTag\x12!.uburnode.somni.v1.GetAudioTagReq\x1a!.uburnode.somni.v1.GetAudioTagRes\x12\x44\n\x06GetHot\x12\x1c.uburnode.somni.v1.GetHotReq\x1a\x1c.uburnode.somni.v1.GetHotResb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -38,38 +38,64 @@ _globals['_GETANSWERRES']._serialized_end=152 _globals['_REPORTDATEREQ']._serialized_start=154 _globals['_REPORTDATEREQ']._serialized_end=203 - _globals['_GETSUMMARYRES']._serialized_start=205 - _globals['_GETSUMMARYRES']._serialized_end=220 - _globals['_GETEVENTSRES']._serialized_start=222 - _globals['_GETEVENTSRES']._serialized_end=236 - _globals['_GETENVIRONMENTRES']._serialized_start=238 - _globals['_GETENVIRONMENTRES']._serialized_end=257 - _globals['_GETSTRUCTURERES']._serialized_start=259 - _globals['_GETSTRUCTURERES']._serialized_end=276 - _globals['_GETSLEEPQUALITYRES']._serialized_start=278 - _globals['_GETSLEEPQUALITYRES']._serialized_end=298 - _globals['_GETAUDIOREQ']._serialized_start=301 - _globals['_GETAUDIOREQ']._serialized_end=494 - _globals['_AUDIOLISTITEM']._serialized_start=497 - _globals['_AUDIOLISTITEM']._serialized_end=720 - _globals['_GETAUDIORES']._serialized_start=722 - _globals['_GETAUDIORES']._serialized_end=831 - _globals['_GETAUDIOTAGREQ']._serialized_start=833 - _globals['_GETAUDIOTAGREQ']._serialized_end=849 - _globals['_TAGDICTITEM']._serialized_start=852 - _globals['_TAGDICTITEM']._serialized_end=999 - _globals['_GETAUDIOTAGRES']._serialized_start=1001 - _globals['_GETAUDIOTAGRES']._serialized_end=1063 - _globals['_GETHOTREQ']._serialized_start=1065 - _globals['_GETHOTREQ']._serialized_end=1076 - _globals['_HOTKEYWORD']._serialized_start=1078 - _globals['_HOTKEYWORD']._serialized_end=1122 - _globals['_GETHOTRES']._serialized_start=1124 - _globals['_GETHOTRES']._serialized_end=1181 - _globals['_QUIZSERVICE']._serialized_start=1183 - _globals['_QUIZSERVICE']._serialized_end=1275 - _globals['_REPORTSERVICE']._serialized_start=1278 - _globals['_REPORTSERVICE']._serialized_end=1723 - _globals['_AUDIOSERVICE']._serialized_start=1726 - _globals['_AUDIOSERVICE']._serialized_end=1971 + _globals['_SLEEPSUMMARY']._serialized_start=206 + _globals['_SLEEPSUMMARY']._serialized_end=376 + _globals['_GETSUMMARYRES']._serialized_start=378 + _globals['_GETSUMMARYRES']._serialized_end=449 + _globals['_SLEEPEVENTDETAIL']._serialized_start=451 + _globals['_SLEEPEVENTDETAIL']._serialized_end=576 + _globals['_INTERVENTION']._serialized_start=579 + _globals['_INTERVENTION']._serialized_end=734 + _globals['_SLEEPEVENTITEM']._serialized_start=737 + _globals['_SLEEPEVENTITEM']._serialized_end=909 + _globals['_IDFSTAGE']._serialized_start=911 + _globals['_IDFSTAGE']._serialized_end=964 + _globals['_PHYSIOMETRICS']._serialized_start=966 + _globals['_PHYSIOMETRICS']._serialized_end=1027 + _globals['_PHYSIODATAPOINT']._serialized_start=1029 + _globals['_PHYSIODATAPOINT']._serialized_end=1119 + _globals['_ENVDATAPOINT']._serialized_start=1121 + _globals['_ENVDATAPOINT']._serialized_end=1232 + _globals['_GETEVENTSRES']._serialized_start=1235 + _globals['_GETEVENTSRES']._serialized_end=1555 + _globals['_ENVMETRIC']._serialized_start=1557 + _globals['_ENVMETRIC']._serialized_end=1609 + _globals['_ENVIRONMENTSUMMARY']._serialized_start=1612 + _globals['_ENVIRONMENTSUMMARY']._serialized_end=1827 + _globals['_GETENVIRONMENTRES']._serialized_start=1829 + _globals['_GETENVIRONMENTRES']._serialized_end=1916 + _globals['_SLEEPSTAGEPART']._serialized_start=1918 + _globals['_SLEEPSTAGEPART']._serialized_end=1968 + _globals['_SLEEPSTRUCTURE']._serialized_start=1971 + _globals['_SLEEPSTRUCTURE']._serialized_end=2202 + _globals['_GETSTRUCTURERES']._serialized_start=2204 + _globals['_GETSTRUCTURERES']._serialized_end=2281 + _globals['_SLEEPQUALITY']._serialized_start=2284 + _globals['_SLEEPQUALITY']._serialized_end=2464 + _globals['_GETSLEEPQUALITYRES']._serialized_start=2466 + _globals['_GETSLEEPQUALITYRES']._serialized_end=2542 + _globals['_GETAUDIOREQ']._serialized_start=2545 + _globals['_GETAUDIOREQ']._serialized_end=2738 + _globals['_AUDIOLISTITEM']._serialized_start=2741 + _globals['_AUDIOLISTITEM']._serialized_end=2964 + _globals['_GETAUDIORES']._serialized_start=2966 + _globals['_GETAUDIORES']._serialized_end=3075 + _globals['_GETAUDIOTAGREQ']._serialized_start=3077 + _globals['_GETAUDIOTAGREQ']._serialized_end=3093 + _globals['_TAGDICTITEM']._serialized_start=3096 + _globals['_TAGDICTITEM']._serialized_end=3243 + _globals['_GETAUDIOTAGRES']._serialized_start=3245 + _globals['_GETAUDIOTAGRES']._serialized_end=3307 + _globals['_GETHOTREQ']._serialized_start=3309 + _globals['_GETHOTREQ']._serialized_end=3320 + _globals['_HOTKEYWORD']._serialized_start=3322 + _globals['_HOTKEYWORD']._serialized_end=3366 + _globals['_GETHOTRES']._serialized_start=3368 + _globals['_GETHOTRES']._serialized_end=3425 + _globals['_QUIZSERVICE']._serialized_start=3427 + _globals['_QUIZSERVICE']._serialized_end=3519 + _globals['_REPORTSERVICE']._serialized_start=3522 + _globals['_REPORTSERVICE']._serialized_end=3967 + _globals['_AUDIOSERVICE']._serialized_start=3970 + _globals['_AUDIOSERVICE']._serialized_end=4215 # @@protoc_insertion_point(module_scope) diff --git a/proto/uburnode_somni.proto b/proto/uburnode_somni.proto index d6ba398..edcfbbc 100644 --- a/proto/uburnode_somni.proto +++ b/proto/uburnode_somni.proto @@ -25,15 +25,125 @@ message ReportDateReq { string record_date = 2; // 必传:记录日期 } -message GetSummaryRes {} +message SleepSummary { + int32 body_battery = 1; + string body_battery_status = 2; + int32 total_minutes = 3; + int32 deep_sleep_minutes = 4; + int32 avg_heart_rate = 5; + int32 avg_respiratory_rate = 6; +} + +message GetSummaryRes { + SleepSummary sleep_summary = 1; +} + +message SleepEventDetail { + string event_type = 1; + string duration = 2; + string trigger_cause = 3; + string action_taken = 4; + string result_summary = 5; +} + +message Intervention { + string type = 1; + string event_time = 2; + string event_type = 3; + string duration = 4; + string trigger_cause = 5; + string action_taken = 6; + string result_summary = 7; +} + +message SleepEventItem { + string event_time = 1; + string type = 2; + string code = 3; + repeated SleepEventDetail events = 4; + Intervention intervention = 5; +} + +message IdfStage { + string stage = 1; + string start = 2; + string end = 3; +} + +message PhysioMetrics { + int32 heart_rate = 1; + int32 respiration_rate = 2; +} + +message PhysioDataPoint { + string collected_at = 1; + PhysioMetrics metrics = 2; +} -message GetEventsRes {} +message EnvDataPoint { + string collected_at = 1; + int32 temperature = 2; + int32 humidity = 3; + int32 illuminance = 4; + int32 noise = 5; +} + +message GetEventsRes { + string record_date = 1; + repeated SleepEventItem sleep_events = 2; + int32 event_count = 3; + int32 abnormal_count = 4; + int32 intervention_count = 5; + repeated IdfStage idf_data = 6; + repeated PhysioDataPoint physio_data = 7; + repeated EnvDataPoint env_data = 8; +} + +message EnvMetric { + int32 value = 1; + int32 min = 2; + int32 max = 3; +} + +message EnvironmentSummary { + EnvMetric temperature = 1; + EnvMetric humidity = 2; + EnvMetric illuminance = 3; + EnvMetric noise = 4; +} + +message GetEnvironmentRes { + EnvironmentSummary environment_summary = 1; +} + +message SleepStagePart { + int32 minutes = 1; + int32 percent = 2; +} + +message SleepStructure { + SleepStagePart awake = 1; + SleepStagePart rem_sleep = 2; + SleepStagePart light_sleep = 3; + SleepStagePart deep_sleep = 4; +} -message GetEnvironmentRes {} +message GetStructureRes { + SleepStructure sleep_structure = 1; +} -message GetStructureRes {} +message SleepQuality { + int32 time_in_bed_minutes = 1; + int32 sleep_onset_latency_minutes = 2; + int32 sleep_efficiency = 3; + string bedtime = 4; + string wake_up_time = 5; + int32 awake_after_onset_minutes = 6; +} -message GetSleepQualityRes {} +message GetSleepQualityRes { + SleepQuality sleep_quality = 1; +} service ReportService { rpc GetSummary (ReportDateReq) returns (GetSummaryRes); diff --git a/tests/test_grpc_report.py b/tests/test_grpc_report.py index 74db809..8c6fd79 100644 --- a/tests/test_grpc_report.py +++ b/tests/test_grpc_report.py @@ -6,6 +6,7 @@ import grpc import pytest +from google.protobuf.json_format import MessageToDict from app.server.somni.report.rpc import ReportRpc from app.uburnode_grpc.grpc_gen import uburnode_somni_pb2 @@ -19,11 +20,62 @@ def _context() -> MagicMock: def _make_rpc() -> tuple[ReportRpc, MagicMock]: service = MagicMock() - service.get_summary = AsyncMock() - service.get_events = AsyncMock() - service.get_environment = AsyncMock() - service.get_structure = AsyncMock() - service.get_sleep_quality = AsyncMock() + service.get_summary = AsyncMock( + return_value={ + "sleep_summary": { + "body_battery": 90, + "body_battery_status": "Energy At Its Peak", + "total_minutes": 457, + "deep_sleep_minutes": 95, + "avg_heart_rate": 62, + "avg_respiratory_rate": 15, + } + } + ) + service.get_events = AsyncMock( + return_value={ + "record_date": "2026-08-24", + "sleep_events": [], + "event_count": 0, + "abnormal_count": 0, + "intervention_count": 0, + "idf_data": [], + "physio_data": [], + "env_data": [], + } + ) + service.get_environment = AsyncMock( + return_value={ + "environment_summary": { + "temperature": {"value": 23, "min": 21, "max": 25}, + "humidity": {"value": 52, "min": 40, "max": 60}, + "illuminance": {"value": 1, "min": 0, "max": 5}, + "noise": {"value": 28, "min": 20, "max": 35}, + } + } + ) + service.get_structure = AsyncMock( + return_value={ + "sleep_structure": { + "awake": {"minutes": 20, "percent": 4}, + "rem_sleep": {"minutes": 100, "percent": 20}, + "light_sleep": {"minutes": 250, "percent": 50}, + "deep_sleep": {"minutes": 90, "percent": 18}, + } + } + ) + service.get_sleep_quality = AsyncMock( + return_value={ + "sleep_quality": { + "time_in_bed_minutes": 500, + "sleep_onset_latency_minutes": 20, + "sleep_efficiency": 92, + "bedtime": "23:30", + "wake_up_time": "07:30", + "awake_after_onset_minutes": 18, + } + } + ) return ReportRpc(service), service @@ -54,3 +106,15 @@ async def test_report_rpcs_call_service(method_name: str, service_name: str) -> res = await getattr(rpc, method_name)(req, _context()) getattr(service, service_name).assert_awaited_once_with("u1", "2026-08-24") assert res is not None + + +@pytest.mark.asyncio +async def test_get_summary_maps_fields() -> None: + rpc, _service = _make_rpc() + res = await rpc.GetSummary( + uburnode_somni_pb2.ReportDateReq(uid="u1", record_date="2026-08-24"), + _context(), + ) + payload = MessageToDict(res, preserving_proto_field_name=True) + assert payload["sleep_summary"]["avg_heart_rate"] == 62 + assert payload["sleep_summary"]["body_battery_status"] == "Energy At Its Peak" diff --git a/tests/test_somni_report_calc.py b/tests/test_somni_report_calc.py new file mode 100644 index 0000000..2b3b0ad --- /dev/null +++ b/tests/test_somni_report_calc.py @@ -0,0 +1,61 @@ +"""量产报告 calc 单测。""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from app.server.somni.report.calc import ( + floor_avg, + floor_metric_stats, + format_hhmm, + local_day_utc_range, + minutes_between, + sleep_stage_parts, + stage_minutes, +) + + +def test_local_day_utc_range_shanghai() -> None: + start, end = local_day_utc_range("2026-08-10") + assert start == datetime(2026, 8, 9, 16, 0, tzinfo=UTC) + assert end == datetime(2026, 8, 10, 16, 0, tzinfo=UTC) + + +def test_minutes_between_and_stage() -> None: + bed = datetime(2026, 8, 9, 15, 30, tzinfo=UTC) # 23:30 CST + wake = datetime(2026, 8, 10, 0, 30, tzinfo=UTC) # 08:30 CST + assert minutes_between(bed, wake) == 540 + assert stage_minutes(540, 17) == 91 + assert stage_minutes(0, 17) == 0 + + +def test_floor_avg_and_metric_stats() -> None: + assert floor_avg([66.5, 62.2]) == 64 + assert floor_avg([]) == 0 + assert floor_metric_stats([21.9, 23.2, 25.8]) == { + "value": 23, + "min": 21, + "max": 25, + } + assert floor_metric_stats([]) == {"value": 0, "min": 0, "max": 0} + + +def test_format_hhmm() -> None: + assert format_hhmm(datetime(2026, 8, 9, 15, 30, tzinfo=UTC)) == "23:30" + assert format_hhmm(None) == "" + + +def test_sleep_stage_parts_excludes_awake_from_total() -> None: + raw = { + "bed_time": datetime(2026, 8, 9, 15, 30, tzinfo=UTC), + "wake_up_time": datetime(2026, 8, 10, 0, 30, tzinfo=UTC), + "awake_ratio": 10, + "deep_sleep_ratio": 20, + "light_sleep_ratio": 50, + "rem_ratio": 20, + } + parts = sleep_stage_parts(raw) + assert parts["bed_minutes"] == 540 + assert parts["deep_sleep"]["minutes"] == 108 + assert parts["total_minutes"] == 108 + 270 + 108 + assert parts["awake"]["percent"] == 10 diff --git a/tests/test_somni_report_service.py b/tests/test_somni_report_service.py new file mode 100644 index 0000000..0d375b2 --- /dev/null +++ b/tests/test_somni_report_service.py @@ -0,0 +1,198 @@ +"""量产 ReportService 业务单测。""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.core.config import Settings +from app.core.exceptions import MongoNotConfiguredError +from app.server.somni.report.service import ReportService + + +def _settings() -> Settings: + return Settings(somni_mongo_uri="mongodb://localhost") + + +def _service(store: MagicMock) -> ReportService: + return ReportService(client=MagicMock(), settings=_settings(), store=store) + + +@pytest.mark.asyncio +async def test_get_summary_computes_stages_and_avg() -> None: + store = MagicMock() + store.find_record = AsyncMock( + return_value={ + "raw_data": { + "bed_time": datetime(2026, 8, 9, 15, 30, tzinfo=UTC), + "wake_up_time": datetime(2026, 8, 10, 0, 30, tzinfo=UTC), + "deep_sleep_ratio": 20, + "light_sleep_ratio": 50, + "rem_ratio": 20, + "awake_ratio": 10, + } + } + ) + store.find_sleep_report = AsyncMock( + return_value={ + "sleep_summary": { + "body_battery": 90, + "body_battery_status": "Energy At Its Peak", + } + } + ) + store.find_device_id = AsyncMock(return_value="dev_1") + store.list_telemetry = AsyncMock( + return_value=[ + {"data": {"hr": 66.5, "br": 16}}, + {"data": {"hr": 62.2, "br": 14.8}}, + ] + ) + svc = _service(store) + payload = await svc.get_summary("u1", "2026-08-10") + summary = payload["sleep_summary"] + assert summary["body_battery"] == 90 + assert summary["body_battery_status"] == "Energy At Its Peak" + assert summary["deep_sleep_minutes"] == 108 + assert summary["total_minutes"] == 108 + 270 + 108 + assert summary["avg_heart_rate"] == 64 + assert summary["avg_respiratory_rate"] == 15 + + +@pytest.mark.asyncio +async def test_get_summary_without_device_zeros_avg() -> None: + store = MagicMock() + store.find_record = AsyncMock(return_value=None) + store.find_sleep_report = AsyncMock(return_value=None) + store.find_device_id = AsyncMock(return_value=None) + svc = _service(store) + summary = (await svc.get_summary("u1", "2026-08-10"))["sleep_summary"] + assert summary == { + "body_battery": 0, + "body_battery_status": "", + "total_minutes": 0, + "deep_sleep_minutes": 0, + "avg_heart_rate": 0, + "avg_respiratory_rate": 0, + } + + +@pytest.mark.asyncio +async def test_get_environment_stats() -> None: + store = MagicMock() + store.find_device_id = AsyncMock(return_value="dev_1") + store.list_telemetry = AsyncMock( + return_value=[ + {"data": {"temp": 21.9, "humi": 40.2, "lux": 1.2, "noise_db": 28.9}}, + {"data": {"temp": 25.8, "humi": 55.1, "lux": 4.9, "noise_db": 34.1}}, + ] + ) + env = (await _service(store).get_environment("u1", "2026-08-10"))[ + "environment_summary" + ] + assert env["temperature"] == {"value": 23, "min": 21, "max": 25} + assert env["noise"]["value"] == 31 + assert "status" not in env["temperature"] + + +@pytest.mark.asyncio +async def test_get_events_attaches_intervention() -> None: + store = MagicMock() + store.list_events = AsyncMock( + return_value=[ + { + "_id": "ab1", + "event_time": "02:15", + "type": "abnormal", + "code": "heart_rate_increase", + "events": [ + { + "event_type": "心率上升", + "duration": "00:03:20", + "trigger_cause": "可能受到噪音影响", + "action_taken": "播放舒缓声音", + "result_summary": "心率恢复", + } + ], + }, + { + "_id": "iv1", + "type": "intervention", + "related_event_id": "ab1", + "event_time": "02:16", + "event_type": "干预", + "duration": "00:01:00", + "trigger_cause": "噪音", + "action_taken": "播放白噪音", + "result_summary": "恢复", + }, + ] + ) + store.find_record = AsyncMock( + return_value={"idf_data": [{"stage": "deep", "start": "01:00", "end": "02:20"}]} + ) + store.find_device_id = AsyncMock(return_value="dev_1") + store.list_telemetry = AsyncMock( + side_effect=[ + [ + { + "ts": datetime(2026, 8, 9, 18, 15, tzinfo=UTC), + "data": {"hr": 68.2, "br": 16.1}, + } + ], + [ + { + "ts": datetime(2026, 8, 9, 18, 15, tzinfo=UTC), + "data": {"temp": 23.2, "humi": 52.9, "lux": 1.2, "noise_db": 28.4}, + } + ], + ] + ) + payload = await _service(store).get_events("u1", "2026-08-10") + assert payload["event_count"] == 1 + assert payload["abnormal_count"] == 1 + assert payload["intervention_count"] == 1 + assert len(payload["sleep_events"]) == 1 + assert payload["sleep_events"][0]["intervention"]["event_type"] == "干预" + assert payload["idf_data"][0]["stage"] == "deep" + assert payload["physio_data"][0]["metrics"]["heart_rate"] == 68 + assert payload["env_data"][0]["noise"] == 28 + + +@pytest.mark.asyncio +async def test_get_structure_and_sleep_quality() -> None: + store = MagicMock() + store.find_record = AsyncMock( + return_value={ + "raw_data": { + "bed_time": datetime(2026, 8, 9, 15, 30, tzinfo=UTC), + "sleep_time": datetime(2026, 8, 9, 15, 50, tzinfo=UTC), + "wake_time": datetime(2026, 8, 10, 0, 12, tzinfo=UTC), + "wake_up_time": datetime(2026, 8, 10, 0, 30, tzinfo=UTC), + "awake_ratio": 10, + "deep_sleep_ratio": 20, + "light_sleep_ratio": 50, + "rem_ratio": 20, + "sleep_latency": 20, + "sleep_efficiency": 92, + } + } + ) + svc = _service(store) + structure = (await svc.get_structure("u1", "2026-08-10"))["sleep_structure"] + assert structure["deep_sleep"] == {"minutes": 108, "percent": 20} + quality = (await svc.get_sleep_quality("u1", "2026-08-10"))["sleep_quality"] + assert quality["time_in_bed_minutes"] == 540 + assert quality["bedtime"] == "23:30" + assert quality["wake_up_time"] == "08:30" + assert quality["awake_after_onset_minutes"] == 18 + assert quality["sleep_onset_latency_minutes"] == 20 + + +@pytest.mark.asyncio +async def test_mongo_not_configured() -> None: + svc = ReportService(client=None, settings=_settings()) + with pytest.raises(MongoNotConfiguredError): + await svc.get_summary("u1", "2026-08-10") diff --git a/tests/test_somni_report_store.py b/tests/test_somni_report_store.py new file mode 100644 index 0000000..f22e69d --- /dev/null +++ b/tests/test_somni_report_store.py @@ -0,0 +1,45 @@ +"""ReportStore 查询行为。""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.core.config import Settings +from app.server.somni.report.store import ReportStore + + +@pytest.mark.asyncio +async def test_list_events_reads_sleep_events_field() -> None: + collection = MagicMock() + collection.find_one = AsyncMock( + return_value={ + "uid": "u1", + "record_date": "2026-08-10", + "sleep_events": [ + {"type": "abnormal", "_id": "a1"}, + {"type": "intervention", "related_event_id": "a1"}, + ], + } + ) + db = MagicMock() + db.__getitem__.return_value = collection + client = MagicMock() + client.__getitem__.return_value = db + store = ReportStore(client, Settings()) + events = await store.list_events("u1", "2026-08-10") + assert len(events) == 2 + assert events[0]["type"] == "abnormal" + + +@pytest.mark.asyncio +async def test_list_events_empty_when_missing() -> None: + collection = MagicMock() + collection.find_one = AsyncMock(return_value=None) + db = MagicMock() + db.__getitem__.return_value = collection + client = MagicMock() + client.__getitem__.return_value = db + store = ReportStore(client, Settings()) + assert await store.list_events("u1", "2026-08-10") == []