mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Experiment with visit calculation service
This commit is contained in:
parent
3fd176ad6e
commit
ab700c8f25
9 changed files with 188 additions and 10 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
127
app/services/visitcalc.rb
Normal 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
|
||||
40
app/services/visits/area/calculate.rb
Normal file
40
app/services/visits/area/calculate.rb
Normal 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
|
||||
|
|
@ -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
5
db/schema.rb
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :visit do
|
||||
area { nil }
|
||||
user { nil }
|
||||
area
|
||||
user
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue