151 lines
4.5 KiB
Go
151 lines
4.5 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// TelemetrySample 规则引擎遥测样本。
|
|
type TelemetrySample struct {
|
|
Metric string `json:"metric"`
|
|
Value float64 `json:"value"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
// RuleContext 规则输入;缺失项用 nil 表示,不填 0。
|
|
type RuleContext struct {
|
|
Stage string `json:"stage,omitempty"`
|
|
Now time.Time `json:"now"`
|
|
Samples []TelemetrySample `json:"samples"`
|
|
Density *float64 `json:"density,omitempty"`
|
|
Ventilation *bool `json:"ventilation,omitempty"`
|
|
SeedSourceCount *int `json:"seedSourceCount,omitempty"`
|
|
DisinfectionCount *int `json:"disinfectionCount,omitempty"`
|
|
}
|
|
|
|
// RuleVersion 可版本化规则。
|
|
type RuleVersion struct {
|
|
Version string `json:"version"`
|
|
Name string `json:"name"`
|
|
Disease string `json:"disease"`
|
|
WindowHours int `json:"windowHours"`
|
|
MaxStaleness time.Duration `json:"-"`
|
|
RequiresDensity bool `json:"-"`
|
|
RequiresBiosecurity bool `json:"-"`
|
|
}
|
|
|
|
// RuleResult 规则输出,保留版本、输入快照、缺失项和计算时间。
|
|
type RuleResult struct {
|
|
RuleVersion string `json:"ruleVersion"`
|
|
RuleName string `json:"ruleName"`
|
|
Disease string `json:"disease"`
|
|
Matched bool `json:"matched"`
|
|
Level string `json:"level,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Missing []string `json:"missing"`
|
|
InputSnapshot json.RawMessage `json:"inputSnapshot"`
|
|
ComputedAt time.Time `json:"computedAt"`
|
|
}
|
|
|
|
// EvaluateRules 按版本执行规则,所有缺失输入进入 Missing。
|
|
func EvaluateRules(ctx RuleContext, rules []RuleVersion) []RuleResult {
|
|
if ctx.Now.IsZero() {
|
|
ctx.Now = time.Now()
|
|
}
|
|
results := make([]RuleResult, 0, len(rules))
|
|
input, _ := json.Marshal(ctx)
|
|
for _, rule := range rules {
|
|
missing := ruleMissing(ctx, rule)
|
|
result := RuleResult{
|
|
RuleVersion: rule.Version,
|
|
RuleName: rule.Name,
|
|
Disease: rule.Disease,
|
|
Missing: missing,
|
|
InputSnapshot: input,
|
|
ComputedAt: ctx.Now,
|
|
}
|
|
if len(missing) == 0 {
|
|
switch rule.Name {
|
|
case "continuous_humidity":
|
|
result.Matched, result.Level, result.Reason = evaluateContinuousHumidity(ctx, rule)
|
|
default:
|
|
result.Matched = false
|
|
result.Reason = "未配置规则实现"
|
|
}
|
|
}
|
|
results = append(results, result)
|
|
}
|
|
return results
|
|
}
|
|
|
|
func ruleMissing(ctx RuleContext, rule RuleVersion) []string {
|
|
missing := []string{}
|
|
hasHumidity := false
|
|
hasStaleHumidity := false
|
|
for _, sample := range ctx.Samples {
|
|
if sample.Metric != "humidity" {
|
|
continue
|
|
}
|
|
if ctx.Now.Sub(sample.Timestamp) > rule.MaxStaleness {
|
|
hasStaleHumidity = true
|
|
continue
|
|
}
|
|
hasHumidity = true
|
|
}
|
|
if rule.Name == "continuous_humidity" {
|
|
if hasStaleHumidity && !hasHumidity {
|
|
missing = append(missing, "humidity_stale")
|
|
}
|
|
if !hasHumidity {
|
|
missing = append(missing, "humidity")
|
|
}
|
|
}
|
|
if rule.RequiresDensity && ctx.Density == nil {
|
|
missing = append(missing, "density")
|
|
}
|
|
if rule.RequiresBiosecurity {
|
|
if ctx.SeedSourceCount == nil {
|
|
missing = append(missing, "seed_source")
|
|
}
|
|
if ctx.DisinfectionCount == nil {
|
|
missing = append(missing, "disinfection")
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
func evaluateContinuousHumidity(ctx RuleContext, rule RuleVersion) (bool, string, string) {
|
|
byDay := map[string]float64{}
|
|
for _, sample := range ctx.Samples {
|
|
if sample.Metric != "humidity" {
|
|
continue
|
|
}
|
|
day := sample.Timestamp.Format("2006-01-02")
|
|
if sample.Value > byDay[day] {
|
|
byDay[day] = sample.Value
|
|
}
|
|
}
|
|
days := make([]string, 0, len(byDay))
|
|
for day := range byDay {
|
|
days = append(days, day)
|
|
}
|
|
sort.Strings(days)
|
|
for i := 2; i < len(days); i++ {
|
|
if consecutiveDays(days[i-2], days[i-1], days[i]) && byDay[days[i-2]] >= 80 && byDay[days[i-1]] >= 80 && byDay[days[i]] >= 80 {
|
|
return true, "orange", fmt.Sprintf("连续 %d 天湿度≥80%%,满足规则 %s", rule.WindowHours, rule.Version)
|
|
}
|
|
}
|
|
return false, "", "连续高湿天数不足或窗口内存在缺失"
|
|
}
|
|
|
|
func consecutiveDays(a, b, c string) bool {
|
|
parse := func(s string) time.Time {
|
|
t, _ := time.Parse("2006-01-02", s)
|
|
return t
|
|
}
|
|
da, db, dc := parse(a), parse(b), parse(c)
|
|
return db.Sub(da) == 24*time.Hour && dc.Sub(db) == 24*time.Hour
|
|
}
|