Calculate treatment cadence by year

This commit is contained in:
Eli Ribble 2025-11-21 17:26:49 +00:00
parent 689cc0319d
commit 009dc29e5d
No known key found for this signature in database
3 changed files with 200 additions and 69 deletions

23
html.go
View file

@ -128,7 +128,8 @@ type ContentSource struct {
Source *BreedingSourceDetail Source *BreedingSourceDetail
Traps []TrapNearby Traps []TrapNearby
Treatments []Treatment Treatments []Treatment
TreatmentCadence time.Duration //TreatmentCadence TreatmentCadence
TreatmentModels []TreatmentModel
User User User User
} }
type Inspection struct { type Inspection struct {
@ -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) respondError(w, "Failed to get treatments", err, http.StatusInternalServerError)
return return
} }
treatment_times := make([]time.Time, 0) treatment_models := modelTreatment(treatments)
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
}
data := ContentSource{ data := ContentSource{
Inspections: inspections, Inspections: inspections,
MapData: ComponentMap{ MapData: ComponentMap{
@ -534,7 +524,7 @@ func htmlSource(w http.ResponseWriter, r *http.Request, user *models.User, id st
Source: s, Source: s,
Traps: traps, Traps: traps,
Treatments: treatments, Treatments: treatments,
TreatmentCadence: cadence, TreatmentModels: treatment_models,
User: userContent, User: userContent,
} }
@ -577,6 +567,7 @@ func makeFuncMap() template.FuncMap {
"bigNumber": bigNumber, "bigNumber": bigNumber,
"GISStatement": gisStatement, "GISStatement": gisStatement,
"latLngDisplay": latLngDisplay, "latLngDisplay": latLngDisplay,
"timeAsRelativeDate": timeAsRelativeDate,
"timeDelta": timeDelta, "timeDelta": timeDelta,
"timeElapsed": timeElapsed, "timeElapsed": timeElapsed,
"timeInterval": timeInterval, "timeInterval": timeInterval,
@ -642,6 +633,10 @@ func parseFromDisk(files []string) (*template.Template, error) {
return templ, nil return templ, nil
} }
func timeAsRelativeDate(d time.Time) string {
return d.Format("01-02")
}
// FormatTimeDuration returns a human-readable string representing a time.Duration // FormatTimeDuration returns a human-readable string representing a time.Duration
// as "X units early" or "X units late" // as "X units early" or "X units late"
func timeDelta(d time.Duration) string { func timeDelta(d time.Duration) string {

View file

@ -250,7 +250,24 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-body"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
<p>Estimated Treatment Cadence: {{.TreatmentCadence|timeInterval}}</p> <table class="table table-striped">
<tbody>
<tr>
<th>Year</th>
<th>Start</th>
<th>End</th>
<th>Interval</th>
</tr>
{{ range .TreatmentModels }}
<tr>
<td>{{.Year}}</td>
<td>{{.SeasonStart|timeAsRelativeDate}}</td>
<td>{{.SeasonEnd|timeAsRelativeDate}}</td>
<td>{{.Interval|timeInterval}}</td>
</tr>
{{ end }}
</tbody>
</table>
<table class="table table-striped table-hover"> <table class="table table-striped table-hover">
<thead> <thead>
<tr> <tr>
@ -316,7 +333,7 @@
<tbody> <tbody>
<tr> <tr>
<td class="info-label">Trap ID:</td> <td class="info-label">Trap ID:</td>
<td>{{ .ID }}</td> <td><a href="/trap/{{.ID}}">{{ .ID }}</a></td>
</tr> </tr>
<tr> <tr>
<td class="info-label">Distance</td> <td class="info-label">Distance</td>

177
time.go
View file

@ -1,42 +1,161 @@
package main package main
import ( import (
"sort"
"time" "time"
//"github.com/rs/zerolog/log"
"github.com/rs/zerolog/log"
) )
// calculateCadenceVariance takes a slice of time.Time instances and returns // TreatmentModel represents the calculated model for a year's treatments
// the average time span between consecutive instances and how much each type TreatmentModel struct {
// instance deviates from its expected position in an evenly-spaced sequence Year int
// timestamps should be in descending order (most recent time in index 0) SeasonStart time.Time
func calculateCadenceVariance(timestamps []time.Time) (averageSpan time.Duration, deviations []time.Duration) { SeasonEnd time.Time
if len(timestamps) <= 1 { Interval time.Duration
return 0, []time.Duration{} ActualDates []time.Time
PredictedDates []time.Time
Errors []time.Duration
} }
// Calculate total span between first and last timestamp func modelTreatment(treatments []Treatment) []TreatmentModel {
totalSpan := timestamps[0].Sub(timestamps[len(timestamps)-1]) treatment_times := make([]time.Time, 0)
for _, treatment := range treatments {
// Calculate average span treatment_times = append(treatment_times, treatment.Date)
averageSpan = totalSpan / time.Duration(len(timestamps)-1) }
if averageSpan < 0 { models := calculateTreatmentModels(treatment_times)
log.Error().Int64("average", int64(averageSpan)).Msg("Negative average") /*models_by_year := make(map[int]TreatmentModel)
return 0, []time.Duration{} 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
} }
// Calculate deviations // calculateTreatmentModels segments treatments by year and calculates a model for each year
deviations = make([]time.Duration, len(timestamps)) func calculateTreatmentModels(treatments []time.Time) []TreatmentModel {
// Group treatments by year
// The first timestamp is our reference point (deviation = 0) yearMap := make(map[int][]time.Time)
deviations[0] = 0 for _, t := range treatments {
year := t.Year()
// Calculate expected timestamps based on perfect intervals yearMap[year] = append(yearMap[year], t)
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") // Calculate a model for each year
return averageSpan, deviations 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
} }