2025-12-05 09:22:23 +08:00

552 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log"
"math"
"sort"
"strings"
"time"
"weatherstation/core/internal/config"
"weatherstation/core/internal/data"
"weatherstation/core/internal/sms"
)
const (
providerForecast = "imdroid_mix"
alertTypeForecast = "forecast_3h_rain"
alertTypeActual30m = "actual_30m_rain"
alertTypeNeighbor30 = "actual_30m_neighbor"
levelRed = "red"
levelYellow = "yellow"
forecastRedMM = 8.0
forecastYellowMM = 4.0
actualRedMM = 4.0
actualYellowMM = 2.0
halfAngleDeg = 90.0
timeFormatShort = "2006-01-02 15:04"
defaultCheckTimeout = 20 * time.Second
)
var (
flagOnce bool
flagTest bool
flagTestStIDs string
flagTestTime string
flagWhy bool
)
func main() {
flag.BoolVar(&flagOnce, "once", false, "run checks once immediately (no scheduling)")
flag.BoolVar(&flagTest, "test", false, "force alerts regardless of thresholds (for dry-run)")
flag.StringVar(&flagTestStIDs, "station", "", "comma-separated station_id list for test mode")
flag.StringVar(&flagTestTime, "time", "", "test mode: specify end time (YYYY-MM-DD HH:MM:SS, CST)")
flag.BoolVar(&flagWhy, "why", false, "in test mode, log reasons when alert not triggered")
flag.Parse()
cfg := config.Load()
scli := mustInitSMS(cfg)
if flagOnce {
tick := time.Now()
runForecastCheck(scli, tick)
runActualCheck(scli, tick)
runNeighborActualCheck(scli, tick)
return
}
go alignAndRunHour10(func(tick time.Time) { runForecastCheck(scli, tick) })
go alignAndRunHalfHour(func(tick time.Time) { runActualCheck(scli, tick) })
go alignAndRunHalfHour(func(tick time.Time) { runNeighborActualCheck(scli, tick) })
select {}
}
func mustInitSMS(cfg config.Config) *sms.Client {
cli, err := sms.New(sms.Config{
AccessKeyID: strings.TrimSpace(cfg.SMS.AccessKeyID),
AccessKeySecret: strings.TrimSpace(cfg.SMS.AccessKeySecret),
SignName: strings.TrimSpace(cfg.SMS.SignName),
TemplateCode: strings.TrimSpace(cfg.SMS.TemplateCode),
Endpoint: strings.TrimSpace(cfg.SMS.Endpoint),
})
if err != nil {
log.Printf("sms: disabled (%v)", err)
return nil
}
return cli
}
func alignAndRunHour10(fn func(tick time.Time)) {
now := time.Now()
base := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 10, 0, 0, now.Location())
var next time.Time
if now.After(base) {
next = base.Add(time.Hour)
} else {
next = base
}
time.Sleep(time.Until(next))
for {
tick := time.Now().Truncate(time.Minute)
fn(tick)
time.Sleep(time.Hour)
}
}
func alignAndRunHalfHour(fn func(tick time.Time)) {
now := time.Now()
next := now.Truncate(30 * time.Minute).Add(30 * time.Minute)
time.Sleep(time.Until(next))
for {
tick := time.Now().Truncate(time.Minute)
fn(tick)
time.Sleep(30 * time.Minute)
}
}
func runForecastCheck(scli *sms.Client, tick time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), defaultCheckTimeout)
defer cancel()
stations := listFixedStations(ctx)
stations = filterStations(stations, flagTestStIDs)
if len(stations) == 0 {
log.Printf("forecast: no stations after filter")
return
}
recip := loadRecipients(ctx)
loc := mustShanghai()
now := tick.In(loc)
issued := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, loc)
next1 := issued.Add(time.Hour)
next3 := issued.Add(3 * time.Hour)
for _, st := range stations {
points, err := data.ForecastRainAtIssued(ctx, st.ID, providerForecast, issued)
if err != nil {
log.Printf("forecast: query station=%s err=%v", st.ID, err)
continue
}
var redMax, yellowMax int64
for _, p := range points {
if p.ForecastTime.Before(next1) || p.ForecastTime.After(next3) {
continue
}
v := int64(p.RainMMx1000)
if v >= 8000 {
if v > redMax {
redMax = v
}
} else if v >= 4000 {
if v > yellowMax {
yellowMax = v
}
}
}
level := ""
value := 0.0
threshold := 0.0
if redMax > 0 {
level = levelRed
value = float64(redMax) / 1000.0
threshold = forecastRedMM
} else if yellowMax > 0 {
level = levelYellow
value = float64(yellowMax) / 1000.0
threshold = forecastYellowMM
}
if level == "" {
if !flagTest {
if flagWhy {
log.Printf("forecast why: station=%s no threshold hit redMax=%d yellowMax=%d", st.ID, redMax, yellowMax)
}
continue
}
level = levelYellow
value = forecastYellowMM
threshold = forecastYellowMM
}
if flagTest {
msg := fmt.Sprintf("【测试】站点%s 强制触发未来3小时降水预警 level=%s", st.Name, levelLabel(level))
targetPhones := recip.forLevel(level)
if len(targetPhones) == 0 {
recordAlert(ctx, alertTypeForecast, st.ID, level, issued, msg, sql.NullString{})
} else {
sendToPhones(ctx, scli, st.Name, value, level, issued, targetPhones, msg, alertTypeForecast, st.ID)
}
continue
}
msg := fmt.Sprintf("站点%s 未来3小时单小时最大降水 %.3fmm,达到%s阈值 %.1fmmissued_at=%s", st.Name, value, levelLabel(level), threshold, issued.Format(timeFormatShort))
targetPhones := recip.forLevel(level)
if len(targetPhones) == 0 {
recordAlert(ctx, alertTypeForecast, st.ID, level, issued, msg, sql.NullString{})
continue
}
sendToPhones(ctx, scli, st.Name, value, level, issued, targetPhones, msg, alertTypeForecast, st.ID)
}
}
func runActualCheck(scli *sms.Client, tick time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), defaultCheckTimeout)
defer cancel()
stations := listFixedStations(ctx)
stations = filterStations(stations, flagTestStIDs)
if len(stations) == 0 {
log.Printf("actual: no stations after filter")
return
}
recip := loadRecipients(ctx)
loc := mustShanghai()
end := tick.In(loc)
if flagTestTime != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", flagTestTime, loc); err == nil {
end = t
}
}
start := end.Add(-30 * time.Minute)
for _, st := range stations {
rain, ok, err := data.SumRainMM(ctx, st.ID, start, end)
if err != nil {
log.Printf("actual: sum station=%s err=%v", st.ID, err)
continue
}
if flagWhy {
log.Printf("actual why: station=%s window=%s~%s rain_sum=%.3f ok=%v", st.ID, start.Format(timeFormatShort), end.Format(timeFormatShort), rain, ok)
}
if !ok {
if flagWhy {
log.Printf("actual why: station=%s no rain data", st.ID)
}
continue
}
level := ""
threshold := 0.0
if rain >= actualRedMM {
level = levelRed
threshold = actualRedMM
} else if rain >= actualYellowMM {
level = levelYellow
threshold = actualYellowMM
}
if level == "" {
if flagWhy {
log.Printf("actual why: station=%s rain=%.3f below threshold", st.ID, rain)
}
if !flagTest {
continue
}
level = levelYellow
threshold = actualYellowMM
rain = actualYellowMM
}
if flagTest {
msg := fmt.Sprintf("【测试】站点%s 强制触发30分钟降水预警 level=%s", st.Name, levelLabel(level))
targetPhones := recip.forLevel(level)
if len(targetPhones) == 0 {
recordAlert(ctx, alertTypeActual30m, st.ID, level, end, msg, sql.NullString{})
} else {
sendToPhones(ctx, scli, st.Name, rain, level, end, targetPhones, msg, alertTypeActual30m, st.ID)
}
continue
}
msg := fmt.Sprintf("站点%s 过去30分钟降水 %.3fmm,达到%s阈值 %.1fmm,窗口 %s - %s", st.Name, rain, levelLabel(level), threshold, start.Format(timeFormatShort), end.Format(timeFormatShort))
targetPhones := recip.forLevel(level)
if len(targetPhones) == 0 {
recordAlert(ctx, alertTypeActual30m, st.ID, level, end, msg, sql.NullString{})
log.Printf("actual: triggered station=%s level=%s rain=%.3f (no phones)", st.ID, level, rain)
continue
}
sendToPhones(ctx, scli, st.Name, rain, level, end, targetPhones, msg, alertTypeActual30m, st.ID)
log.Printf("actual: triggered station=%s level=%s rain=%.3f phones=%d", st.ID, level, rain, len(targetPhones))
}
}
func runNeighborActualCheck(scli *sms.Client, tick time.Time) {
ctx, cancel := context.WithTimeout(context.Background(), defaultCheckTimeout)
defer cancel()
allStations := listFixedStations(ctx)
centers := filterStations(allStations, flagTestStIDs)
if len(centers) == 0 {
if flagWhy {
log.Printf("neighbor why: no stations after filter")
}
return
}
recip := loadRecipients(ctx)
loc := mustShanghai()
end := tick.In(loc)
if flagTestTime != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", flagTestTime, loc); err == nil {
end = t
}
}
start := end.Add(-30 * time.Minute)
for _, center := range centers {
if center.Latitude == 0 || center.Longitude == 0 {
if flagWhy {
log.Printf("neighbor why: center %s missing lat/lon", center.ID)
}
continue
}
wind, err := data.RadarWeatherNearest(center.Latitude, center.Longitude, end, 6*time.Hour)
if err != nil {
log.Printf("neighbor: wind query failed station=%s: %v", center.ID, err)
continue
}
if wind == nil || !wind.WindDirection.Valid || !wind.WindSpeed.Valid || wind.WindSpeed.Float64 <= 0.01 {
if flagWhy {
log.Printf("neighbor why: center %s no wind data", center.ID)
}
continue
}
dir := wind.WindDirection.Float64
spd := wind.WindSpeed.Float64
radius := spd * 3600
for _, nb := range allStations {
if nb.ID == center.ID {
continue
}
if nb.Latitude == 0 || nb.Longitude == 0 {
if flagWhy {
log.Printf("neighbor why: neighbor %s missing lat/lon", nb.ID)
}
continue
}
dist := haversine(center.Latitude, center.Longitude, nb.Latitude, nb.Longitude)
brg := bearingDeg(center.Latitude, center.Longitude, nb.Latitude, nb.Longitude)
diff := angDiff(brg, dir)
inSector := dist <= radius && diff <= halfAngleDeg
if !inSector {
if flagWhy {
log.Printf("neighbor why: center=%s neighbor=%s not in sector dist=%.1fm radius=%.1fm bearing=%.1f windFrom=%.1f diff=%.1f half=%.1f",
center.ID, nb.ID, dist, radius, brg, dir, diff, halfAngleDeg)
}
continue
}
rain, ok, err := data.SumRainMM(ctx, nb.ID, start, end)
if err != nil {
log.Printf("neighbor: sum rain station=%s err=%v", nb.ID, err)
continue
}
if flagWhy {
log.Printf("neighbor why: center=%s neighbor=%s window=%s~%s rain_sum=%.3f ok=%v", center.ID, nb.ID, start.Format(timeFormatShort), end.Format(timeFormatShort), rain, ok)
}
if !ok {
if flagWhy {
log.Printf("neighbor why: neighbor %s no rain data", nb.ID)
}
continue
}
level := ""
threshold := 0.0
if rain >= actualRedMM {
level = levelRed
threshold = actualRedMM
} else if rain >= actualYellowMM {
level = levelYellow
threshold = actualYellowMM
}
if level == "" {
if flagWhy {
log.Printf("neighbor why: neighbor %s rain=%.3f below threshold", nb.ID, rain)
}
continue
}
atype := alertTypeNeighbor30 + "_" + nb.ID
msg := fmt.Sprintf("站点%s 迎风扇区内站点%s 30分钟降水 %.3fmm,达到%s阈值 %.1fmm,窗口 %s - %s", center.Name, nb.Name, rain, levelLabel(level), threshold, start.Format(timeFormatShort), end.Format(timeFormatShort))
targetPhones := recip.forLevel(level)
if len(targetPhones) == 0 {
recordAlert(ctx, atype, center.ID, level, end, msg, sql.NullString{})
log.Printf("neighbor: center=%s neighbor=%s level=%s rain=%.3f (no phones)", center.ID, nb.ID, level, rain)
continue
}
sendToPhones(ctx, scli, center.Name, rain, level, end, targetPhones, msg, atype, center.ID)
log.Printf("neighbor: center=%s neighbor=%s level=%s rain=%.3f phones=%d", center.ID, nb.ID, level, rain, len(targetPhones))
}
}
}
func recordAlert(ctx context.Context, alertType, stationID, level string, issuedAt time.Time, message string, phone sql.NullString) {
_, err := data.InsertAlert(ctx, data.AlertRecord{
AlertType: alertType,
StationID: stationID,
Level: level,
IssuedAt: issuedAt,
Message: message,
SMSPhone: phone,
})
if err != nil {
log.Printf("alert insert failed station=%s type=%s level=%s: %v", stationID, alertType, level, err)
}
}
type recipients struct {
red []string
yel []string
}
func (r recipients) forLevel(level string) []string {
if level == levelRed {
return r.red
}
if level == levelYellow {
return r.yel
}
return nil
}
func loadRecipients(ctx context.Context) recipients {
list, err := data.ListEnabledSMSRecipients(ctx)
if err != nil {
log.Printf("sms: load recipients failed: %v", err)
return recipients{}
}
var res recipients
for _, r := range list {
if r.AlertLevel >= 1 {
res.red = append(res.red, r.Phone)
}
if r.AlertLevel >= 2 {
res.yel = append(res.yel, r.Phone)
}
}
return res
}
func sendToPhones(ctx context.Context, scli *sms.Client, stationName string, value float64, level string, issuedAt time.Time, phones []string, message string, alertType string, stationID string) {
if scli == nil {
return
}
name := "" + stationName + ""
content := format3(value) + " mm"
alertText := "【大礼村】暴雨"
if level == levelRed {
alertText += "红色预警"
} else {
alertText += "黄色预警"
}
for _, ph := range phones {
if err := scli.Send(ctx, name, content, alertText, "", []string{ph}); err != nil {
log.Printf("sms: send failed phone=%s station=%s level=%s: %v", ph, stationID, level, err)
continue
}
recordAlert(ctx, alertType, stationID, level, issuedAt, message, sql.NullString{String: ph, Valid: true})
log.Printf("sms: sent phone=%s station=%s level=%s", ph, stationID, level)
}
}
func format3(v float64) string {
s := fmt.Sprintf("%.3f", v)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
if s == "" {
return "0"
}
return s
}
func mustShanghai() *time.Location {
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.FixedZone("CST", 8*3600)
}
return loc
}
func levelLabel(level string) string {
if level == levelRed {
return "红色"
}
return "黄色"
}
func listFixedStations(ctx context.Context) []data.StationInfo {
ids := []string{"RS485-002964", "RS485-002A39", "RS485-0029CB"}
sts, err := data.ListStationsByIDs(ctx, ids)
if err != nil || len(sts) == 0 {
var out []data.StationInfo
for _, id := range ids {
out = append(out, data.StationInfo{ID: id, Name: id})
}
return out
}
// 保证顺序按 ids
order := make(map[string]int)
for i, id := range ids {
order[id] = i
}
sort.Slice(sts, func(i, j int) bool { return order[sts[i].ID] < order[sts[j].ID] })
return sts
}
func filterStations(in []data.StationInfo, filter string) []data.StationInfo {
f := strings.TrimSpace(filter)
if f == "" {
return in
}
parts := strings.Split(f, ",")
m := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
m[p] = struct{}{}
}
}
if len(m) == 0 {
return in
}
var out []data.StationInfo
for _, st := range in {
if _, ok := m[st.ID]; ok {
out = append(out, st)
}
}
return out
}
func toRad(d float64) float64 { return d * math.Pi / 180 }
func toDeg(r float64) float64 { return r * 180 / math.Pi }
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371000.0
dLat := toRad(lat2 - lat1)
dLon := toRad(lon2 - lon1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
}
func bearingDeg(lat1, lon1, lat2, lon2 float64) float64 {
φ1 := toRad(lat1)
φ2 := toRad(lat2)
Δλ := toRad(lon2 - lon1)
y := math.Sin(Δλ) * math.Cos(φ2)
x := math.Cos(φ1)*math.Sin(φ2) - math.Sin(φ1)*math.Cos(φ2)*math.Cos(Δλ)
brg := toDeg(math.Atan2(y, x))
if brg < 0 {
brg += 360
}
return brg
}
func angDiff(a, b float64) float64 {
d := math.Mod(a-b+540, 360) - 180
if d < 0 {
d = -d
}
return math.Abs(d)
}