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

59
html.go
View file

@ -123,13 +123,14 @@ type ContentSignin struct {
} }
type ContentSignup struct{} type ContentSignup struct{}
type ContentSource struct { type ContentSource struct {
Inspections []Inspection Inspections []Inspection
MapData ComponentMap MapData ComponentMap
Source *BreedingSourceDetail Source *BreedingSourceDetail
Traps []TrapNearby Traps []TrapNearby
Treatments []Treatment Treatments []Treatment
TreatmentCadence time.Duration //TreatmentCadence TreatmentCadence
User User TreatmentModels []TreatmentModel
User User
} }
type Inspection struct { type Inspection struct {
Action string 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) 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{
@ -531,11 +521,11 @@ func htmlSource(w http.ResponseWriter, r *http.Request, user *models.User, id st
}, },
Zoom: 13, Zoom: 13,
}, },
Source: s, Source: s,
Traps: traps, Traps: traps,
Treatments: treatments, Treatments: treatments,
TreatmentCadence: cadence, TreatmentModels: treatment_models,
User: userContent, User: userContent,
} }
renderOrError(w, source, data) renderOrError(w, source, data)
@ -574,14 +564,15 @@ func latLngDisplay(ll h3.LatLng) string {
func makeFuncMap() template.FuncMap { func makeFuncMap() template.FuncMap {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"bigNumber": bigNumber, "bigNumber": bigNumber,
"GISStatement": gisStatement, "GISStatement": gisStatement,
"latLngDisplay": latLngDisplay, "latLngDisplay": latLngDisplay,
"timeDelta": timeDelta, "timeAsRelativeDate": timeAsRelativeDate,
"timeElapsed": timeElapsed, "timeDelta": timeDelta,
"timeInterval": timeInterval, "timeElapsed": timeElapsed,
"timeSince": timeSince, "timeInterval": timeInterval,
"uuidShort": uuidShort, "timeSince": timeSince,
"uuidShort": uuidShort,
} }
return funcMap return funcMap
} }
@ -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>

189
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 }
totalSpan := timestamps[0].Sub(timestamps[len(timestamps)-1])
func modelTreatment(treatments []Treatment) []TreatmentModel {
// Calculate average span treatment_times := make([]time.Time, 0)
averageSpan = totalSpan / time.Duration(len(timestamps)-1) for _, treatment := range treatments {
if averageSpan < 0 { treatment_times = append(treatment_times, treatment.Date)
log.Error().Int64("average", int64(averageSpan)).Msg("Negative average") }
return 0, []time.Duration{} models := calculateTreatmentModels(treatment_times)
} /*models_by_year := make(map[int]TreatmentModel)
for _, m := range models {
// Calculate deviations models_by_year[m.Year] = m
deviations = make([]time.Duration, len(timestamps)) }*/
offset := 0
// The first timestamp is our reference point (deviation = 0) for _, model := range models {
deviations[0] = 0 for _, e := range model.Errors {
treatments[offset].CadenceDelta = e
// Calculate expected timestamps based on perfect intervals offset = offset + 1
for i := 1; i < len(timestamps); i++ { }
expectedTime := timestamps[0].Add(-averageSpan * time.Duration(i)) }
deviations[i] = timestamps[i].Sub(expectedTime) /*
} for i, treatment := range treatments {
model
log.Info().Int64("average", int64(averageSpan)).Int("number deviations", len(deviations)).Msg("Calculated cadence") treatment.CadenceDelta = deltas[i]
return averageSpan, deviations 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
} }