From 93301f0bf3d65d594f8a3c7cc9e1ddc569ecc414 Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 17:02:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(server-go):=20=E9=A3=8E=E9=99=A9=E8=AF=84?= =?UTF-8?q?=E5=88=86=E5=BC=95=E6=93=8E=EF=BC=88#9=EF=BC=8C=E5=85=AC?= =?UTF-8?q?=E5=BC=8F/=E5=88=86=E7=BA=A7/=E9=98=B6=E6=AE=B5=E4=B8=8E?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E7=B3=BB=E6=95=B0=EF=BC=89=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=B7=A1=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-go/internal/handler/inspection.go | 55 +++++++++++++++ server-go/internal/service/risk.go | 69 +++++++++++++++++++ server-go/internal/service/risk_test.go | 85 ++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 server-go/internal/service/risk.go create mode 100644 server-go/internal/service/risk_test.go diff --git a/server-go/internal/handler/inspection.go b/server-go/internal/handler/inspection.go index 812dbbd..d60e0b2 100644 --- a/server-go/internal/handler/inspection.go +++ b/server-go/internal/handler/inspection.go @@ -112,6 +112,23 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, } else { raw, _ := json.Marshal(detRes.Detections) rec.Detections = raw + + // 风险评分(#9):AI 置信度取检测结果最大值;环境/阶段系数在有 roomId 时按房间数据计算 + aiConf := 0.0 + for _, d := range detRes.Detections { + if d.Confidence > aiConf { + aiConf = d.Confidence + } + } + stageCoef, envCoef := loadRoomRisk(db, roomID) + score := service.ComputeRiskScore(service.RiskInput{ + AI: aiConf, + Env: envCoef, + Stage: stageCoef, + }) + rec.RiskScore = &score + level := service.RiskLevel(score) + rec.RiskLevel = &level } if err := db.Create(&rec).Error; err != nil { @@ -130,6 +147,44 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, } } +// loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 0) +func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) { + if roomID == "" { + return 0, 0 + } + var room model.Room + if db.Where("id = ?", roomID).First(&room).Error != nil { + return 0, 0 + } + if room.Stage != nil { + stageCoef = service.StageCoefficient(*room.Stage) + } + + var humidity, temperature *float64 + var h model.Telemetry + if err := db.Table("telemetry"). + Select("telemetry.value"). + Joins("JOIN devices ON devices.device_key = telemetry.device_key AND devices.room_id = ?", roomID). + Where("telemetry.metric = ?", "humidity"). + Order("telemetry.timestamp DESC"). + Limit(1). + First(&h).Error; err == nil { + humidity = &h.Value + } + var t model.Telemetry + if err := db.Table("telemetry"). + Select("telemetry.value"). + Joins("JOIN devices ON devices.device_key = telemetry.device_key AND devices.room_id = ?", roomID). + Where("telemetry.metric = ?", "temperature"). + Order("telemetry.timestamp DESC"). + Limit(1). + First(&t).Error; err == nil { + temperature = &t.Value + } + envCoef = service.EnvCoefficient(temperature, humidity) + return stageCoef, envCoef +} + // listInspections 巡检记录列表(roomId/limit 过滤) func listInspections(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/server-go/internal/service/risk.go b/server-go/internal/service/risk.go new file mode 100644 index 0000000..5b054f9 --- /dev/null +++ b/server-go/internal/service/risk.go @@ -0,0 +1,69 @@ +package service + +import "math" + +// RiskInput 风险评分输入(各系数取值 0~1) +type RiskInput struct { + AI float64 // AI 识别置信度(权重 0.5) + Env float64 // 环境风险系数(权重 0.2) + Stage float64 // 饲养阶段风险系数(权重 0.15) + Uniformity float64 // 群体整齐度偏离度(权重 0.15) +} + +// ComputeRiskScore 按规格书 3.1.4 公式计算 0~100 风险分: +// 风险分 = 0.5×AI置信度 + 0.2×环境系数 + 0.15×阶段系数 + 0.15×整齐度偏离度 +func ComputeRiskScore(in RiskInput) float64 { + score := 0.5*in.AI + 0.2*in.Env + 0.15*in.Stage + 0.15*in.Uniformity + score = math.Max(0, math.Min(1, score)) + return score * 100 +} + +// RiskLevel 按规格书 3.1.4 分级:绿 0-30 / 黄 31-60 / 橙 61-80 / 红 81-100 +func RiskLevel(score float64) string { + switch { + case score <= 30: + return "green" + case score <= 60: + return "yellow" + case score <= 80: + return "orange" + default: + return "red" + } +} + +// StageCoefficient 蚕房阶段 → 阶段风险系数(Room.Stage 粗粒度映射; +// 待 #7 蚕匾/批次管理的龄期字段落地后细化) +func StageCoefficient(stage string) float64 { + switch stage { + case "pupa": + return 0.5 // 核型多角体病 5龄后期至蛹期高发 + case "larva": + return 0.4 // 软化病 5 龄集中暴发等 + case "moth": + return 0.2 + case "egg": + return 0.1 + default: + return 0 + } +} + +// EnvCoefficient 由最新温湿度计算环境风险系数(规则取自规格书 3.2.3) +func EnvCoefficient(temp, humidity *float64) float64 { + coef := 0.0 + if humidity != nil { + switch { + case *humidity >= 80: + coef = math.Max(coef, 0.8) // 白僵病等真菌病高湿条件 + case *humidity >= 75: + coef = math.Max(coef, 0.5) + } + } + if temp != nil { + if *temp > 30 || *temp < 20 { + coef = math.Max(coef, 0.5) // 温度突变诱发核型多角体病 + } + } + return coef +} diff --git a/server-go/internal/service/risk_test.go b/server-go/internal/service/risk_test.go new file mode 100644 index 0000000..0fa67bd --- /dev/null +++ b/server-go/internal/service/risk_test.go @@ -0,0 +1,85 @@ +package service + +import "testing" + +func f(v float64) *float64 { return &v } + +func TestComputeRiskScoreWeights(t *testing.T) { + // 全 1:0.5*1 + 0.2*1 + 0.15*1 + 0.15*1 = 1 → 100 + if s := ComputeRiskScore(RiskInput{AI: 1, Env: 1, Stage: 1, Uniformity: 1}); s != 100 { + t.Errorf("全 1 应得 100,实际 %.2f", s) + } + // 仅 AI 置信度 1:0.5*1 = 0.5 → 50 + if s := ComputeRiskScore(RiskInput{AI: 1}); s != 50 { + t.Errorf("仅 AI=1 应得 50,实际 %.2f", s) + } + // 0.5*0.8 + 0.2*0.5 = 0.5 + if s := ComputeRiskScore(RiskInput{AI: 0.8, Env: 0.5}); s != 50 { + t.Errorf("0.8/0.5 应得 50,实际 %.2f", s) + } +} + +func TestComputeRiskScoreClamps(t *testing.T) { + if s := ComputeRiskScore(RiskInput{AI: 2, Env: 2, Stage: 2, Uniformity: 2}); s > 100 { + t.Errorf("应钳制到 100,实际 %.2f", s) + } + if s := ComputeRiskScore(RiskInput{AI: -1}); s < 0 { + t.Errorf("应钳制到 0,实际 %.2f", s) + } +} + +func TestRiskLevelBoundaries(t *testing.T) { + cases := map[float64]string{ + 0: "green", + 30: "green", + 30.5: "yellow", + 60: "yellow", + 61: "orange", + 80: "orange", + 81: "red", + 100: "red", + } + for score, want := range cases { + if got := RiskLevel(score); got != want { + t.Errorf("RiskLevel(%.1f) = %s, want %s", score, got, want) + } + } +} + +func TestStageCoefficientMapping(t *testing.T) { + if StageCoefficient("pupa") <= 0 { + t.Error("蛹期应有风险系数") + } + if StageCoefficient("") != 0 { + t.Error("空阶段应为 0") + } + if StageCoefficient("unknown") != 0 { + t.Error("未知阶段应为 0") + } + if StageCoefficient("pupa") <= StageCoefficient("egg") { + t.Error("蛹期系数应高于卵期") + } +} + +func TestEnvCoefficientRules(t *testing.T) { + // 湿度 >=80 → 高(真菌病) + if c := EnvCoefficient(f(25), f(85)); c < 0.7 { + t.Errorf("湿度 85 应 ≥0.7,实际 %.2f", c) + } + // 湿度 75-80 → 中 + if c := EnvCoefficient(f(25), f(78)); c < 0.3 { + t.Errorf("湿度 78 应 ≥0.3,实际 %.2f", c) + } + // 温度突变 >30 → 中(核型多角体病诱发) + if c := EnvCoefficient(f(32), f(60)); c < 0.3 { + t.Errorf("温度 32 应 ≥0.3,实际 %.2f", c) + } + // 舒适环境 → 0 + if c := EnvCoefficient(f(25), f(60)); c != 0 { + t.Errorf("舒适环境应为 0,实际 %.2f", c) + } + // 缺数据 → 0 + if c := EnvCoefficient(nil, nil); c != 0 { + t.Errorf("无数据应为 0,实际 %.2f", c) + } +}