Files
hr_data_analyzer/controllers/train.go
T
2026-05-04 16:20:46 +08:00

542 lines
15 KiB
Go

package controllers
import (
"gonum.org/v1/gonum/floats"
"gonum.org/v1/gonum/stat"
"gonum.org/v1/gonum/stat/distuv"
)
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"hr_receiver/config"
"hr_receiver/models"
"math"
"net/http"
)
var analyzeRunTypes = []string{"6.5开始", "7开始", "8开始"} // 替换为你的具体值
func contains(s string) bool {
for _, item := range analyzeRunTypes {
if item == s {
return true
}
}
return false
}
type TrainingController struct {
DB *gorm.DB
}
func NewTrainingController() *TrainingController {
return &TrainingController{DB: config.DB}
}
// @Summary 创建训练记录
// @Description 接收并保存训练记录及心率数据,支持重复上传(按train_id去重更新)
// @Tags 训练管理
// @Accept json
// @Produce json
// @Param record body SwagAPIResponse true "训练记录数据"
// @Success 201 {object} SwagAPIResponse "保存成功"
// @Failure 400 {object} SwagAPIResponse "请求参数错误"
// @Router /train-records [post]
func (tc *TrainingController) CreateTrainingRecord(c *gin.Context) {
var record models.TrainRecord
// 绑定并验证JSON数据
if err := c.ShouldBindJSON(&record); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 使用事务保存数据[4](@ref)
err := tc.DB.Transaction(func(tx *gorm.DB) error {
// 保存主记录
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "train_id"}}, // 指定冲突的列
DoUpdates: clause.Assignments(map[string]interface{}{
"max_heart_rate": record.MaxHeartRate,
"start_time": record.StartTime,
"end_time": record.EndTime,
"duration": record.Duration,
"people_num": record.PeopleNum,
"name": record.Name,
"evaluation": record.Evaluation,
}),
}).Omit("HeartRates", "belts").Create(&record).Error; err != nil {
return err
}
// 保存关联的心率数据
for i := range record.HeartRates {
if err := tx.Clauses(
clause.OnConflict{
Columns: []clause.Column{{Name: "identifier"}}, // 指定冲突的列
DoUpdates: clause.Assignments(map[string]interface{}{"value": record.HeartRates[i].Value, "time": record.HeartRates[i].Time}),
},
).Create(&record.HeartRates[i]).Error; err != nil {
return err
}
}
if contains(record.RunType) {
err := tc.heartRateAnalyze(tx, record)
if err != nil {
return err
}
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "数据保存成功",
"id": record.TrainId,
})
}
type trainingSessionRequest struct {
Tid int `json:"tid"`
Time int64 `json:"time"`
TestTime int64 `json:"testTime"`
EndTime int64 `json:"endTime"`
Name string `json:"name"`
RunType string `json:"runType"`
Gender string `json:"gender"`
Age int `json:"age"`
MaxHeartRate int `json:"maxHeartRate"`
Duration int `json:"duration"`
PeopleNum int `json:"peopleNum"`
Evaluation string `json:"evaluation"`
AiResult string `json:"aiResult"`
IsStart bool `json:"isStart"`
RegionID uint32 `json:"regionId"`
AppName string `json:"appName"`
}
// @Summary 上传训练会话
// @Description 上传训练开始/结束会话,用于MQTT训练会话追踪
// @Tags 训练管理
// @Accept json
// @Produce json
// @Param session body trainingSessionRequest true "训练会话数据"
// @Success 200 {object} SwagAPIResponse "操作成功"
// @Failure 400 {object} SwagAPIResponse "请求参数错误"
// @Router /train-records/session [post]
func (tc *TrainingController) UploadTrainingSession(c *gin.Context) {
var req trainingSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, http.StatusBadRequest, err.Error())
return
}
trainID := fmt.Sprintf("%d", req.Time)
now := time.Now().UnixMilli()
flavorType := "heartrate"
identifier := schema.NamingStrategy{}.IndexName(
"mqtt_training_session",
fmt.Sprintf("%s_%d_%s", flavorType, req.RegionID, trainID),
)
rawPayload, _ := json.Marshal(req)
if req.IsStart {
record := &models.MqttTrainingSessionRecord{
Identifier: identifier,
TestID: trainID,
EventType: "start_test",
RegionID: req.RegionID,
FlavorType: flavorType,
RawFlavor: "hr",
AppName: req.AppName,
TrainId: trainID,
PeopleNum: req.PeopleNum,
StartedAt: &req.Time,
PublishedAt: now,
ReceivedAt: now,
RawPayload: string(rawPayload),
}
err := tc.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "identifier"}},
DoUpdates: clause.Assignments(map[string]interface{}{"started_at": *record.StartedAt, "train_id": record.TrainId, "updated_at": time.Now()}),
}).Create(record).Error
if err != nil {
writeError(c, http.StatusInternalServerError, "failed to save session")
return
}
writeSuccess(c, http.StatusOK, "session start registered", nil)
return
}
var existing models.MqttTrainingSessionRecord
err := tc.DB.Where("train_id = ?", trainID).First(&existing).Error
if err != nil {
record := &models.MqttTrainingSessionRecord{
Identifier: identifier,
TestID: trainID,
EventType: "stop_test",
RegionID: req.RegionID,
FlavorType: flavorType,
RawFlavor: "hr",
AppName: req.AppName,
TrainId: trainID,
PeopleNum: req.PeopleNum,
StartedAt: &req.Time,
EndedAt: &req.EndTime,
PublishedAt: now,
ReceivedAt: now,
RawPayload: string(rawPayload),
}
err = tc.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "identifier"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"ended_at": *record.EndedAt,
"people_num": record.PeopleNum,
"event_type": record.EventType,
"updated_at": time.Now(),
}),
}).Create(record).Error
if err != nil {
writeError(c, http.StatusInternalServerError, "failed to create session")
return
}
writeSuccess(c, http.StatusOK, "session created", nil)
return
}
updates := map[string]interface{}{
"event_type": "stop_test",
"ended_at": req.EndTime,
"people_num": req.PeopleNum,
"received_at": now,
"raw_payload": string(rawPayload),
"updated_at": time.Now(),
}
if err := tc.DB.Model(&existing).Updates(updates).Error; err != nil {
writeError(c, http.StatusInternalServerError, "failed to update session")
return
}
writeSuccess(c, http.StatusOK, "session updated", nil)
}
type cloudLessonPlanItem struct {
ID uint `json:"id"`
OriginalFilename string `json:"originalFilename"`
FileSize int64 `json:"fileSize"`
UploaderName string `json:"uploaderName"`
}
// @Summary 获取云端教案列表
// @Description 获取所有云端教案文件列表
// @Tags 训练管理
// @Produce json
// @Success 200 {object} SwagAPIResponse "查询成功"
// @Router /train-records/cloud-files [get]
func (tc *TrainingController) ListCloudLessonPlans(c *gin.Context) {
var records []models.AppFile
if err := tc.DB.Where("file_type = ?", models.AppFileTypeLessonPlan).Order("created_at DESC").Find(&records).Error; err != nil {
writeError(c, http.StatusInternalServerError, "failed to list lesson plans")
return
}
items := make([]cloudLessonPlanItem, 0, len(records))
for _, r := range records {
items = append(items, cloudLessonPlanItem{
ID: r.ID,
OriginalFilename: r.OriginalFilename,
FileSize: r.FileSize,
UploaderName: r.UploaderName,
})
}
writeSuccess(c, http.StatusOK, "query success", items)
}
// analysis_response.go
type AnalysisResponse struct {
Status string `json:"status"` // 状态码
Message string `json:"message"` // 附加信息
Data struct {
Mean float64 `json:"mean"` // 均值
StdDev float64 `json:"stdDev"` // 标准差
Histogram []HistoBin `json:"histogram"` // 直方图数据
Curve []CurvePoint `json:"curve"` // 正态曲线数据
} `json:"data"`
}
type HistoBin struct {
BinStart float64 `json:"binStart"` // 区间起始值
BinEnd float64 `json:"binEnd"` // 区间结束值
Count int `json:"count"` // 该区间计数
}
type CurvePoint struct {
X float64 `json:"x"` // X坐标
Y float64 `json:"y"` // Y坐标
}
// @Summary 心率曲线分析
// @Description 对历史心率数据进行统计分析和正态分布曲线拟合
// @Tags 训练管理
// @Produce json
// @Success 200 {object} SwagAPIResponse "分析成功"
// @Failure 400 {object} SwagAPIResponse "数据量不足"
// @Router /train-records/analysis [get]
func (tc *TrainingController) HandleCurveAnalysis(c *gin.Context) {
// 获取数据库连接(根据实际项目配置调整)
// 1. 获取历史数据
aValues, err := collectCurveParams(tc.DB)
if err != nil {
c.JSON(500, gin.H{
"status": "error",
"message": "数据查询失败: " + err.Error(),
})
return
}
// 2. 检查数据有效性
if len(aValues) < 10 { // 至少需要10个样本
c.JSON(400, gin.H{
"status": "fail",
"message": "数据量不足,至少需要10个样本",
})
return
}
// 3. 计算统计量
mean, stddev := calculateStats(aValues)
// 4. 生成直方图数据
histogram := calculateHistogram(aValues, 20) // 20个分箱
// 5. 生成正态曲线
x, y := generateNormalCurve(mean, stddev, 100)
// 6. 构造响应
response := AnalysisResponse{
Status: "success",
Message: "分析完成",
Data: struct {
Mean float64 `json:"mean"`
StdDev float64 `json:"stdDev"`
Histogram []HistoBin `json:"histogram"`
Curve []CurvePoint `json:"curve"`
}{
Mean: mean,
StdDev: stddev,
Histogram: histogram,
Curve: convertToCurvePoints(x, y),
},
}
c.JSON(200, response)
}
// 直方图计算函数
func calculateHistogram(data []float64, bins int) []HistoBin {
minV, maxV := floats.Min(data), floats.Max(data)
binWidth := (maxV - minV) / float64(bins)
counts := make([]int, bins)
for _, v := range data {
idx := int((v - minV) / binWidth)
if idx == bins { // 处理最大值刚好等于maxV的情况
idx--
}
counts[idx]++
}
histogram := make([]HistoBin, bins)
for i := 0; i < bins; i++ {
start := minV + float64(i)*binWidth
end := minV + float64(i+1)*binWidth
histogram[i] = HistoBin{
BinStart: start,
BinEnd: end,
Count: counts[i],
}
}
return histogram
}
// 转换曲线数据格式
func convertToCurvePoints(x, y []float64) []CurvePoint {
points := make([]CurvePoint, len(x))
for i := range x {
points[i] = CurvePoint{
X: x[i],
Y: y[i],
}
}
return points
}
func (tc *TrainingController) heartRateAnalyze(tx *gorm.DB, record models.TrainRecord) error {
var startTime int64
if record.TestTime > 0 {
startTime = record.TestTime
} else {
startTime = record.StartTime
}
// 获取所有唯一的beltID
var beltIDs []uint
tx.Model(&models.HeartRate{}).Where("train_id = ?", record.TrainId).
Select("DISTINCT belt_id").Pluck("belt_id", &beltIDs)
// 对每个belt计算
for _, bid := range beltIDs {
// 计算平均心率
ranges := getTimeRanges(startTime)
averages, err := calculateAverages(tx, record.TrainId, bid, ranges)
if err != nil {
return err
}
// 曲线拟合
x := []float64{2, 4, 6}
y := []float64{averages["2min"], averages["4min"], averages["6min"]}
a, b, _ := quadraticFit(x, y)
// 存储结果
analysis := models.BeltAnalysis{
TrainID: record.TrainId,
RunType: record.RunType,
BeltID: bid,
Avg2min: averages["2min"],
Avg4min: averages["4min"],
Avg6min: averages["6min"],
CurveParamA: a,
CurveParamB: b,
}
if err := tx.Create(&analysis).Error; err != nil {
return err
}
}
return nil
}
func collectCurveParams(tx *gorm.DB) ([]float64, error) {
var aValues []float64
// 查询所有记录的 CurveParamA 字段
err := tx.Model(&models.BeltAnalysis{}).Pluck("curve_param_a", &aValues).Error
if err != nil {
return nil, err
}
return aValues, nil
}
func calculateStats(data []float64) (mean, stddev float64) {
mean = stat.Mean(data, nil)
variance := stat.Variance(data, nil)
stddev = math.Sqrt(variance)
return
}
func generateNormalCurve(mean, stddev float64, numPoints int) (x, y []float64) {
normal := distuv.Normal{
Mu: mean,
Sigma: stddev,
}
minV := mean - 3*stddev // 从均值-3σ开始
maxV := mean + 3*stddev // 到均值+3σ结束
step := (maxV - minV) / float64(numPoints-1)
for i := 0; i < numPoints; i++ {
xi := minV + float64(i)*step
yi := normal.Prob(xi)
x = append(x, xi)
y = append(y, yi)
}
return
}
func calculateAverages(tx *gorm.DB, trainID uint, beltID uint, ranges map[string]TimeRange) (map[string]float64, error) {
averages := make(map[string]float64)
for key, tr := range ranges {
var avg float64
// 使用GORM Raw SQL提高效率[6,10](@ref)
err := tx.Raw(`
SELECT COALESCE(AVG(value), 0) AS avg -- 关键修复
FROM heart_rates
WHERE train_id = ?
AND belt_id = ?
AND time BETWEEN ? AND ?`,
trainID, beltID, tr.Start, tr.End,
).Scan(&avg).Error
if err != nil {
return nil, err
}
averages[key] = avg
}
return averages, nil
}
//func quadraticFit(x []float64, y []float64) (float64, error) {
// // 使用三点计算y=ax²+b的a值(x=[2,4,6]对应分钟)
// if len(x) != 3 || len(y) != 3 {
// return 0, errors.New("需要三个点")
// }
// // 构造方程组矩阵(简化计算)
// a := (y[2] - 2*y[1] + y[0]) / (x[2]*x[2] - 2*x[1]*x[1] + x[0]*x[0])
// return a, nil
//}
func quadraticFit(x []float64, y []float64) (float64, float64, error) {
// 校验输入长度
if len(x) != 3 || len(y) != 3 {
return 0, 0, errors.New("需要三个点")
}
// 计算各项累加值
var sumX4, sumX2, sumY, sumX2Y float64
for i := 0; i < 3; i++ {
xi := x[i]
xi2 := xi * xi
sumX4 += xi2 * xi2 // x^4累加
sumX2 += xi2 // x^2累加
sumY += y[i] // y累加
sumX2Y += xi2 * y[i] // x²y累加
}
// 计算行列式
determinant := sumX4*3 - sumX2*sumX2
if determinant == 0 {
return 0, 0, errors.New("无解,行列式为零")
}
// 计算系数 a 和 b
a := (sumX2Y*3 - sumY*sumX2) / determinant
b := (sumX4*sumY - sumX2*sumX2Y) / determinant
return a, b, nil
}
type TimeRange struct {
Start int64 // 毫秒时间戳起点
End int64 // 毫秒时间戳终点
}
func getTimeRanges(startTime int64) map[string]TimeRange {
// 计算相对于训练开始时间的窗口
return map[string]TimeRange{
"2min": {Start: startTime + 120000, End: startTime + 240000}, // 第2分钟(120-240秒)
"4min": {Start: startTime + 240000, End: startTime + 360000},
"6min": {Start: startTime + 360000, End: startTime + 480000},
}
}