148 lines
4.1 KiB
Go
148 lines
4.1 KiB
Go
package alerting
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"time"
|
|
|
|
"dynatron.me/x/stillbox/internal/forms"
|
|
"dynatron.me/x/stillbox/internal/jsontime"
|
|
"dynatron.me/x/stillbox/internal/trending"
|
|
"dynatron.me/x/stillbox/pkg/config"
|
|
"dynatron.me/x/stillbox/pkg/talkgroups"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// A Simulation simulates what happens to the alerter during a specified time period using past data from the database.
|
|
type Simulation struct {
|
|
// normal Alerting config
|
|
config.Alerting
|
|
|
|
// ScoreStart is the time when scoring begins
|
|
ScoreStart jsontime.Time `json:"scoreStart" yaml:"scoreStart" form:"scoreStart"`
|
|
// ScoreEnd is the time when the score simulator ends. Left blank, it defaults to time.Now()
|
|
ScoreEnd jsontime.Time `json:"scoreEnd" yaml:"scoreEnd" form:"scoreEnd"`
|
|
|
|
// SimInterval is the interval at which the scorer will be called
|
|
SimInterval jsontime.Duration `json:"simInterval" yaml:"simInterval" form:"simInterval"`
|
|
|
|
clock offsetClock `json:"-"`
|
|
*alerter `json:"-"`
|
|
}
|
|
|
|
func (s *Simulation) verify() error {
|
|
switch {
|
|
case !s.ScoreEnd.Time().IsZero() && s.ScoreStart.Time().After(s.ScoreEnd.Time()):
|
|
return errors.New("end is before start")
|
|
case s.LookbackDays > 14:
|
|
return errors.New("lookback days >14")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// stepClock is called by backfill during simulation operations.
|
|
func (s *Simulation) stepClock(ctx context.Context, t time.Time) {
|
|
now := s.clock.Now()
|
|
step := t.Sub(s.lastScore)
|
|
if step > time.Duration(s.SimInterval) {
|
|
s.clock += offsetClock(s.SimInterval)
|
|
s.scores = s.scorer.Score(ctx)
|
|
s.lastScore = now
|
|
}
|
|
|
|
}
|
|
|
|
// Simulate begins the simulation using the DB handle from ctx. It returns final scores.
|
|
func (s *Simulation) Simulate(ctx context.Context) (trending.Scores[talkgroups.ID], error) {
|
|
now := time.Now()
|
|
tgc := talkgroups.NewCache()
|
|
|
|
s.Enable = true
|
|
s.alerter = New(s.Alerting, tgc, WithClock(&s.clock)).(*alerter)
|
|
if time.Time(s.ScoreEnd).IsZero() {
|
|
s.ScoreEnd = jsontime.Time(now)
|
|
}
|
|
log.Debug().Time("scoreStart", s.ScoreStart.Time()).
|
|
Time("scoreEnd", s.ScoreEnd.Time()).
|
|
Str("interval", s.SimInterval.String()).
|
|
Uint("lookbackDays", s.LookbackDays).
|
|
Msg("simulation start")
|
|
|
|
scoreEnd := time.Time(s.ScoreEnd)
|
|
|
|
// compute lookback start Time
|
|
sinceLookback := time.Time(scoreEnd).Add(-24 * time.Hour * time.Duration(s.LookbackDays))
|
|
|
|
// backfill from lookback start until score start
|
|
_, err := s.backfill(ctx, sinceLookback, time.Time(s.ScoreStart))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("simulate backfill: %w", err)
|
|
}
|
|
|
|
// initial score
|
|
s.scores = s.scorer.Score(ctx)
|
|
s.lastScore = time.Time(s.ScoreStart)
|
|
|
|
ssT := time.Time(s.ScoreStart)
|
|
nowDiff := now.Sub(time.Time(ssT))
|
|
|
|
// and set the clock offset to it
|
|
s.clock -= offsetClock(nowDiff)
|
|
|
|
// turn on sim mode
|
|
s.alerter.sim = s
|
|
|
|
// compute time since score start until now
|
|
// backfill from scorestart until now. sim is enabled, so scoring will be done by stepClock()
|
|
_, err = s.backfill(ctx, time.Time(s.ScoreStart), scoreEnd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("simulate backfill final: %w", err)
|
|
}
|
|
|
|
s.lastScore = scoreEnd
|
|
sort.Sort(s.scores)
|
|
|
|
return s.scores, nil
|
|
}
|
|
|
|
// simulateHandler is the POST endpoint handler.
|
|
func (as *alerter) simulateHandler(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
s := new(Simulation)
|
|
switch r.Header.Get("Content-Type") {
|
|
case "application/json":
|
|
err := json.NewDecoder(r.Body).Decode(s)
|
|
if err != nil {
|
|
err = fmt.Errorf("simulate decode: %w", err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
default:
|
|
err := forms.Unmarshal(r, s, forms.WithAcceptBlank(), forms.WithParseLocalTime())
|
|
if err != nil {
|
|
err = fmt.Errorf("simulate unmarshal: %w", err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
err := s.verify()
|
|
if err != nil {
|
|
err = fmt.Errorf("simulation profile verify: %w", err)
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
_, err = s.Simulate(ctx)
|
|
if err != nil {
|
|
err = fmt.Errorf("simulate: %w", err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.tgStatsHandler(w, r)
|
|
}
|