dawarich/app/services/visits/smart_detect.rb
2025-03-03 21:45:09 +01:00

438 lines
13 KiB
Ruby

class Visits::SmartDetect
MINIMUM_VISIT_DURATION = 5.minutes
MAXIMUM_VISIT_GAP = 30.minutes
MINIMUM_POINTS_FOR_VISIT = 3
SIGNIFICANT_PLACE_VISITS = 2 # Number of visits to consider a place significant
SIGNIFICANT_MOVEMENT_THRESHOLD = 50 # meters
attr_reader :user, :start_at, :end_at, :points
def initialize(user, start_at:, end_at:)
@user = user
@start_at = start_at.to_i
@end_at = end_at.to_i
@points = user.tracked_points.not_visited
.order(timestamp: :asc)
.where(timestamp: start_at..end_at)
end
def call
return [] if points.empty?
potential_visits = detect_potential_visits
merged_visits = merge_consecutive_visits(potential_visits)
significant_visits = filter_significant_visits(merged_visits)
create_visits(significant_visits)
end
private
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
def merge_consecutive_visits(visits)
return visits if visits.empty?
merged = []
current_merged = visits.first
visits[1..-1].each do |visit|
if can_merge_visits?(current_merged, visit)
# Merge the visits
current_merged[:end_time] = visit[:end_time]
current_merged[:points].concat(visit[:points])
else
merged << current_merged
current_merged = visit
end
end
merged << current_merged
merged
end
def can_merge_visits?(first_visit, second_visit)
return false unless same_location?(first_visit, second_visit)
return false if gap_too_large?(first_visit, second_visit)
return false if significant_movement_between?(first_visit, second_visit)
true
end
def same_location?(first_visit, second_visit)
distance = Geocoder::Calculations.distance_between(
[first_visit[:center_lat], first_visit[:center_lon]],
[second_visit[:center_lat], second_visit[:center_lon]]
)
# Convert to meters and check if within threshold
(distance * 1000) <= SIGNIFICANT_MOVEMENT_THRESHOLD
end
def gap_too_large?(first_visit, second_visit)
gap = second_visit[:start_time] - first_visit[:end_time]
gap > MAXIMUM_VISIT_GAP
end
def significant_movement_between?(first_visit, second_visit)
# Get points between the two visits
between_points = points.where(
timestamp: (first_visit[:end_time] + 1)..(second_visit[:start_time] - 1)
)
return false if between_points.empty?
visit_center = [first_visit[:center_lat], first_visit[:center_lon]]
max_distance = between_points.map do |point|
Geocoder::Calculations.distance_between(
visit_center,
[point.lat, point.lon]
)
end.max
# Convert to meters and check if exceeds threshold
(max_distance * 1000) > SIGNIFICANT_MOVEMENT_THRESHOLD
end
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]],
[point.lat, point.lon]
)
# 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),
suggested_name: suggest_place_name(points)
)
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|
Geocoder::Calculations.distance_between(center, [point.lat, point.lon])
end.max
# Convert to meters and ensure minimum radius
[(max_distance * 1000), 15].max
end
def suggest_place_name(points)
# Get points with geodata
geocoded_points = points.select { |p| p.geodata.present? && !p.geodata.empty? }
return nil if geocoded_points.empty?
# Extract all features from points' geodata
features = geocoded_points.flat_map do |point|
next [] unless point.geodata['features'].is_a?(Array)
point.geodata['features']
end.compact
return nil if features.empty?
# Group features by type and count occurrences
feature_counts = features.group_by { |f| f.dig('properties', 'type') }
.transform_values(&:size)
# Find the most common feature type
most_common_type = feature_counts.max_by { |_, count| count }&.first
return nil unless most_common_type
# Get all features of the most common type
common_features = features.select { |f| f.dig('properties', 'type') == most_common_type }
# Group these features by name and get the most common one
name_counts = common_features.group_by { |f| f.dig('properties', 'name') }
.transform_values(&:size)
most_common_name = name_counts.max_by { |_, count| count }&.first
return unless most_common_name.present?
# If we have a name, try to get additional context
feature = common_features.find { |f| f.dig('properties', 'name') == most_common_name }
properties = feature['properties']
# Build a more descriptive name if possible
[
most_common_name,
properties['street'],
properties['city'],
properties['state']
].compact.uniq.join(', ')
end
def filter_significant_visits(visits)
# Group nearby visits to identify significant places
grouped_visits = group_nearby_visits(visits)
grouped_visits.select do |group|
group.size >= SIGNIFICANT_PLACE_VISITS ||
significant_duration?(group) ||
near_known_place?(group.first)
end.flatten
end
def group_nearby_visits(visits)
visits.group_by do |visit|
[
(visit[:center_lat] * 1000).round / 1000.0,
(visit[:center_lon] * 1000).round / 1000.0
]
end.values
end
def significant_duration?(visits)
total_duration = visits.sum { |v| v[:duration] }
total_duration >= 1.hour
end
def near_known_place?(visit)
# Check if the visit is near a known area or previously confirmed place
center = [visit[:center_lat], visit[:center_lon]]
user.areas.any? { |area| near_area?(center, area) } ||
user.places.any? { |place| near_place?(center, place) }
end
def near_area?(center, area)
distance = Geocoder::Calculations.distance_between(
center,
[area.latitude, area.longitude]
)
distance * 1000 <= area.radius # Convert to meters
end
def near_place?(center, place)
distance = Geocoder::Calculations.distance_between(
center,
[place.latitude, place.longitude]
)
distance <= 0.05 # 50 meters
end
def create_visits(visits)
visits.map do |visit_data|
ActiveRecord::Base.transaction do
# Try to find matching area or place
area = find_matching_area(visit_data)
place = area ? nil : find_or_create_place(visit_data)
visit = Visit.create!(
user: user,
area: area,
place: place,
started_at: Time.zone.at(visit_data[:start_time]),
ended_at: Time.zone.at(visit_data[:end_time]),
duration: visit_data[:duration] / 60, # Convert to minutes
name: generate_visit_name(area, place, visit_data[:suggested_name]),
status: :suggested
)
Point.where(id: visit_data[:points].map(&:id)).update_all(visit_id: visit.id)
visit
end
end
end
def find_matching_area(visit_data)
user.areas.find do |area|
near_area?([visit_data[:center_lat], visit_data[:center_lon]], area)
end
end
def find_or_create_place(visit_data)
lat = visit_data[:center_lat].round(5)
lon = visit_data[:center_lon].round(5)
name = visit_data[:suggested_name]
# Define the search radius in meters
search_radius = 100 # Adjust this value as needed
# First check by exact coordinates
existing_place = Place.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 1)', lon, lat).first
# If no exact match, check by name within radius
existing_place ||= Place.where(name: name)
.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?)', lon, lat, search_radius)
.first
return existing_place if existing_place
# Use a database transaction with a lock to prevent race conditions
Place.transaction do
# Check again within transaction to prevent race conditions
existing_place = Place.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 50)', lon, lat)
.lock(true)
.first
return existing_place if existing_place
# If no existing place is found, create a new one
place = Place.new(
lonlat: "POINT(#{lon} #{lat})",
latitude: lat,
longitude: lon
)
# Get reverse geocoding data
geocoded_data = Geocoder.search([lat, lon])
if geocoded_data.present?
first_result = geocoded_data.first
data = first_result.data
properties = data['properties'] || {}
# Build a descriptive name from available components
name_components = [
properties['name'],
properties['street'],
properties['housenumber'],
properties['postcode'],
properties['city']
].compact.uniq
place.name = name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
place.city = properties['city']
place.country = properties['country']
place.geodata = data
place.source = :photon
place.save!
# Process nearby organizations outside the main transaction
process_nearby_organizations(geocoded_data.drop(1))
else
place.name = visit_data[:suggested_name] || Place::DEFAULT_NAME
place.source = :manual
place.save!
end
place
end
end
# Extract nearby organizations processing to a separate method
def process_nearby_organizations(geocoded_data)
# Fetch nearby organizations
nearby_organizations = fetch_nearby_organizations(geocoded_data)
# Save each organization as a possible place
nearby_organizations.each do |org|
lon = org[:longitude]
lat = org[:latitude]
# Check if a similar place already exists
existing = Place.where(name: org[:name])
.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 1)', lon, lat)
.first
next if existing
Place.create!(
name: org[:name],
lonlat: "POINT(#{lon} #{lat})",
latitude: lat,
longitude: lon,
city: org[:city],
country: org[:country],
geodata: org[:geodata],
source: :suggested,
status: :possible
)
end
end
def fetch_nearby_organizations(geocoded_data)
geocoded_data.map do |result|
data = result.data
properties = data['properties'] || {}
{
name: properties['name'] || 'Unknown Organization',
latitude: result.latitude,
longitude: result.longitude,
city: properties['city'],
country: properties['country'],
geodata: data
}
end
end
def generate_visit_name(area, place, suggested_name)
return area.name if area
return place.name if place
return suggested_name if suggested_name.present?
'Unknown Location'
end
end