From 009dc29e5d530a923c9f0ffb44eca91b8d4779f7 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Fri, 21 Nov 2025 17:26:49 +0000 Subject: [PATCH] Calculate treatment cadence by year --- html.go | 59 ++++++------- templates/source.html | 21 ++++- time.go | 189 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 200 insertions(+), 69 deletions(-) diff --git a/html.go b/html.go index 428db052..b226b06b 100644 --- a/html.go +++ b/html.go @@ -123,13 +123,14 @@ type ContentSignin struct { } type ContentSignup struct{} type ContentSource struct { - Inspections []Inspection - MapData ComponentMap - Source *BreedingSourceDetail - Traps []TrapNearby - Treatments []Treatment - TreatmentCadence time.Duration - User User + Inspections []Inspection + MapData ComponentMap + Source *BreedingSourceDetail + Traps []TrapNearby + Treatments []Treatment + //TreatmentCadence TreatmentCadence + TreatmentModels []TreatmentModel + User User } type Inspection struct { Action string @@ -506,18 +507,7 @@ func htmlSource(w http.ResponseWriter, r *http.Request, user *models.User, id st respondError(w, "Failed to get treatments", err, http.StatusInternalServerError) return } - treatment_times := make([]time.Time, 0) - for _, treatment := range treatments { - treatment_times = append(treatment_times, treatment.Date) - } - cadence, deltas := calculateCadenceVariance(treatment_times) - for i, treatment := range treatments { - if i >= len(deltas) { - break - } - treatment.CadenceDelta = deltas[i] - treatments[i] = treatment - } + treatment_models := modelTreatment(treatments) data := ContentSource{ Inspections: inspections, MapData: ComponentMap{ @@ -531,11 +521,11 @@ func htmlSource(w http.ResponseWriter, r *http.Request, user *models.User, id st }, Zoom: 13, }, - Source: s, - Traps: traps, - Treatments: treatments, - TreatmentCadence: cadence, - User: userContent, + Source: s, + Traps: traps, + Treatments: treatments, + TreatmentModels: treatment_models, + User: userContent, } renderOrError(w, source, data) @@ -574,14 +564,15 @@ func latLngDisplay(ll h3.LatLng) string { func makeFuncMap() template.FuncMap { funcMap := template.FuncMap{ - "bigNumber": bigNumber, - "GISStatement": gisStatement, - "latLngDisplay": latLngDisplay, - "timeDelta": timeDelta, - "timeElapsed": timeElapsed, - "timeInterval": timeInterval, - "timeSince": timeSince, - "uuidShort": uuidShort, + "bigNumber": bigNumber, + "GISStatement": gisStatement, + "latLngDisplay": latLngDisplay, + "timeAsRelativeDate": timeAsRelativeDate, + "timeDelta": timeDelta, + "timeElapsed": timeElapsed, + "timeInterval": timeInterval, + "timeSince": timeSince, + "uuidShort": uuidShort, } return funcMap } @@ -642,6 +633,10 @@ func parseFromDisk(files []string) (*template.Template, error) { return templ, nil } +func timeAsRelativeDate(d time.Time) string { + return d.Format("01-02") +} + // FormatTimeDuration returns a human-readable string representing a time.Duration // as "X units early" or "X units late" func timeDelta(d time.Duration) string { diff --git a/templates/source.html b/templates/source.html index 6c0aad86..c42ee59d 100644 --- a/templates/source.html +++ b/templates/source.html @@ -250,7 +250,24 @@
-

Estimated Treatment Cadence: {{.TreatmentCadence|timeInterval}}

+ + + + + + + + + {{ range .TreatmentModels }} + + + + + + + {{ end }} + +
YearStartEndInterval
{{.Year}}{{.SeasonStart|timeAsRelativeDate}}{{.SeasonEnd|timeAsRelativeDate}}{{.Interval|timeInterval}}
@@ -316,7 +333,7 @@ - + diff --git a/time.go b/time.go index 2e5fbdea..ae405e4f 100644 --- a/time.go +++ b/time.go @@ -1,42 +1,161 @@ package main import ( + "sort" "time" - - "github.com/rs/zerolog/log" + //"github.com/rs/zerolog/log" ) -// calculateCadenceVariance takes a slice of time.Time instances and returns -// the average time span between consecutive instances and how much each -// instance deviates from its expected position in an evenly-spaced sequence -// timestamps should be in descending order (most recent time in index 0) -func calculateCadenceVariance(timestamps []time.Time) (averageSpan time.Duration, deviations []time.Duration) { - if len(timestamps) <= 1 { - return 0, []time.Duration{} - } - - // Calculate total span between first and last timestamp - totalSpan := timestamps[0].Sub(timestamps[len(timestamps)-1]) - - // Calculate average span - averageSpan = totalSpan / time.Duration(len(timestamps)-1) - if averageSpan < 0 { - log.Error().Int64("average", int64(averageSpan)).Msg("Negative average") - return 0, []time.Duration{} - } - - // Calculate deviations - deviations = make([]time.Duration, len(timestamps)) - - // The first timestamp is our reference point (deviation = 0) - deviations[0] = 0 - - // Calculate expected timestamps based on perfect intervals - for i := 1; i < len(timestamps); i++ { - expectedTime := timestamps[0].Add(-averageSpan * time.Duration(i)) - deviations[i] = timestamps[i].Sub(expectedTime) - } - - log.Info().Int64("average", int64(averageSpan)).Int("number deviations", len(deviations)).Msg("Calculated cadence") - return averageSpan, deviations +// TreatmentModel represents the calculated model for a year's treatments +type TreatmentModel struct { + Year int + SeasonStart time.Time + SeasonEnd time.Time + Interval time.Duration + ActualDates []time.Time + PredictedDates []time.Time + Errors []time.Duration +} + +func modelTreatment(treatments []Treatment) []TreatmentModel { + treatment_times := make([]time.Time, 0) + for _, treatment := range treatments { + treatment_times = append(treatment_times, treatment.Date) + } + models := calculateTreatmentModels(treatment_times) + /*models_by_year := make(map[int]TreatmentModel) + for _, m := range models { + models_by_year[m.Year] = m + }*/ + offset := 0 + for _, model := range models { + for _, e := range model.Errors { + treatments[offset].CadenceDelta = e + offset = offset + 1 + } + } + /* + for i, treatment := range treatments { + model + treatment.CadenceDelta = deltas[i] + treatments[i] = treatment + } + */ + /*cadence, deltas := calculateCadenceVariance(treatment_times) + for i, treatment := range treatments { + if i >= len(deltas) { + break + } + treatment.CadenceDelta = deltas[i] + treatments[i] = treatment + }*/ + return models +} + +// calculateTreatmentModels segments treatments by year and calculates a model for each year +func calculateTreatmentModels(treatments []time.Time) []TreatmentModel { + // Group treatments by year + yearMap := make(map[int][]time.Time) + for _, t := range treatments { + year := t.Year() + yearMap[year] = append(yearMap[year], t) + } + + // Calculate a model for each year + var models []TreatmentModel + for year, dates := range yearMap { + // Sort dates within the year + sort.Slice(dates, func(i, j int) bool { + return dates[i].Before(dates[j]) + }) + + model := calculateYearModel(year, dates) + models = append(models, model) + } + + // Sort models by year + sort.Slice(models, func(i, j int) bool { + return models[i].Year < models[j].Year + }) + + return models +} + +// calculateYearModel creates a model for a specific year using linear regression +func calculateYearModel(year int, dates []time.Time) TreatmentModel { + n := len(dates) + if n < 2 { + // Not enough data for a model + return TreatmentModel{ + Year: year, + ActualDates: dates, + } + } + + // Convert dates to numeric values (seconds since epoch) + var x []float64 + var y []float64 + for i, date := range dates { + x = append(x, float64(i)) + y = append(y, float64(date.Unix())) + } + + // Calculate linear regression + slope, intercept := linearRegression(x, y) + + // Convert back to time.Time and time.Duration + startTime := time.Unix(int64(intercept), 0) + intervalSeconds := int64(slope) + interval := time.Duration(intervalSeconds) * time.Second + + // Calculate end of season + endTime := time.Unix(int64(intercept+slope*float64(n-1)), 0) + + // Generate predicted dates and calculate errors + var predictedDates []time.Time + var errors []time.Duration + + for i := 0; i < n; i++ { + predicted := time.Unix(int64(intercept+slope*float64(i)), 0) + predictedDates = append(predictedDates, predicted) + + // Calculate error + actualTime := dates[i] + error := actualTime.Sub(predicted) + errors = append(errors, error) + } + + return TreatmentModel{ + Year: year, + SeasonStart: startTime, + SeasonEnd: endTime, + Interval: interval, + ActualDates: dates, + PredictedDates: predictedDates, + Errors: errors, + } +} + +// linearRegression calculates the slope and intercept of the best-fit line +func linearRegression(x, y []float64) (float64, float64) { + n := float64(len(x)) + if n < 2 { + return 0, 0 + } + + var sumX, sumY, sumXY, sumX2 float64 + for i := 0; i < len(x); i++ { + sumX += x[i] + sumY += y[i] + sumXY += x[i] * y[i] + sumX2 += x[i] * x[i] + } + + // Calculate slope + slope := (n*sumXY - sumX*sumY) / (n*sumX2 - sumX*sumX) + + // Calculate intercept + intercept := (sumY - slope*sumX) / n + + return slope, intercept }
Trap ID:{{ .ID }}{{ .ID }}
Distance