Files
silk/server-go/internal/service/rule_engine.go
T

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
}