2025-03-05 14:04:26 -05:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
|
|
module Visits
|
|
|
|
|
# Detects potential visits from a collection of tracked points
|
|
|
|
|
class Detector
|
2025-03-07 17:32:56 -05:00
|
|
|
MINIMUM_VISIT_DURATION = 3.minutes
|
2025-03-05 14:04:26 -05:00
|
|
|
MAXIMUM_VISIT_GAP = 30.minutes
|
2025-03-08 13:40:28 -05:00
|
|
|
MINIMUM_POINTS_FOR_VISIT = 2
|
2025-03-05 14:04:26 -05:00
|
|
|
|
2025-05-12 16:49:30 -04:00
|
|
|
attr_reader :points, :place_name_suggester
|
2025-03-05 14:04:26 -05:00
|
|
|
|
|
|
|
|
def initialize(points)
|
|
|
|
|
@points = points
|
2025-05-12 17:36:46 -04:00
|
|
|
@place_name_suggester = Visits::Names::Suggester
|
2025-03-05 14:04:26 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def detect_potential_visits
|
|
|
|
|
visits = []
|
|
|
|
|
current_visit = nil
|
|
|
|
|
|
|
|
|
|
points.each do |point|
|
|
|
|
|
if current_visit.nil?
|
|
|
|
|
current_visit = initialize_visit(point)
|
|
|
|
|
next
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if belongs_to_current_visit?(point, current_visit)
|
|
|
|
|
current_visit[:points] << point
|
|
|
|
|
current_visit[:end_time] = point.timestamp
|
|
|
|
|
else
|
|
|
|
|
visits << finalize_visit(current_visit) if valid_visit?(current_visit)
|
|
|
|
|
current_visit = initialize_visit(point)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Handle the last visit
|
|
|
|
|
visits << finalize_visit(current_visit) if current_visit && valid_visit?(current_visit)
|
|
|
|
|
|
|
|
|
|
visits
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
|
|
def initialize_visit(point)
|
|
|
|
|
{
|
|
|
|
|
start_time: point.timestamp,
|
|
|
|
|
end_time: point.timestamp,
|
|
|
|
|
center_lat: point.lat,
|
|
|
|
|
center_lon: point.lon,
|
|
|
|
|
points: [point]
|
|
|
|
|
}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def belongs_to_current_visit?(point, visit)
|
|
|
|
|
time_gap = point.timestamp - visit[:end_time]
|
|
|
|
|
return false if time_gap > MAXIMUM_VISIT_GAP
|
|
|
|
|
|
|
|
|
|
# Calculate distance from visit center
|
|
|
|
|
distance = Geocoder::Calculations.distance_between(
|
|
|
|
|
[visit[:center_lat], visit[:center_lon]],
|
2025-03-08 13:40:28 -05:00
|
|
|
[point.lat, point.lon],
|
|
|
|
|
units: :km
|
2025-03-05 14:04:26 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Dynamically adjust radius based on visit duration
|
|
|
|
|
max_radius = calculate_max_radius(visit[:end_time] - visit[:start_time])
|
|
|
|
|
|
|
|
|
|
distance <= max_radius
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def calculate_max_radius(duration_seconds)
|
|
|
|
|
# Start with a small radius for short visits, increase for longer stays
|
|
|
|
|
# but cap it at a reasonable maximum
|
|
|
|
|
base_radius = 0.05 # 50 meters
|
|
|
|
|
duration_hours = duration_seconds / 3600.0
|
|
|
|
|
[base_radius * (1 + Math.log(1 + duration_hours)), 0.5].min # Cap at 500 meters
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def valid_visit?(visit)
|
|
|
|
|
duration = visit[:end_time] - visit[:start_time]
|
|
|
|
|
visit[:points].size >= MINIMUM_POINTS_FOR_VISIT && duration >= MINIMUM_VISIT_DURATION
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def finalize_visit(visit)
|
|
|
|
|
points = visit[:points]
|
|
|
|
|
center = calculate_center(points)
|
|
|
|
|
|
|
|
|
|
visit.merge(
|
|
|
|
|
duration: visit[:end_time] - visit[:start_time],
|
|
|
|
|
center_lat: center[0],
|
|
|
|
|
center_lon: center[1],
|
|
|
|
|
radius: calculate_visit_radius(points, center),
|
2025-05-12 17:36:46 -04:00
|
|
|
suggested_name: suggest_place_name(points) || fetch_place_name(center)
|
2025-03-05 14:04:26 -05:00
|
|
|
)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def calculate_center(points)
|
|
|
|
|
lat_sum = points.sum(&:lat)
|
|
|
|
|
lon_sum = points.sum(&:lon)
|
|
|
|
|
count = points.size.to_f
|
|
|
|
|
|
|
|
|
|
[lat_sum / count, lon_sum / count]
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def calculate_visit_radius(points, center)
|
|
|
|
|
max_distance = points.map do |point|
|
2025-03-07 17:32:56 -05:00
|
|
|
Geocoder::Calculations.distance_between(center, [point.lat, point.lon], units: :km)
|
2025-03-05 14:04:26 -05:00
|
|
|
end.max
|
|
|
|
|
|
|
|
|
|
# Convert to meters and ensure minimum radius
|
|
|
|
|
[(max_distance * 1000), 15].max
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def suggest_place_name(points)
|
2025-05-12 16:49:30 -04:00
|
|
|
place_name_suggester.new(points).call
|
2025-03-05 14:04:26 -05:00
|
|
|
end
|
2025-05-12 17:36:46 -04:00
|
|
|
|
|
|
|
|
def fetch_place_name(center)
|
|
|
|
|
Visits::Names::Fetcher.new(center).call
|
|
|
|
|
end
|
2025-03-05 14:04:26 -05:00
|
|
|
end
|
|
|
|
|
end
|