669 lines
19 KiB
Go
669 lines
19 KiB
Go
// Package prediction provides presence prediction using transition probability models.
|
|
package prediction
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// ZoneTransition represents a recorded zone transition event.
|
|
type ZoneTransition struct {
|
|
ID string `json:"id"`
|
|
PersonID string `json:"person_id"`
|
|
FromZoneID string `json:"from_zone_id"`
|
|
ToZoneID string `json:"to_zone_id"`
|
|
HourOfWeek int `json:"hour_of_week"` // 0-167: day_of_week * 24 + hour_of_day
|
|
DwellDurationMinutes float64 `json:"dwell_duration_minutes"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
// TransitionProbability represents the probability of transitioning from one zone to another.
|
|
type TransitionProbability struct {
|
|
PersonID string `json:"person_id"`
|
|
HourOfWeek int `json:"hour_of_week"`
|
|
FromZoneID string `json:"from_zone_id"`
|
|
ToZoneID string `json:"to_zone_id"`
|
|
Probability float64 `json:"probability"`
|
|
Count int `json:"count"`
|
|
LastComputed time.Time `json:"last_computed"`
|
|
}
|
|
|
|
// DwellTimeStats represents typical dwell time statistics for a zone.
|
|
type DwellTimeStats struct {
|
|
PersonID string `json:"person_id"`
|
|
ZoneID string `json:"zone_id"`
|
|
HourOfWeek int `json:"hour_of_week"`
|
|
MeanMinutes float64 `json:"mean_minutes"`
|
|
StddevMinutes float64 `json:"stddev_minutes"`
|
|
Count int `json:"count"`
|
|
LastComputed time.Time `json:"last_computed"`
|
|
}
|
|
|
|
// PersonPrediction represents a prediction for a person's next movement.
|
|
type PersonPrediction struct {
|
|
PersonID string `json:"person_id"`
|
|
PersonLabel string `json:"person_label"`
|
|
CurrentZoneID string `json:"current_zone_id"`
|
|
CurrentZoneName string `json:"current_zone_name"`
|
|
PredictedNextZoneID string `json:"predicted_next_zone_id"`
|
|
PredictedNextZoneName string `json:"predicted_next_zone_name"`
|
|
PredictionConfidence float64 `json:"prediction_confidence"`
|
|
EstimatedTransitionMinutes float64 `json:"estimated_transition_minutes"`
|
|
DataConfidence string `json:"data_confidence"` // "sufficient" or "insufficient_data"
|
|
SampleCount int `json:"sample_count"`
|
|
DaysRemaining int `json:"days_remaining,omitempty"` // days until 7 days of data
|
|
}
|
|
|
|
// ModelStore handles persistence of transition history and probabilities.
|
|
type ModelStore struct {
|
|
mu sync.RWMutex
|
|
db *sql.DB
|
|
path string
|
|
|
|
// Cached data age for determining if recomputation is needed
|
|
firstTransitionTime time.Time
|
|
}
|
|
|
|
// NewModelStore creates a new prediction model store.
|
|
func NewModelStore(dbPath string) (*ModelStore, error) {
|
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
|
return nil, fmt.Errorf("create data dir: %w", err)
|
|
}
|
|
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
|
|
s := &ModelStore{
|
|
db: db,
|
|
path: dbPath,
|
|
}
|
|
|
|
if err := s.migrate(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
|
|
// Load first transition time
|
|
s.loadFirstTransitionTime()
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func (s *ModelStore) migrate() error {
|
|
_, err := s.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS zone_transitions_history (
|
|
id TEXT PRIMARY KEY,
|
|
person_id TEXT NOT NULL,
|
|
from_zone_id TEXT NOT NULL,
|
|
to_zone_id TEXT NOT NULL,
|
|
hour_of_week INTEGER NOT NULL,
|
|
dwell_duration_minutes REAL NOT NULL DEFAULT 0,
|
|
timestamp INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_transitions_person_time ON zone_transitions_history(person_id, hour_of_week);
|
|
CREATE INDEX IF NOT EXISTS idx_transitions_from ON zone_transitions_history(from_zone_id);
|
|
CREATE INDEX IF NOT EXISTS idx_transitions_timestamp ON zone_transitions_history(timestamp);
|
|
|
|
CREATE TABLE IF NOT EXISTS transition_probabilities (
|
|
person_id TEXT NOT NULL,
|
|
hour_of_week INTEGER NOT NULL,
|
|
from_zone_id TEXT NOT NULL,
|
|
to_zone_id TEXT NOT NULL,
|
|
probability REAL NOT NULL,
|
|
count INTEGER NOT NULL,
|
|
last_computed INTEGER NOT NULL,
|
|
PRIMARY KEY (person_id, hour_of_week, from_zone_id, to_zone_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS dwell_times (
|
|
person_id TEXT NOT NULL,
|
|
zone_id TEXT NOT NULL,
|
|
hour_of_week INTEGER NOT NULL,
|
|
mean_minutes REAL NOT NULL,
|
|
stddev_minutes REAL NOT NULL,
|
|
count INTEGER NOT NULL,
|
|
last_computed INTEGER NOT NULL,
|
|
PRIMARY KEY (person_id, zone_id, hour_of_week)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS person_zone_entry (
|
|
person_id TEXT NOT NULL,
|
|
zone_id TEXT NOT NULL,
|
|
entry_time INTEGER NOT NULL,
|
|
blob_id INTEGER NOT NULL,
|
|
PRIMARY KEY (person_id, zone_id)
|
|
);
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func (s *ModelStore) loadFirstTransitionTime() {
|
|
var ts int64
|
|
err := s.db.QueryRow(`SELECT MIN(timestamp) FROM zone_transitions_history`).Scan(&ts)
|
|
if err == nil && ts > 0 {
|
|
s.firstTransitionTime = time.Unix(0, ts)
|
|
}
|
|
}
|
|
|
|
// Close closes the database.
|
|
func (s *ModelStore) Close() error {
|
|
return s.db.Close()
|
|
}
|
|
|
|
// RecordTransition records a zone transition event.
|
|
func (s *ModelStore) RecordTransition(t ZoneTransition) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
id := fmt.Sprintf("%s-%d", t.PersonID, t.Timestamp.UnixNano())
|
|
_, err := s.db.Exec(`
|
|
INSERT INTO zone_transitions_history (id, person_id, from_zone_id, to_zone_id, hour_of_week, dwell_duration_minutes, timestamp)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, id, t.PersonID, t.FromZoneID, t.ToZoneID, t.HourOfWeek, t.DwellDurationMinutes, t.Timestamp.UnixNano())
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update first transition time if needed
|
|
if s.firstTransitionTime.IsZero() || t.Timestamp.Before(s.firstTransitionTime) {
|
|
s.firstTransitionTime = t.Timestamp
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetTransitions retrieves transitions for a person in a specific hour_of_week slot.
|
|
func (s *ModelStore) GetTransitions(personID string, hourOfWeek int) ([]ZoneTransition, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT id, person_id, from_zone_id, to_zone_id, hour_of_week, dwell_duration_minutes, timestamp
|
|
FROM zone_transitions_history
|
|
WHERE person_id = ? AND hour_of_week = ?
|
|
ORDER BY timestamp DESC
|
|
`, personID, hourOfWeek)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var transitions []ZoneTransition
|
|
for rows.Next() {
|
|
var t ZoneTransition
|
|
var ts int64
|
|
if err := rows.Scan(&t.ID, &t.PersonID, &t.FromZoneID, &t.ToZoneID, &t.HourOfWeek, &t.DwellDurationMinutes, &ts); err != nil {
|
|
continue
|
|
}
|
|
t.Timestamp = time.Unix(0, ts)
|
|
transitions = append(transitions, t)
|
|
}
|
|
return transitions, nil
|
|
}
|
|
|
|
// GetTransitionsForSlot retrieves all transitions from a specific zone in an hour slot.
|
|
func (s *ModelStore) GetTransitionsForSlot(personID, fromZoneID string, hourOfWeek int) ([]ZoneTransition, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT id, person_id, from_zone_id, to_zone_id, hour_of_week, dwell_duration_minutes, timestamp
|
|
FROM zone_transitions_history
|
|
WHERE person_id = ? AND from_zone_id = ? AND hour_of_week = ?
|
|
ORDER BY timestamp DESC
|
|
`, personID, fromZoneID, hourOfWeek)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var transitions []ZoneTransition
|
|
for rows.Next() {
|
|
var t ZoneTransition
|
|
var ts int64
|
|
if err := rows.Scan(&t.ID, &t.PersonID, &t.FromZoneID, &t.ToZoneID, &t.HourOfWeek, &t.DwellDurationMinutes, &ts); err != nil {
|
|
continue
|
|
}
|
|
t.Timestamp = time.Unix(0, ts)
|
|
transitions = append(transitions, t)
|
|
}
|
|
return transitions, nil
|
|
}
|
|
|
|
// GetDwellTimes retrieves dwell time records for a person in a zone at a specific hour.
|
|
func (s *ModelStore) GetDwellTimes(personID, zoneID string, hourOfWeek int) ([]float64, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT dwell_duration_minutes
|
|
FROM zone_transitions_history
|
|
WHERE person_id = ? AND from_zone_id = ? AND hour_of_week = ? AND dwell_duration_minutes > 0
|
|
`, personID, zoneID, hourOfWeek)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var times []float64
|
|
for rows.Next() {
|
|
var t float64
|
|
if err := rows.Scan(&t); err != nil {
|
|
continue
|
|
}
|
|
times = append(times, t)
|
|
}
|
|
return times, nil
|
|
}
|
|
|
|
// SaveTransitionProbabilities saves computed probabilities.
|
|
func (s *ModelStore) SaveTransitionProbabilities(probs []TransitionProbability) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
now := time.Now().UnixNano()
|
|
for _, p := range probs {
|
|
_, err := tx.Exec(`
|
|
INSERT OR REPLACE INTO transition_probabilities
|
|
(person_id, hour_of_week, from_zone_id, to_zone_id, probability, count, last_computed)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, p.PersonID, p.HourOfWeek, p.FromZoneID, p.ToZoneID, p.Probability, p.Count, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// SaveDwellTimeStats saves computed dwell time statistics.
|
|
func (s *ModelStore) SaveDwellTimeStats(stats []DwellTimeStats) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
now := time.Now().UnixNano()
|
|
for _, s := range stats {
|
|
_, err := tx.Exec(`
|
|
INSERT OR REPLACE INTO dwell_times
|
|
(person_id, zone_id, hour_of_week, mean_minutes, stddev_minutes, count, last_computed)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, s.PersonID, s.ZoneID, s.HourOfWeek, s.MeanMinutes, s.StddevMinutes, s.Count, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// GetTransitionProbability retrieves the probability for a specific transition.
|
|
func (s *ModelStore) GetTransitionProbability(personID, fromZoneID, toZoneID string, hourOfWeek int) (*TransitionProbability, error) {
|
|
row := s.db.QueryRow(`
|
|
SELECT person_id, hour_of_week, from_zone_id, to_zone_id, probability, count, last_computed
|
|
FROM transition_probabilities
|
|
WHERE person_id = ? AND from_zone_id = ? AND to_zone_id = ? AND hour_of_week = ?
|
|
`, personID, fromZoneID, toZoneID, hourOfWeek)
|
|
|
|
var p TransitionProbability
|
|
var lastComputed int64
|
|
err := row.Scan(&p.PersonID, &p.HourOfWeek, &p.FromZoneID, &p.ToZoneID, &p.Probability, &p.Count, &lastComputed)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.LastComputed = time.Unix(0, lastComputed)
|
|
return &p, nil
|
|
}
|
|
|
|
// GetTransitionProbabilitiesForFromZone retrieves all probabilities from a zone at an hour slot.
|
|
func (s *ModelStore) GetTransitionProbabilitiesForFromZone(personID, fromZoneID string, hourOfWeek int) ([]TransitionProbability, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT person_id, hour_of_week, from_zone_id, to_zone_id, probability, count, last_computed
|
|
FROM transition_probabilities
|
|
WHERE person_id = ? AND from_zone_id = ? AND hour_of_week = ?
|
|
ORDER BY probability DESC
|
|
`, personID, fromZoneID, hourOfWeek)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var probs []TransitionProbability
|
|
for rows.Next() {
|
|
var p TransitionProbability
|
|
var lastComputed int64
|
|
if err := rows.Scan(&p.PersonID, &p.HourOfWeek, &p.FromZoneID, &p.ToZoneID, &p.Probability, &p.Count, &lastComputed); err != nil {
|
|
continue
|
|
}
|
|
p.LastComputed = time.Unix(0, lastComputed)
|
|
probs = append(probs, p)
|
|
}
|
|
return probs, nil
|
|
}
|
|
|
|
// GetDwellTimeStats retrieves dwell time statistics for a person in a zone at an hour.
|
|
func (store *ModelStore) GetDwellTimeStats(personID, zoneID string, hourOfWeek int) (*DwellTimeStats, error) {
|
|
row := store.db.QueryRow(`
|
|
SELECT person_id, zone_id, hour_of_week, mean_minutes, stddev_minutes, count, last_computed
|
|
FROM dwell_times
|
|
WHERE person_id = ? AND zone_id = ? AND hour_of_week = ?
|
|
`, personID, zoneID, hourOfWeek)
|
|
|
|
var stats DwellTimeStats
|
|
var lastComputed int64
|
|
err := row.Scan(&stats.PersonID, &stats.ZoneID, &stats.HourOfWeek, &stats.MeanMinutes, &stats.StddevMinutes, &stats.Count, &lastComputed)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats.LastComputed = time.Unix(0, lastComputed)
|
|
return &stats, nil
|
|
}
|
|
|
|
// GetDataAge returns the duration since the first transition was recorded.
|
|
func (s *ModelStore) GetDataAge() time.Duration {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.firstTransitionTime.IsZero() {
|
|
return 0
|
|
}
|
|
return time.Since(s.firstTransitionTime)
|
|
}
|
|
|
|
// GetTransitionCount returns the total number of transitions recorded.
|
|
func (s *ModelStore) GetTransitionCount() (int, error) {
|
|
var count int
|
|
err := s.db.QueryRow(`SELECT COUNT(*) FROM zone_transitions_history`).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// GetTransitionCountForSlot returns the number of transitions for a specific slot.
|
|
func (s *ModelStore) GetTransitionCountForSlot(personID, fromZoneID string, hourOfWeek int) (int, error) {
|
|
var count int
|
|
err := s.db.QueryRow(`
|
|
SELECT COUNT(*) FROM zone_transitions_history
|
|
WHERE person_id = ? AND from_zone_id = ? AND hour_of_week = ?
|
|
`, personID, fromZoneID, hourOfWeek).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// UpdatePersonZoneEntry updates the entry time for a person in a zone.
|
|
func (s *ModelStore) UpdatePersonZoneEntry(personID, zoneID string, entryTime time.Time, blobID int) error {
|
|
_, err := s.db.Exec(`
|
|
INSERT OR REPLACE INTO person_zone_entry (person_id, zone_id, entry_time, blob_id)
|
|
VALUES (?, ?, ?, ?)
|
|
`, personID, zoneID, entryTime.UnixNano(), blobID)
|
|
return err
|
|
}
|
|
|
|
// GetPersonZoneEntry retrieves the entry time for a person in a zone.
|
|
func (s *ModelStore) GetPersonZoneEntry(personID, zoneID string) (time.Time, int, error) {
|
|
var entryTime int64
|
|
var blobID int
|
|
err := s.db.QueryRow(`
|
|
SELECT entry_time, blob_id FROM person_zone_entry
|
|
WHERE person_id = ? AND zone_id = ?
|
|
`, personID, zoneID).Scan(&entryTime, &blobID)
|
|
if err == sql.ErrNoRows {
|
|
return time.Time{}, 0, nil
|
|
}
|
|
if err != nil {
|
|
return time.Time{}, 0, err
|
|
}
|
|
return time.Unix(0, entryTime), blobID, nil
|
|
}
|
|
|
|
// ClearPersonZoneEntry clears the entry time for a person leaving a zone.
|
|
func (s *ModelStore) ClearPersonZoneEntry(personID, zoneID string) error {
|
|
_, err := s.db.Exec(`
|
|
DELETE FROM person_zone_entry WHERE person_id = ? AND zone_id = ?
|
|
`, personID, zoneID)
|
|
return err
|
|
}
|
|
|
|
// GetAllPersonZoneEntries returns all current zone entries.
|
|
func (s *ModelStore) GetAllPersonZoneEntries() (map[string]struct {
|
|
ZoneID string
|
|
EntryTime time.Time
|
|
BlobID int
|
|
}, error) {
|
|
rows, err := s.db.Query(`SELECT person_id, zone_id, entry_time, blob_id FROM person_zone_entry`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
entries := make(map[string]struct {
|
|
ZoneID string
|
|
EntryTime time.Time
|
|
BlobID int
|
|
})
|
|
|
|
for rows.Next() {
|
|
var personID, zoneID string
|
|
var entryTime int64
|
|
var blobID int
|
|
if err := rows.Scan(&personID, &zoneID, &entryTime, &blobID); err != nil {
|
|
continue
|
|
}
|
|
entries[personID] = struct {
|
|
ZoneID string
|
|
EntryTime time.Time
|
|
BlobID int
|
|
}{
|
|
ZoneID: zoneID,
|
|
EntryTime: time.Unix(0, entryTime),
|
|
BlobID: blobID,
|
|
}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// HourOfWeek computes the hour-of-week (0-167) from a timestamp.
|
|
func HourOfWeek(t time.Time) int {
|
|
weekday := int(t.Weekday()) // 0=Sunday
|
|
hour := t.Hour()
|
|
return weekday*24 + hour
|
|
}
|
|
|
|
// DayNameFromHourOfWeek returns the day name for an hour_of_week value.
|
|
func DayNameFromHourOfWeek(hourOfWeek int) string {
|
|
days := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
|
if hourOfWeek < 0 || hourOfWeek >= 168 {
|
|
return "Unknown"
|
|
}
|
|
return days[hourOfWeek/24]
|
|
}
|
|
|
|
// RecomputeProbabilities recomputes all transition probabilities using Laplace smoothing.
|
|
func (s *ModelStore) RecomputeProbabilities() error {
|
|
log.Printf("[INFO] prediction: recomputing transition probabilities")
|
|
|
|
// Get all unique (person_id, hour_of_week, from_zone_id) groups
|
|
rows, err := s.db.Query(`
|
|
SELECT DISTINCT person_id, hour_of_week, from_zone_id
|
|
FROM zone_transitions_history
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
type groupKey struct {
|
|
personID string
|
|
hourOfWeek int
|
|
fromZoneID string
|
|
}
|
|
var groups []groupKey
|
|
for rows.Next() {
|
|
var g groupKey
|
|
if err := rows.Scan(&g.personID, &g.hourOfWeek, &g.fromZoneID); err != nil {
|
|
continue
|
|
}
|
|
groups = append(groups, g)
|
|
}
|
|
|
|
var allProbs []TransitionProbability
|
|
|
|
for _, g := range groups {
|
|
// Get all transitions for this group
|
|
transRows, err := s.db.Query(`
|
|
SELECT to_zone_id, COUNT(*) as cnt
|
|
FROM zone_transitions_history
|
|
WHERE person_id = ? AND hour_of_week = ? AND from_zone_id = ?
|
|
GROUP BY to_zone_id
|
|
`, g.personID, g.hourOfWeek, g.fromZoneID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
type destCount struct {
|
|
toZoneID string
|
|
count int
|
|
}
|
|
var destCounts []destCount
|
|
var totalCount int
|
|
for transRows.Next() {
|
|
var dc destCount
|
|
if err := transRows.Scan(&dc.toZoneID, &dc.count); err != nil {
|
|
continue
|
|
}
|
|
destCounts = append(destCounts, dc)
|
|
totalCount += dc.count
|
|
}
|
|
transRows.Close()
|
|
|
|
if totalCount == 0 {
|
|
continue
|
|
}
|
|
|
|
// Apply Laplace smoothing: add 1 to each count
|
|
numDests := len(destCounts)
|
|
smoothedTotal := totalCount + numDests
|
|
|
|
for _, dc := range destCounts {
|
|
prob := float64(dc.count+1) / float64(smoothedTotal)
|
|
allProbs = append(allProbs, TransitionProbability{
|
|
PersonID: g.personID,
|
|
HourOfWeek: g.hourOfWeek,
|
|
FromZoneID: g.fromZoneID,
|
|
ToZoneID: dc.toZoneID,
|
|
Probability: prob,
|
|
Count: dc.count,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(allProbs) > 0 {
|
|
if err := s.SaveTransitionProbabilities(allProbs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
log.Printf("[INFO] prediction: computed %d transition probabilities", len(allProbs))
|
|
return nil
|
|
}
|
|
|
|
// RecomputeDwellTimes recomputes all dwell time statistics.
|
|
func (s *ModelStore) RecomputeDwellTimes() error {
|
|
log.Printf("[INFO] prediction: recomputing dwell time statistics")
|
|
|
|
// Get all unique (person_id, zone_id, hour_of_week) groups
|
|
rows, err := s.db.Query(`
|
|
SELECT DISTINCT person_id, from_zone_id, hour_of_week
|
|
FROM zone_transitions_history
|
|
WHERE dwell_duration_minutes > 0
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
type groupKey struct {
|
|
personID string
|
|
zoneID string
|
|
hourOfWeek int
|
|
}
|
|
var groups []groupKey
|
|
for rows.Next() {
|
|
var g groupKey
|
|
if err := rows.Scan(&g.personID, &g.zoneID, &g.hourOfWeek); err != nil {
|
|
continue
|
|
}
|
|
groups = append(groups, g)
|
|
}
|
|
|
|
var allStats []DwellTimeStats
|
|
|
|
for _, g := range groups {
|
|
// Get all dwell times for this group
|
|
times, err := s.GetDwellTimes(g.personID, g.zoneID, g.hourOfWeek)
|
|
if err != nil || len(times) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Compute mean and stddev
|
|
var sum, sumSq float64
|
|
for _, t := range times {
|
|
sum += t
|
|
sumSq += t * t
|
|
}
|
|
n := float64(len(times))
|
|
mean := sum / n
|
|
variance := sumSq/n - mean*mean
|
|
if variance < 0 {
|
|
variance = 0
|
|
}
|
|
stddev := sqrt(variance)
|
|
|
|
allStats = append(allStats, DwellTimeStats{
|
|
PersonID: g.personID,
|
|
ZoneID: g.zoneID,
|
|
HourOfWeek: g.hourOfWeek,
|
|
MeanMinutes: mean,
|
|
StddevMinutes: stddev,
|
|
Count: len(times),
|
|
})
|
|
}
|
|
|
|
if len(allStats) > 0 {
|
|
if err := s.SaveDwellTimeStats(allStats); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
log.Printf("[INFO] prediction: computed %d dwell time statistics", len(allStats))
|
|
return nil
|
|
}
|
|
|
|
func sqrt(x float64) float64 {
|
|
if x < 0 {
|
|
return 0
|
|
}
|
|
// Newton's method for sqrt
|
|
z := 1.0
|
|
for i := 0; i < 100; i++ {
|
|
z -= (z*z - x) / (2 * z)
|
|
}
|
|
return z
|
|
}
|