Experiment with visit calculation service

This commit is contained in:
Eugene Burmakin 2024-07-23 00:40:48 +02:00
parent 3fd176ad6e
commit ab700c8f25
9 changed files with 188 additions and 10 deletions

File diff suppressed because one or more lines are too long

View file

@ -3,4 +3,5 @@
class Visit < ApplicationRecord
belongs_to :area
belongs_to :user
has_many :points, dependent: :nullify
end

127
app/services/visitcalc.rb Normal file
View file

@ -0,0 +1,127 @@
# frozen_string_literal: true
class Visitcalc
class Visit
attr_accessor :start_time, :end_time, :points
def initialize(start_time)
@start_time = start_time
@end_time = start_time
@points = []
end
def add_point(point)
@points << point
@end_time = point.timestamp if point.timestamp > @end_time
end
def duration_in_minutes
(end_time - start_time) / 60.0
end
def valid?
@points.size > 1 && duration_in_minutes >= 10
end
end
def call
# Usage
area = Area.last
points = Point.near([area.latitude, area.longitude], (area.radius / 1000.0)).order(timestamp: :asc)
points_grouped_by_month = points.group_by { |point| Time.zone.at(point.timestamp).strftime('%Y-%m') }
visits_by_month = {}
points_grouped_by_month.each do |month, points_in_month|
visits_by_month[month] = group_points_into_visits(points_in_month, 30, 15)
end
# Debugging output to check the number of visits and some sample data
visits_by_month.each do |month, visits|
puts "Month: #{month}, Total visits: #{visits.size}"
visits.each do |time_range, visit_points|
puts "Visit from #{time_range}, Points: #{visit_points.size}"
end
end
visits_by_month.map { |d, v| v.keys }
end
def group_points_into_visits(points, time_threshold_minutes = 30, merge_threshold_minutes = 15)
# Ensure points are sorted by timestamp
sorted_points = points.sort_by(&:timestamp)
visits = []
current_visit = nil
sorted_points.each do |point|
point_time = point.timestamp
puts "Processing point at #{Time.zone.at(point_time).strftime('%Y-%m-%d %H:%M:%S')}"
if current_visit.nil?
puts "Starting new visit at #{Time.zone.at(point_time).strftime('%Y-%m-%d %H:%M:%S')}"
current_visit = Visit.new(point_time)
current_visit.add_point(point)
else
time_difference = (point_time - current_visit.end_time) / 60.0 # Convert to minutes
puts "Time difference: #{time_difference.round} minutes"
if time_difference <= time_threshold_minutes
current_visit.add_point(point)
else
if current_visit.valid?
puts "Ending visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')}, duration: #{current_visit.duration_in_minutes} minutes, points: #{current_visit.points.size}"
visits << current_visit
else
puts "Discarding visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')} (invalid, points: #{current_visit.points.size}, duration: #{current_visit.duration_in_minutes} minutes)"
end
current_visit = Visit.new(point_time)
current_visit.add_point(point)
puts "Starting new visit at #{Time.zone.at(point_time).strftime('%Y-%m-%d %H:%M:%S')}"
end
end
end
# Add the last visit to the list if it is valid
if current_visit&.valid?
puts "Ending visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')}, duration: #{current_visit.duration_in_minutes} minutes, points: #{current_visit.points.size}"
visits << current_visit
else
puts "Discarding last visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')} (invalid, points: #{current_visit.points.size}, duration: #{current_visit.duration_in_minutes} minutes)"
end
# Merge visits that are not more than merge_threshold_minutes apart
merged_visits = []
previous_visit = nil
visits.each do |visit|
if previous_visit.nil?
previous_visit = visit
else
time_difference = (visit.start_time - previous_visit.end_time) / 60.0 # Convert to minutes
if time_difference <= merge_threshold_minutes
previous_visit.points.concat(visit.points)
previous_visit.end_time = visit.end_time
else
merged_visits << previous_visit
previous_visit = visit
end
end
end
merged_visits << previous_visit if previous_visit
# Sort visits by start time
merged_visits.sort_by!(&:start_time)
# Convert visits to a hash with human-readable datetime ranges as keys and points as values
visits_hash = {}
merged_visits.each do |visit|
start_time_str = Time.zone.at(visit.start_time).strftime('%Y-%m-%d %H:%M:%S')
end_time_str = Time.zone.at(visit.end_time).strftime('%Y-%m-%d %H:%M:%S')
visits_hash["#{start_time_str} - #{end_time_str}"] = visit.points
end
visits_hash
end
end
# Run the Visitcalc class
# Visitcalc.new.call

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
class Visits::Area::Calculate
attr_accessor :point
def initialize(point)
@point = point
end
def call
return unless point.city && point.country
# After a reverse geocoding process done for a point, check if there are any areas in the same country+city.
# If there are, check if the point coordinates are within the area's boundaries.
# If they are, find or create a Visit: Name of Area + point.id (visit has many points and belongs to area, point optionally belong to a visit)
#
areas = Area.where(city: point.city, country: point.country)
end
private
def days
# 1. Getting all the points within the area
points = Point.near([area.latitude, area.longitude], area.radius).order(:timestamp)
# 2. Grouping the points by date
points.group_by { |point| Time.at(point.timestamp).to_date }
end
def visits
# 3. Within each day, group points by hour. If difference between two groups is less than 1 hour, they are considered to be part of the same visit.
days.map do |day, points|
points.group_by { |point| Time.at(point.timestamp).strftime('%Y-%m-%d %H') }
end
# 4. If a visit has more than 1 point, it is considered a visit.
end

View file

@ -5,7 +5,7 @@ class Visits::Calculate
@points = points
end
def call
def city_visits
normalize_result(city_visits)
end

5
db/schema.rb generated
View file

@ -53,9 +53,6 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_21_183116) do
t.index ["user_id"], name: "index_areas_on_user_id"
end
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
end
create_table "exports", force: :cascade do |t|
t.string "name", null: false
t.string "url"
@ -162,7 +159,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_21_183116) do
t.datetime "updated_at", null: false
t.string "api_key", default: "", null: false
t.string "theme", default: "dark", null: false
t.jsonb "settings", default: {"fog_of_war_meters"=>"200", "meters_between_routes"=>"1000", "minutes_between_routes"=>"60"}
t.jsonb "settings", default: {"fog_of_war_meters"=>"100", "meters_between_routes"=>"1000", "minutes_between_routes"=>"60"}
t.boolean "admin", default: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true

View file

@ -5,7 +5,8 @@ return if User.any?
User.create!(
email: 'user@domain.com',
password: 'password',
password_confirmation: 'password'
password_confirmation: 'password',
admin: true
)
puts "User created: #{User.first.email} / password: 'password'"

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :visit do
area { nil }
user { nil }
area
user
end
end

View file

@ -1,5 +1,15 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Visit, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
describe 'associations' do
it { is_expected.to belong_to(:area) }
it { is_expected.to belong_to(:user) }
it { is_expected.to have_many(:points).dependent(:nullify) }
end
describe 'factory' do
it { expect(build(:visit)).to be_valid }
end
end