feat(core+health): HealthDataProvider 扩展趋势分析预计算数据
- erp-core: 新增 get_trend_analysis_data() trait 方法和配套 DTO (TrendAnalysisDto, MetricTrendAnalysis, RegressionStats, AnomalyInfo) - erp-health: 实现 get_trend_analysis_data(),查询 vital_signs 时间序列 后调用 trend_stats 模块计算线性回归和异常检测,返回结构化统计摘要
This commit is contained in:
@@ -37,6 +37,15 @@ pub trait HealthDataProvider: Send + Sync {
|
|||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
report_id: Uuid,
|
report_id: Uuid,
|
||||||
) -> AppResult<HealthReportDto>;
|
) -> AppResult<HealthReportDto>;
|
||||||
|
|
||||||
|
/// 获取趋势分析预计算数据(统计摘要 + 异常检测)
|
||||||
|
async fn get_trend_analysis_data(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
patient_id: Uuid,
|
||||||
|
metrics: &[String],
|
||||||
|
range: &TimeRange,
|
||||||
|
) -> AppResult<TrendAnalysisDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === DTO 定义 ===
|
// === DTO 定义 ===
|
||||||
@@ -97,3 +106,56 @@ pub struct ReportSectionDto {
|
|||||||
pub findings: Vec<String>,
|
pub findings: Vec<String>,
|
||||||
pub abnormal_items: Vec<String>,
|
pub abnormal_items: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 趋势分析 DTO ===
|
||||||
|
|
||||||
|
/// 趋势方向
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TrendDirection {
|
||||||
|
Rising,
|
||||||
|
Falling,
|
||||||
|
Stable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 趋势分析整体 DTO
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TrendAnalysisDto {
|
||||||
|
pub patient_id: Uuid,
|
||||||
|
pub period_start: String,
|
||||||
|
pub period_end: String,
|
||||||
|
pub metrics: Vec<MetricTrendAnalysis>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个指标的趋势分析结果
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MetricTrendAnalysis {
|
||||||
|
pub metric: String,
|
||||||
|
pub unit: String,
|
||||||
|
pub data_point_count: usize,
|
||||||
|
/// 线性回归统计(数据不足时为 None)
|
||||||
|
pub regression: Option<RegressionStats>,
|
||||||
|
/// 检测到的异常点
|
||||||
|
pub anomalies: Vec<AnomalyInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线性回归统计摘要
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegressionStats {
|
||||||
|
pub slope: f64,
|
||||||
|
pub intercept: f64,
|
||||||
|
pub r_squared: f64,
|
||||||
|
pub direction: TrendDirection,
|
||||||
|
pub daily_change: f64,
|
||||||
|
pub period_change: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 异常点信息
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AnomalyInfo {
|
||||||
|
pub date: String,
|
||||||
|
pub value: f64,
|
||||||
|
pub mean: f64,
|
||||||
|
pub std_dev: f64,
|
||||||
|
pub deviation: f64,
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ use chrono::Datelike;
|
|||||||
use erp_core::error::{AppError, AppResult};
|
use erp_core::error::{AppError, AppResult};
|
||||||
use num_traits::ToPrimitive;
|
use num_traits::ToPrimitive;
|
||||||
use erp_core::health_provider::{
|
use erp_core::health_provider::{
|
||||||
HealthDataProvider, HealthReportDto, LabItemDto, LabReportDto, PatientSummaryDto,
|
AnomalyInfo, HealthDataProvider, HealthReportDto, LabItemDto, LabReportDto,
|
||||||
ReportSectionDto, TimeRange, VitalSignDto,
|
MetricTrendAnalysis, PatientSummaryDto, RegressionStats, ReportSectionDto, TimeRange,
|
||||||
|
TrendAnalysisDto, TrendDirection, VitalSignDto,
|
||||||
};
|
};
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -395,4 +396,111 @@ impl HealthDataProvider for HealthDataProviderImpl {
|
|||||||
sections,
|
sections,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_trend_analysis_data(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
patient_id: Uuid,
|
||||||
|
metrics: &[String],
|
||||||
|
range: &TimeRange,
|
||||||
|
) -> AppResult<TrendAnalysisDto> {
|
||||||
|
let _ = find_patient(&self.db, tenant_id, patient_id).await?;
|
||||||
|
|
||||||
|
let start_date = range.start.date_naive();
|
||||||
|
let end_date = range.end.date_naive();
|
||||||
|
|
||||||
|
let records = vital_signs::Entity::find()
|
||||||
|
.filter(vital_signs::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(vital_signs::Column::PatientId.eq(patient_id))
|
||||||
|
.filter(vital_signs::Column::DeletedAt.is_null())
|
||||||
|
.filter(vital_signs::Column::RecordDate.gte(start_date))
|
||||||
|
.filter(vital_signs::Column::RecordDate.lte(end_date))
|
||||||
|
.order_by_asc(vital_signs::Column::RecordDate)
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let metric_extractors: [(&str, Box<dyn Fn(&vital_signs::Model) -> Option<f64>>); 8] = [
|
||||||
|
("systolic_bp_morning", Box::new(|r| r.systolic_bp_morning.map(|v| v as f64))),
|
||||||
|
("diastolic_bp_morning", Box::new(|r| r.diastolic_bp_morning.map(|v| v as f64))),
|
||||||
|
("heart_rate", Box::new(|r| r.heart_rate.map(|v| v as f64))),
|
||||||
|
("weight", Box::new(|r| r.weight.map(|v| v.to_f64().unwrap_or(0.0)))),
|
||||||
|
("blood_sugar", Box::new(|r| r.blood_sugar.map(|v| v.to_f64().unwrap_or(0.0)))),
|
||||||
|
("body_temperature", Box::new(|r| r.body_temperature.map(|v| v.to_f64().unwrap_or(0.0)))),
|
||||||
|
("spo2", Box::new(|r| r.spo2.map(|v| v as f64))),
|
||||||
|
("urine_output_ml", Box::new(|r| r.urine_output_ml.map(|v| v as f64))),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut metric_results = Vec::new();
|
||||||
|
|
||||||
|
for (metric_name, extractor) in &metric_extractors {
|
||||||
|
if !metrics.is_empty() && !metrics.iter().any(|m| m == *metric_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建时间序列数据
|
||||||
|
let time_series: Vec<(chrono::NaiveDate, f64)> = records
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| extractor(r).map(|v| (r.record_date, v)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let data_point_count = time_series.len();
|
||||||
|
if data_point_count == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 线性回归
|
||||||
|
let regression = crate::service::trend_stats::compute_linear_regression(&time_series)
|
||||||
|
.map(|r| RegressionStats {
|
||||||
|
slope: r.slope,
|
||||||
|
intercept: r.intercept,
|
||||||
|
r_squared: r.r_squared,
|
||||||
|
direction: match r.direction {
|
||||||
|
crate::service::trend_stats::TrendDirection::Rising => TrendDirection::Rising,
|
||||||
|
crate::service::trend_stats::TrendDirection::Falling => TrendDirection::Falling,
|
||||||
|
crate::service::trend_stats::TrendDirection::Stable => TrendDirection::Stable,
|
||||||
|
},
|
||||||
|
daily_change: r.daily_change,
|
||||||
|
period_change: r.period_change,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 异常检测(使用 2.0 倍标准差阈值)
|
||||||
|
let anomaly_points = crate::service::trend_stats::detect_anomalies(&time_series, 2.0);
|
||||||
|
let anomalies: Vec<AnomalyInfo> = anomaly_points
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| AnomalyInfo {
|
||||||
|
date: a.date.to_string(),
|
||||||
|
value: a.value,
|
||||||
|
mean: a.mean,
|
||||||
|
std_dev: a.std_dev,
|
||||||
|
deviation: a.deviation,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let unit = match *metric_name {
|
||||||
|
"systolic_bp_morning" | "diastolic_bp_morning" => "mmHg",
|
||||||
|
"heart_rate" => "bpm",
|
||||||
|
"weight" => "kg",
|
||||||
|
"blood_sugar" => "mmol/L",
|
||||||
|
"body_temperature" => "°C",
|
||||||
|
"spo2" => "%",
|
||||||
|
"urine_output_ml" => "ml",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
metric_results.push(MetricTrendAnalysis {
|
||||||
|
metric: metric_name.to_string(),
|
||||||
|
unit: unit.to_string(),
|
||||||
|
data_point_count,
|
||||||
|
regression,
|
||||||
|
anomalies,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TrendAnalysisDto {
|
||||||
|
patient_id,
|
||||||
|
period_start: start_date.to_string(),
|
||||||
|
period_end: end_date.to_string(),
|
||||||
|
metrics: metric_results,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user