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
Traps []TrapNearby
Treatments []Treatment
TreatmentCadence time.Duration
//TreatmentCadence TreatmentCadence
TreatmentModels []TreatmentModel
User User
}
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)
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{
@ -534,7 +524,7 @@ func htmlSource(w http.ResponseWriter, r *http.Request, user *models.User, id st
Source: s,
Traps: traps,
Treatments: treatments,
TreatmentCadence: cadence,
TreatmentModels: treatment_models,
User: userContent,
}
@ -577,6 +567,7 @@ func makeFuncMap() template.FuncMap {
"bigNumber": bigNumber,
"GISStatement": gisStatement,
"latLngDisplay": latLngDisplay,
"timeAsRelativeDate": timeAsRelativeDate,
"timeDelta": timeDelta,
"timeElapsed": timeElapsed,
"timeInterval": timeInterval,
@ -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 {

View file

@ -250,7 +250,24 @@
<div class="card mb-4">
<div class="card-body">
<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">
<thead>
<tr>
@ -316,7 +333,7 @@
<tbody>
<tr>
<td class="info-label">Trap ID:</td>
<td>{{ .ID }}</td>
<td><a href="/trap/{{.ID}}">{{ .ID }}</a></td>
</tr>
<tr>
<td class="info-label">Distance</td>

177
time.go
View file

@ -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{}
// 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
}
// 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{}
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
}
// 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)
// 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)
}
log.Info().Int64("average", int64(averageSpan)).Int("number deviations", len(deviations)).Msg("Calculated cadence")
return averageSpan, deviations
// 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
}