feat(server-go): 风险评分引擎(#9,公式/分级/阶段与环境系数)接入巡检
This commit is contained in:
@@ -112,6 +112,23 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
|||||||
} else {
|
} else {
|
||||||
raw, _ := json.Marshal(detRes.Detections)
|
raw, _ := json.Marshal(detRes.Detections)
|
||||||
rec.Detections = raw
|
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 {
|
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 过滤)
|
// listInspections 巡检记录列表(roomId/limit 过滤)
|
||||||
func listInspections(db *gorm.DB) gin.HandlerFunc {
|
func listInspections(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user