Introduce latlon to Points and use it instead of latitude and longitude.

This commit is contained in:
Eugene Burmakin 2025-02-21 23:45:36 +01:00
parent d6cec7725d
commit d9eac91834
50 changed files with 755 additions and 225 deletions

View file

@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.24.2 - 2025-02-15
## TODO:
- Data migration to convert `latitude` and `longitude` to `lonlat` column.
- Frontend update to use `lonlat` column.
## Fixed
- Fixed a bug where background jobs to import Immich and Photoprism geolocation data data could not be created by non-admin users.
@ -17,6 +22,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Restrict access to Sidekiq in non self-hosted mode.
- Restrict access to background jobs in non self-hosted mode.
- Restrict access to users management in non self-hosted mode.
- Points are now using `lonlat` column for storing longitude and latitude.
- Semantic history points are now being imported much faster.
# 0.24.1 - 2025-02-13

View file

@ -9,7 +9,7 @@ gem 'bootsnap', require: false
gem 'chartkick'
gem 'data_migrate'
gem 'devise'
gem 'geocoder', git: 'https://github.com/alexreisner/geocoder.git', ref: '04ee293'
gem 'geocoder', path: '../../geocoder'
gem 'gpx'
gem 'groupdate'
gem 'httparty'
@ -19,11 +19,12 @@ gem 'lograge'
gem 'oj'
gem 'pg'
gem 'prometheus_exporter'
gem 'activerecord-postgis-adapter', github: 'StoneGod/activerecord-postgis-adapter', branch: 'rails-8'
gem 'activerecord-postgis-adapter'
gem 'puma'
gem 'pundit'
gem 'rails', '~> 8.0'
gem 'rgeo'
gem 'rgeo-activerecord'
gem 'rswag-api'
gem 'rswag-ui'
gem 'shrine', '~> 3.6'

View file

@ -1,18 +1,7 @@
GIT
remote: https://github.com/StoneGod/activerecord-postgis-adapter.git
revision: 147fd43191ef703e2a1b3654f31d9139201a87e8
branch: rails-8
PATH
remote: ../../geocoder
specs:
activerecord-postgis-adapter (10.0.1)
activerecord (~> 8.0.0)
rgeo-activerecord (~> 8.0.0)
GIT
remote: https://github.com/alexreisner/geocoder.git
revision: 04ee2936a30b30a23ded5231d7faf6cf6c27c099
ref: 04ee293
specs:
geocoder (1.8.3)
geocoder (1.8.5)
base64 (>= 0.1.0)
csv (>= 3.0.0)
@ -71,6 +60,9 @@ GEM
activemodel (= 8.0.1)
activesupport (= 8.0.1)
timeout (>= 0.4.0)
activerecord-postgis-adapter (11.0.0)
activerecord (~> 8.0.0)
rgeo-activerecord (~> 8.0.0)
activestorage (8.0.1)
actionpack (= 8.0.1)
activejob (= 8.0.1)
@ -192,7 +184,7 @@ GEM
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.4)
logger (1.6.5)
logger (1.6.6)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
@ -461,7 +453,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
activerecord-postgis-adapter!
activerecord-postgis-adapter
bootsnap
chartkick
data_migrate
@ -490,6 +482,7 @@ DEPENDENCIES
rails (~> 8.0)
redis
rgeo
rgeo-activerecord
rspec-rails
rswag-api
rswag-specs

View file

@ -0,0 +1,52 @@
# frozen_string_literal: true
module Distanceable
extend ActiveSupport::Concern
DISTANCE_UNITS = {
km: 1000, # to meters
mi: 1609.34, # to meters
m: 1, # already in meters
ft: 0.3048, # to meters
yd: 0.9144 # to meters
}.freeze
def distance_to(other_point, unit = :km)
unless DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
end
# Extract coordinates based on what type other_point is
other_lonlat = extract_point(other_point)
return nil if other_lonlat.nil?
# Calculate distance in meters using PostGIS
distance_in_meters = self.class.connection.select_value(<<-SQL.squish)
SELECT ST_Distance(
ST_GeomFromEWKT('#{lonlat}')::geography,
ST_GeomFromEWKT('#{other_lonlat}')::geography
)
SQL
# Convert to requested unit
distance_in_meters.to_f / DISTANCE_UNITS[unit.to_sym]
end
private
def extract_point(point)
case point
when Array
unless point.length == 2
raise ArgumentError,
'Coordinates array must contain exactly 2 elements [latitude, longitude]'
end
RGeo::Geographic.spherical_factory(srid: 4326).point(point[1], point[0])
when self.class
point.lonlat
else
nil
end
end
end

View file

@ -0,0 +1,74 @@
# frozen_string_literal: true
module Nearable
extend ActiveSupport::Concern
DISTANCE_UNITS = {
km: 1000, # to meters
mi: 1609.34, # to meters
m: 1, # already in meters
ft: 0.3048, # to meters
yd: 0.9144 # to meters
}.freeze
class_methods do
# rubocop:disable Metrics/MethodLength
def near(*args)
latitude, longitude, radius, unit = extract_coordinates_and_options(*args)
unless DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
end
# Convert radius to meters for ST_DWithin
radius_in_meters = radius * DISTANCE_UNITS[unit.to_sym]
# Create a point from the given coordinates
point = "SRID=4326;POINT(#{longitude} #{latitude})"
where(<<-SQL.squish)
ST_DWithin(
lonlat::geography,
ST_GeomFromEWKT('#{point}')::geography,
#{radius_in_meters}
)
SQL
end
def with_distance(*args)
latitude, longitude, unit = extract_coordinates_and_options(*args)
unless DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
end
point = "SRID=4326;POINT(#{longitude} #{latitude})"
conversion_factor = 1.0 / DISTANCE_UNITS[unit.to_sym]
select(<<-SQL.squish)
#{table_name}.*,
ST_Distance(
lonlat::geography,
ST_GeomFromEWKT('#{point}')::geography
) * #{conversion_factor} as distance_in_#{unit}
SQL
end
# rubocop:enable Metrics/MethodLength
private
def extract_coordinates_and_options(*args)
coords = args.first
if !coords.is_a?(Array) || coords.length != 2
raise ArgumentError,
'First argument must be coordinates array containing exactly 2 elements [latitude, longitude]'
end
[coords[0], coords[1], *args[1..]].tap do |extracted|
# Set default values for missing options
extracted[2] ||= 1 if extracted.length < 3 # default radius
extracted[3] ||= :km if extracted.length < 4 # default unit
end
end
end
end

View file

@ -1,18 +1,20 @@
# frozen_string_literal: true
class Point < ApplicationRecord
reverse_geocoded_by :latitude, :longitude
include Nearable
include Distanceable
belongs_to :import, optional: true, counter_cache: true
belongs_to :visit, optional: true
belongs_to :user
validates :latitude, :longitude, :timestamp, presence: true
validates :timestamp, uniqueness: {
scope: %i[latitude longitude user_id],
validates :timestamp, :lonlat, presence: true
validates :lonlat, uniqueness: {
scope: %i[timestamp user_id],
message: 'already has a point at this location and time for this user',
index: true
}
enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3 }, suffix: true
enum :trigger, {
unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3,
@ -47,6 +49,14 @@ class Point < ApplicationRecord
reverse_geocoded_at.present?
end
def lon
lonlat.x
end
def lat
lonlat.y
end
private
def broadcast_coordinates

View file

@ -29,7 +29,7 @@ class Visit < ApplicationRecord
return area&.radius if area.present?
radius = points.map do |point|
Geocoder::Calculations.distance_between(center, [point.latitude, point.longitude])
Geocoder::Calculations.distance_between(center, [point.lat, point.lon])
end.max
radius && radius >= 15 ? radius : 15

View file

@ -8,8 +8,8 @@ class Api::SlimPointSerializer
def call
{
id: point.id,
latitude: point.latitude,
longitude: point.longitude,
latitude: point.lat,
longitude: point.lon,
timestamp: point.timestamp
}
end

View file

@ -22,8 +22,8 @@ class ExportSerializer
def export_point(point)
{
lat: point.latitude,
lon: point.longitude,
lat: point.lat,
lon: point.lon,
bs: battery_status(point),
batt: point.battery,
p: point.ping,

View file

@ -1,14 +1,17 @@
# frozen_string_literal: true
class PointSerializer
EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id id import_id user_id raw_data].freeze
EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id id import_id user_id raw_data lonlat].freeze
def initialize(point)
@point = point
end
def call
point.attributes.except(*EXCLUDED_ATTRIBUTES)
point.attributes.except(*EXCLUDED_ATTRIBUTES).tap do |attributes|
attributes['latitude'] = point.lat.to_s
attributes['longitude'] = point.lon.to_s
end
end
private

View file

@ -14,7 +14,7 @@ class Points::GeojsonSerializer
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [point.longitude, point.latitude]
coordinates: [point.lon.to_s, point.lat.to_s]
},
properties: PointSerializer.new(point).call
}

View file

@ -38,7 +38,7 @@ class Areas::Visits::Create
end
points = Point.where(user_id: user.id)
.near([area.latitude, area.longitude], area_radius, units: DISTANCE_UNIT)
.near([area.latitude, area.longitude], area_radius, DISTANCE_UNIT)
.order(timestamp: :asc)
# check if all points within the area are assigned to a visit

View file

@ -27,8 +27,7 @@ class Geojson::ImportParser
def point_exists?(params, user_id)
Point.exists?(
latitude: params[:latitude],
longitude: params[:longitude],
lonlat: params[:lonlat],
timestamp: params[:timestamp],
user_id:
)

View file

@ -33,8 +33,7 @@ class Geojson::Params
def build_point(feature)
{
latitude: feature[:geometry][:coordinates][1],
longitude: feature[:geometry][:coordinates][0],
lonlat: "POINT(#{feature[:geometry][:coordinates][0]} #{feature[:geometry][:coordinates][1]})",
battery_status: feature[:properties][:battery_state],
battery: battery_level(feature[:properties][:battery_level]),
timestamp: timestamp(feature),
@ -64,8 +63,7 @@ class Geojson::Params
def build_line_point(point)
{
latitude: point[1],
longitude: point[0],
lonlat: "POINT(#{point[0]} #{point[1]})",
timestamp: timestamp(point),
raw_data: point
}

View file

@ -16,14 +16,12 @@ class GoogleMaps::PhoneTakeoutParser
points_data.compact.each.with_index(1) do |point_data, index|
next if Point.exists?(
timestamp: point_data[:timestamp],
latitude: point_data[:latitude],
longitude: point_data[:longitude],
lonlat: point_data[:lonlat],
user_id:
)
Point.create(
latitude: point_data[:latitude],
longitude: point_data[:longitude],
lonlat: point_data[:lonlat],
timestamp: point_data[:timestamp],
raw_data: point_data[:raw_data],
accuracy: point_data[:accuracy],
@ -72,8 +70,7 @@ class GoogleMaps::PhoneTakeoutParser
def point_hash(lat, lon, timestamp, raw_data)
{
latitude: lat.to_f,
longitude: lon.to_f,
lonlat: "POINT(#{lon.to_f} #{lat.to_f})",
timestamp:,
raw_data:,
accuracy: raw_data['accuracyMeters'],

View file

@ -25,8 +25,7 @@ class GoogleMaps::RecordsImporter
# rubocop:disable Metrics/MethodLength
def prepare_location_data(location)
{
latitude: location['latitudeE7'].to_f / 10**7,
longitude: location['longitudeE7'].to_f / 10**7,
lonlat: "POINT(#{location['longitudeE7'].to_f / 10**7} #{location['latitudeE7'].to_f / 10**7})",
timestamp: parse_timestamp(location),
altitude: location['altitude'],
velocity: location['velocity'],
@ -47,7 +46,7 @@ class GoogleMaps::RecordsImporter
# rubocop:disable Rails/SkipsModelValidations
Point.upsert_all(
unique_batch,
unique_by: %i[latitude longitude timestamp user_id],
unique_by: %i[lonlat timestamp user_id],
returning: false,
on_duplicate: :skip
)
@ -59,8 +58,7 @@ class GoogleMaps::RecordsImporter
def deduplicate_batch(batch)
batch.uniq do |record|
[
record[:latitude].round(7),
record[:longitude].round(7),
record[:lonlat],
record[:timestamp],
record[:user_id]
]

View file

@ -3,87 +3,135 @@
class GoogleMaps::SemanticHistoryParser
include Imports::Broadcaster
BATCH_SIZE = 1000
attr_reader :import, :user_id
def initialize(import, user_id)
@import = import
@user_id = user_id
@current_index = 0
end
def call
points_data = parse_json
points_data.each.with_index(1) do |point_data, index|
next if Point.exists?(
timestamp: point_data[:timestamp],
latitude: point_data[:latitude],
longitude: point_data[:longitude],
user_id:
)
Point.create(
latitude: point_data[:latitude],
longitude: point_data[:longitude],
timestamp: point_data[:timestamp],
raw_data: point_data[:raw_data],
topic: 'Google Maps Timeline Export',
tracker_id: 'google-maps-timeline-export',
import_id: import.id,
user_id:
)
broadcast_import_progress(import, index)
points_data.each_slice(BATCH_SIZE) do |batch|
@current_index += batch.size
process_batch(batch)
broadcast_import_progress(import, @current_index)
end
end
private
def process_batch(batch)
records = batch.map { |point_data| prepare_point_data(point_data) }
# rubocop:disable Rails/SkipsModelValidations
Point.upsert_all(
records,
unique_by: %i[lonlat timestamp user_id],
returning: false,
on_duplicate: :skip
)
# rubocop:enable Rails/SkipsModelValidations
rescue StandardError => e
create_notification("Failed to process location batch: #{e.message}")
end
def prepare_point_data(point_data)
{
lonlat: point_data[:lonlat],
timestamp: point_data[:timestamp],
raw_data: point_data[:raw_data],
topic: 'Google Maps Timeline Export',
tracker_id: 'google-maps-timeline-export',
import_id: import.id,
user_id: user_id,
created_at: Time.current,
updated_at: Time.current
}
end
def create_notification(message)
Notification.create!(
user_id: user_id,
title: 'Google Maps Timeline Import Error',
content: message,
kind: :error
)
end
def parse_json
import.raw_data['timelineObjects'].flat_map do |timeline_object|
parse_timeline_object(timeline_object)
end.compact
end
def parse_timeline_object(timeline_object)
if timeline_object['activitySegment'].present?
if timeline_object['activitySegment']['startLocation'].blank?
next if timeline_object['activitySegment']['waypointPath'].blank?
timeline_object['activitySegment']['waypointPath']['waypoints'].map do |waypoint|
{
latitude: waypoint['latE7'].to_f / 10**7,
longitude: waypoint['lngE7'].to_f / 10**7,
timestamp: Timestamps.parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']),
raw_data: timeline_object
}
end
else
{
latitude: timeline_object['activitySegment']['startLocation']['latitudeE7'].to_f / 10**7,
longitude: timeline_object['activitySegment']['startLocation']['longitudeE7'].to_f / 10**7,
timestamp: Timestamps.parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']),
raw_data: timeline_object
}
end
parse_activity_segment(timeline_object['activitySegment'])
elsif timeline_object['placeVisit'].present?
if timeline_object.dig('placeVisit', 'location', 'latitudeE7').present? &&
timeline_object.dig('placeVisit', 'location', 'longitudeE7').present?
{
latitude: timeline_object['placeVisit']['location']['latitudeE7'].to_f / 10**7,
longitude: timeline_object['placeVisit']['location']['longitudeE7'].to_f / 10**7,
timestamp: Timestamps.parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']),
raw_data: timeline_object
}
elsif timeline_object.dig('placeVisit', 'otherCandidateLocations')&.any?
point = timeline_object['placeVisit']['otherCandidateLocations'][0]
parse_place_visit(timeline_object['placeVisit'])
end
end
next unless point['latitudeE7'].present? && point['longitudeE7'].present?
{
latitude: point['latitudeE7'].to_f / 10**7,
longitude: point['longitudeE7'].to_f / 10**7,
timestamp: Timestamps.parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']),
raw_data: timeline_object
}
def parse_activity_segment(activity)
if activity['startLocation'].blank?
parse_waypoints(activity)
else
next
build_point_from_location(
longitude: activity['startLocation']['longitudeE7'],
latitude: activity['startLocation']['latitudeE7'],
timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'],
raw_data: activity
)
end
end
end.reject(&:blank?)
def parse_waypoints(activity)
return if activity['waypointPath'].blank?
activity['waypointPath']['waypoints'].map do |waypoint|
build_point_from_location(
longitude: waypoint['lngE7'],
latitude: waypoint['latE7'],
timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'],
raw_data: activity
)
end
end
def parse_place_visit(place_visit)
if place_visit.dig('location', 'latitudeE7').present? &&
place_visit.dig('location', 'longitudeE7').present?
build_point_from_location(
longitude: place_visit['location']['longitudeE7'],
latitude: place_visit['location']['latitudeE7'],
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
raw_data: place_visit
)
elsif (candidate = place_visit.dig('otherCandidateLocations', 0))
parse_candidate_location(candidate, place_visit)
end
end
def parse_candidate_location(candidate, place_visit)
return unless candidate['latitudeE7'].present? && candidate['longitudeE7'].present?
build_point_from_location(
longitude: candidate['longitudeE7'],
latitude: candidate['latitudeE7'],
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
raw_data: place_visit
)
end
def build_point_from_location(longitude:, latitude:, timestamp:, raw_data:)
{
lonlat: "POINT(#{longitude.to_f / 10**7} #{latitude.to_f / 10**7})",
timestamp: Timestamps.parse_timestamp(timestamp),
raw_data: raw_data
}
end
end

View file

@ -36,8 +36,7 @@ class Gpx::TrackParser
return if point_exists?(point)
Point.create(
latitude: point['lat'].to_d,
longitude: point['lon'].to_d,
lonlat: "POINT(#{point['lon'].to_d} #{point['lat'].to_d})",
altitude: point['ele'].to_i,
timestamp: Time.parse(point['time']).to_i,
import_id: import.id,
@ -51,8 +50,7 @@ class Gpx::TrackParser
def point_exists?(point)
Point.exists?(
latitude: point['lat'].to_d,
longitude: point['lon'].to_d,
lonlat: "POINT(#{point['lon'].to_d} #{point['lat'].to_d})",
timestamp: Time.parse(point['time']).to_i,
user_id:
)

View file

@ -16,9 +16,8 @@ class OwnTracks::ExportParser
points_data.each.with_index(1) do |point_data, index|
next if Point.exists?(
lonlat: point_data[:lonlat],
timestamp: point_data[:timestamp],
latitude: point_data[:latitude],
longitude: point_data[:longitude],
user_id:
)

View file

@ -7,10 +7,11 @@ class OwnTracks::Params
@params = params.to_h.deep_symbolize_keys
end
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/AbcSize
def call
{
latitude: params[:lat],
longitude: params[:lon],
lonlat: "POINT(#{params[:lon]} #{params[:lat]})",
battery: params[:batt],
ping: params[:p],
altitude: params[:alt],
@ -30,6 +31,8 @@ class OwnTracks::Params
raw_data: params.deep_stringify_keys
}
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
private

View file

@ -20,8 +20,7 @@ class Photos::ImportParser
return 0 if point_exists?(point, point['timestamp'])
Point.create(
latitude: point['latitude'].to_d,
longitude: point['longitude'].to_d,
lonlat: "POINT(#{point['longitude']} #{point['latitude']})",
timestamp: point['timestamp'],
raw_data: point,
import_id: import.id,
@ -33,8 +32,7 @@ class Photos::ImportParser
def point_exists?(point, timestamp)
Point.exists?(
latitude: point['latitude'].to_d,
longitude: point['longitude'].to_d,
lonlat: "POINT(#{point['longitude']} #{point['latitude']})",
timestamp:,
user_id:
)

View file

@ -18,7 +18,7 @@ class ReverseGeocoding::Points::FetchData
private
def update_point_with_geocoding_data
response = Geocoder.search([point.latitude, point.longitude]).first
response = Geocoder.search([point.lat, point.lon]).first
return if response.blank? || response.data['error'].present?
point.update!(

View file

@ -16,7 +16,9 @@ class Visits::Prepare
grouped_points = Visits::GroupPoints.new(day_points).group_points_by_radius
day_result = prepare_day_result(grouped_points)
# Iterate through the day_result, check if there are any points outside of visits that are between two consecutive visits. If there are none, merge the visits.
# Iterate through the day_result, check if there are any points outside
# of visits that are between two consecutive visits. If there are none,
# merge the visits.
day_result.each_cons(2) do |visit1, visit2|
next if visit1[:points].last == visit2[:points].first
@ -65,8 +67,8 @@ class Visits::Prepare
center_point = group.first
{
latitude: center_point.latitude,
longitude: center_point.longitude,
latitude: center_point.lat,
longitude: center_point.lon,
radius: calculate_radius(center_point, group),
points: group,
duration: (group.last.timestamp - group.first.timestamp).to_i / 60,

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddLonlatToPoints < ActiveRecord::Migration[8.0]
def change
add_column :points, :lonlat, :st_point, geographic: true
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddLonlatIndex < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :points, :lonlat, using: :gist, algorithm: :concurrently
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class AddUniqueLonLatIndexToPoints < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
return if index_exists?(:points, %i[lonlat timestamp user_id], name: 'index_points_on_lonlat_timestamp_user_id')
add_index :points, %i[lonlat timestamp user_id], unique: true,
name: 'index_points_on_lonlat_timestamp_user_id',
algorithm: :concurrently
end
end

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
class RemovePointsLatitudeLongitudeUniquenessIndex < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def up
return unless index_exists?(
:points, %i[latitude longitude timestamp user_id],
name: 'unique_points_lat_long_timestamp_user_id_index'
)
remove_index :points,
name: 'unique_points_lat_long_timestamp_user_id_index',
algorithm: :concurrently
end
def down
return if index_exists?(
:points, %i[latitude longitude timestamp user_id],
name: 'unique_points_lat_long_timestamp_user_id_index'
)
add_index :points, %i[latitude longitude timestamp user_id],
unique: true,
name: 'unique_points_lat_long_timestamp_user_id_index',
algorithm: :concurrently
end
end

8
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do
ActiveRecord::Schema[8.0].define(version: 2025_02_21_151930) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@ -160,6 +160,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do
t.decimal "course", precision: 8, scale: 5
t.decimal "course_accuracy", precision: 8, scale: 5
t.string "external_track_id"
t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true}
t.index ["altitude"], name: "index_points_on_altitude"
t.index ["battery"], name: "index_points_on_battery"
t.index ["battery_status"], name: "index_points_on_battery_status"
@ -169,8 +170,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do
t.index ["external_track_id"], name: "index_points_on_external_track_id"
t.index ["geodata"], name: "index_points_on_geodata", using: :gin
t.index ["import_id"], name: "index_points_on_import_id"
t.index ["latitude", "longitude", "timestamp", "user_id"], name: "unique_points_lat_long_timestamp_user_id_index", unique: true
t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude"
t.index ["lonlat", "timestamp", "user_id"], name: "index_points_on_lonlat_timestamp_user_id", unique: true
t.index ["lonlat"], name: "index_points_on_lonlat", using: :gist
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
t.index ["timestamp"], name: "index_points_on_timestamp"
t.index ["trigger"], name: "index_points_on_trigger"
@ -201,7 +203,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.geometry "path", limit: {:srid=>3857, :type=>"line_string"}
t.geometry "path", limit: {srid: 3857, type: "line_string"}
t.index ["user_id"], name: "index_trips_on_user_id"
end

View file

@ -29,11 +29,11 @@ FactoryBot.define do
course { nil }
course_accuracy { nil }
external_track_id { nil }
lonlat { "POINT(#{FFaker::Geolocation.lng} #{FFaker::Geolocation.lat})" }
user
trait :with_known_location do
latitude { 55.755826 }
longitude { 37.6173 }
lonlat { 'POINT(37.6173 55.755826)' }
end
trait :with_geodata do

File diff suppressed because one or more lines are too long

View file

@ -7,7 +7,7 @@ RSpec.describe Trips::CreatePathJob, type: :job do
let(:points) { trip.points }
let(:trip_path) do
"LINESTRING (#{points.map do |point|
"#{point.longitude.to_f.round(5)} #{point.latitude.to_f.round(5)}"
"#{point.lon.to_f.round(5)} #{point.lat.to_f.round(5)}"
end.join(', ')})"
end

View file

@ -12,6 +12,7 @@ RSpec.describe Point, type: :model do
it { is_expected.to validate_presence_of(:latitude) }
it { is_expected.to validate_presence_of(:longitude) }
it { is_expected.to validate_presence_of(:timestamp) }
it { is_expected.to validate_presence_of(:lonlat) }
end
describe 'scopes' do

View file

@ -25,8 +25,8 @@ RSpec.describe Stat, type: :model do
context 'when there are points' do
let!(:points) do
create(:point, user:, latitude: 1, longitude: 1, timestamp: DateTime.new(year, 1, 1, 1))
create(:point, user:, latitude: 2, longitude: 2, timestamp: DateTime.new(year, 1, 1, 2))
create(:point, user:, lonlat: 'POINT(1 1)', timestamp: DateTime.new(year, 1, 1, 1))
create(:point, user:, lonlat: 'POINT(2 2)', timestamp: DateTime.new(year, 1, 1, 2))
end
before { expected_distance[0][1] = 157.23 }

View file

@ -18,8 +18,8 @@ RSpec.describe ExportSerializer do
user_email => {
'dawarich-export' => [
{
lat: points.first.latitude,
lon: points.first.longitude,
lat: points.first.lat,
lon: points.first.lon,
bs: 'u',
batt: points.first.battery,
p: points.first.ping,
@ -39,8 +39,8 @@ RSpec.describe ExportSerializer do
raw_data: points.first.raw_data
},
{
lat: points.second.latitude,
lon: points.second.longitude,
lat: points.second.lat,
lon: points.second.lon,
bs: 'u',
batt: points.second.battery,
p: points.second.ping,

View file

@ -20,7 +20,7 @@ RSpec.describe Points::GeojsonSerializer do
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [point.longitude, point.latitude]
coordinates: [point.lon, point.lat]
},
properties: PointSerializer.new(point).call
}

View file

@ -24,8 +24,8 @@ RSpec.describe Points::GpxSerializer do
serializer.tracks[0].points.each_with_index do |track_point, index|
point = points[index]
expect(track_point.lat).to eq(point.latitude)
expect(track_point.lon).to eq(point.longitude)
expect(track_point.lat).to eq(point.lat)
expect(track_point.lon).to eq(point.lon)
expect(track_point.time).to eq(point.recorded_at)
end
end

View file

@ -23,14 +23,14 @@ RSpec.describe Areas::Visits::Create do
context 'when there are points' do
let(:home_visit_date) { DateTime.new(2021, 1, 1, 10, 0, 0, Time.zone.formatted_offset) }
let!(:home_point1) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date) }
let!(:home_point2) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date + 10.minutes) }
let!(:home_point3) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date + 20.minutes) }
let!(:home_point1) { create(:point, user:, lonlat: 'POINT(0 0)', timestamp: home_visit_date) }
let!(:home_point2) { create(:point, user:, lonlat: 'POINT(0 0)', timestamp: home_visit_date + 10.minutes) }
let!(:home_point3) { create(:point, user:, lonlat: 'POINT(0 0)', timestamp: home_visit_date + 20.minutes) }
let(:work_visit_date) { DateTime.new(2021, 1, 1, 12, 0, 0, Time.zone.formatted_offset) }
let!(:work_point1) { create(:point, user:, latitude: 1, longitude: 1, timestamp: work_visit_date) }
let!(:work_point2) { create(:point, user:, latitude: 1, longitude: 1, timestamp: work_visit_date + 10.minutes) }
let!(:work_point3) { create(:point, user:, latitude: 1, longitude: 1, timestamp: work_visit_date + 20.minutes) }
let!(:work_point1) { create(:point, user:, lonlat: 'POINT(1 1)', timestamp: work_visit_date) }
let!(:work_point2) { create(:point, user:, lonlat: 'POINT(1 1)', timestamp: work_visit_date + 10.minutes) }
let!(:work_point3) { create(:point, user:, lonlat: 'POINT(1 1)', timestamp: work_visit_date + 20.minutes) }
it 'creates visits' do
expect { create_visits }.to change { Visit.count }.by(2)
@ -47,7 +47,7 @@ RSpec.describe Areas::Visits::Create do
end
context 'when there are points outside the time threshold' do
let(:home_point4) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date + 40.minutes) }
let(:home_point4) { create(:point, user:, lonlat: 'POINT(0 0)', timestamp: home_visit_date + 40.minutes) }
it 'does not create visits' do
expect { create_visits }.to change { Visit.count }.by(2)

View file

@ -18,8 +18,7 @@ RSpec.describe Geojson::Params do
it 'returns the correct data for each point' do
expect(subject.first).to eq(
latitude: '0.0',
longitude: '0.0',
lonlat: 'POINT(0.1 0.1)',
battery_status: nil,
battery: nil,
timestamp: Time.zone.at(1_609_459_201),
@ -34,8 +33,8 @@ RSpec.describe Geojson::Params do
'geometry' => {
'type' => 'Point',
'coordinates' => [
'0.0',
'0.0'
'0.1',
'0.1'
]
},
'properties' => {
@ -72,8 +71,7 @@ RSpec.describe Geojson::Params do
it 'returns the correct data for each point' do
expect(subject.first).to eq(
latitude: 10.758321212464024,
longitude: 106.64234449272531,
lonlat: 'POINT(106.64234449272531 10.758321212464024)',
battery_status: nil,
battery: nil,
timestamp: Time.parse('2024-11-03T16:30:11.331+07:00').to_i,

View file

@ -9,6 +9,7 @@ RSpec.describe GoogleMaps::PhoneTakeoutParser do
let(:user) { create(:user) }
context 'when file content is an object' do
# This file contains 3 duplicates
let(:file_path) { Rails.root.join('spec/fixtures/files/google/phone-takeout.json') }
let(:raw_data) { JSON.parse(File.read(file_path)) }
let(:import) { create(:import, user:, name: 'phone_takeout.json', raw_data:) }
@ -21,6 +22,7 @@ RSpec.describe GoogleMaps::PhoneTakeoutParser do
end
context 'when file content is an array' do
# This file contains 4 duplicates
let(:file_path) { Rails.root.join('spec/fixtures/files/google/location-history.json') }
let(:raw_data) { JSON.parse(File.read(file_path)) }
let(:import) { create(:import, user:, name: 'phone_takeout.json', raw_data:) }
@ -33,12 +35,12 @@ RSpec.describe GoogleMaps::PhoneTakeoutParser do
it 'creates points with correct data' do
parser
expect(Point.all[6].latitude).to eq(27.696576.to_d)
expect(Point.all[6].longitude).to eq(-97.376949.to_d)
expect(Point.all[6].lat).to eq(27.696576)
expect(Point.all[6].lon).to eq(-97.376949)
expect(Point.all[6].timestamp).to eq(1_693_180_140)
expect(Point.last.latitude).to eq(27.709617.to_d)
expect(Point.last.longitude).to eq(-97.375988.to_d)
expect(Point.last.lat).to eq(27.709617)
expect(Point.last.lon).to eq(-97.375988)
expect(Point.last.timestamp).to eq(1_693_180_320)
end
end

View file

@ -56,8 +56,7 @@ RSpec.describe GoogleMaps::RecordsImporter do
:point,
user: import.user,
import: import,
latitude: 12.3456789,
longitude: 12.3456789,
lonlat: 'POINT(12.3456789 12.3456789)',
timestamp: time.to_i
)
end

View file

@ -80,7 +80,7 @@ RSpec.describe GoogleMaps::SemanticHistoryParser do
{
'activitySegment' => {
'startLocation' => { 'latitudeE7' => 123_456_789, 'longitudeE7' => 123_456_789 },
'duration' => { 'startTimestamp' => (time.to_i).to_s }
'duration' => { 'startTimestamp' => time.to_i.to_s }
}
}
end

View file

@ -53,8 +53,8 @@ RSpec.describe Gpx::TrackParser do
it 'creates points with correct data' do
parser
expect(Point.first.latitude).to eq(37.17221.to_d)
expect(Point.first.longitude).to eq(-3.55468.to_d)
expect(Point.first.lat).to eq(37.1722103)
expect(Point.first.lon).to eq(-3.55468)
expect(Point.first.altitude).to eq(1066)
expect(Point.first.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i)
expect(Point.first.velocity).to eq('2.9')
@ -67,8 +67,8 @@ RSpec.describe Gpx::TrackParser do
it 'creates points with correct data' do
parser
expect(Point.first.latitude).to eq(10.758321.to_d)
expect(Point.first.longitude).to eq(106.642344.to_d)
expect(Point.first.lat).to eq(10.758321212464024)
expect(Point.first.lon).to eq(106.64234449272531)
expect(Point.first.altitude).to eq(17)
expect(Point.first.timestamp).to eq(1_730_626_211)
expect(Point.first.velocity).to eq('2.8')

View file

@ -17,9 +17,10 @@ RSpec.describe OwnTracks::ExportParser do
it 'correctly writes attributes' do
parser
expect(Point.first.attributes).to include(
'latitude' => 52.225,
'longitude' => 13.332,
point = Point.first
expect(point.lonlat.x).to be_within(0.001).of(13.332)
expect(point.lonlat.y).to be_within(0.001).of(52.225)
expect(point.attributes.except('lonlat')).to include(
'battery_status' => 'charging',
'battery' => 94,
'ping' => '100.266',

View file

@ -13,8 +13,7 @@ RSpec.describe OwnTracks::Params do
let(:expected_json) do
{
latitude: 52.225,
longitude: 13.332,
lonlat: 'POINT(13.332 52.225)',
battery: 94,
ping: 100.266,
altitude: 36,

View file

@ -23,13 +23,13 @@ RSpec.describe Photos::ImportParser do
it 'creates points with correct attributes' do
service
expect(Point.first.latitude.to_f).to eq(59.0000)
expect(Point.first.longitude.to_f).to eq(30.0000)
expect(Point.first.lat.to_f).to eq(59.0000)
expect(Point.first.lon.to_f).to eq(30.0000)
expect(Point.first.timestamp).to eq(978_296_400)
expect(Point.first.import_id).to eq(import.id)
expect(Point.second.latitude.to_f).to eq(55.0001)
expect(Point.second.longitude.to_f).to eq(37.0001)
expect(Point.second.lat.to_f).to eq(55.0001)
expect(Point.second.lon.to_f).to eq(37.0001)
expect(Point.second.timestamp).to eq(978_296_400)
expect(Point.second.import_id).to eq(import.id)
end
@ -37,7 +37,7 @@ RSpec.describe Photos::ImportParser do
context 'when there are points with the same coordinates' do
let!(:existing_point) do
create(:point, latitude: 59.0000, longitude: 30.0000, timestamp: 978_296_400, user:)
create(:point, lonlat: 'POINT(30.0000 59.0000)', timestamp: 978_296_400, user:)
end
it 'creates only new points' do

View file

@ -27,7 +27,7 @@ RSpec.describe ReverseGeocoding::Points::FetchData do
it 'calls Geocoder' do
fetch_data
expect(Geocoder).to have_received(:search).with([point.latitude, point.longitude])
expect(Geocoder).to have_received(:search).with([point.lat, point.lon])
end
end

View file

@ -26,24 +26,21 @@ RSpec.describe Stats::CalculateMonth do
user:,
import:,
timestamp: timestamp1,
latitude: 52.107902115161316,
longitude: 14.452712811406352)
lonlat: 'POINT(14.452712811406352 52.107902115161316)')
end
let!(:point2) do
create(:point,
user:,
import:,
timestamp: timestamp2,
latitude: 51.9746598171507,
longitude: 12.291519487061901)
lonlat: 'POINT(12.291519487061901 51.9746598171507)')
end
let!(:point3) do
create(:point,
user:,
import:,
timestamp: timestamp3,
latitude: 52.72859111523629,
longitude: 9.77973105800526)
lonlat: 'POINT(9.77973105800526 52.72859111523629)')
end
context 'when units are kilometers' do

View file

@ -6,17 +6,17 @@ RSpec.describe Visits::GroupPoints do
describe '#group_points_by_radius' do
it 'groups points by radius' do
day_points = [
build(:point, latitude: 0, longitude: 0, timestamp: 1.day.ago),
build(:point, latitude: 0.00001, longitude: 0.00001, timestamp: 1.day.ago + 1.minute),
build(:point, latitude: 0.00002, longitude: 0.00002, timestamp: 1.day.ago + 2.minutes),
build(:point, latitude: 0.00003, longitude: 0.00003, timestamp: 1.day.ago + 3.minutes),
build(:point, latitude: 0.00004, longitude: 0.00004, timestamp: 1.day.ago + 4.minutes),
build(:point, latitude: 0.00005, longitude: 0.00005, timestamp: 1.day.ago + 5.minutes),
build(:point, latitude: 0.00006, longitude: 0.00006, timestamp: 1.day.ago + 6.minutes),
build(:point, latitude: 0.00007, longitude: 0.00007, timestamp: 1.day.ago + 7.minutes),
build(:point, latitude: 0.00008, longitude: 0.00008, timestamp: 1.day.ago + 8.minutes),
build(:point, latitude: 0.00009, longitude: 0.00009, timestamp: 1.day.ago + 9.minutes),
build(:point, latitude: 0.0001, longitude: 0.0009, timestamp: 1.day.ago + 9.minutes)
build(:point, lonlat: 'POINT(0 0)', timestamp: 1.day.ago),
build(:point, lonlat: 'POINT(0.00001 0.00001)', timestamp: 1.day.ago + 1.minute),
build(:point, lonlat: 'POINT(0.00002 0.00002)', timestamp: 1.day.ago + 2.minutes),
build(:point, lonlat: 'POINT(0.00003 0.00003)', timestamp: 1.day.ago + 3.minutes),
build(:point, lonlat: 'POINT(0.00004 0.00004)', timestamp: 1.day.ago + 4.minutes),
build(:point, lonlat: 'POINT(0.00005 0.00005)', timestamp: 1.day.ago + 5.minutes),
build(:point, lonlat: 'POINT(0.00006 0.00006)', timestamp: 1.day.ago + 6.minutes),
build(:point, lonlat: 'POINT(0.00007 0.00007)', timestamp: 1.day.ago + 7.minutes),
build(:point, lonlat: 'POINT(0.00008 0.00008)', timestamp: 1.day.ago + 8.minutes),
build(:point, lonlat: 'POINT(0.00009 0.00009)', timestamp: 1.day.ago + 9.minutes),
build(:point, lonlat: 'POINT(0.001 0.001)', timestamp: 1.day.ago + 10.minutes)
]
grouped_points = described_class.new(day_points).group_points_by_radius

View file

@ -14,21 +14,21 @@ RSpec.describe Visits::Group do
context 'when points are too far apart' do
it 'groups points into separate visits' do
points = [
build(:point, latitude: 0, longitude: 0, timestamp: 1.day.ago),
build(:point, latitude: 0.00001, longitude: 0.00001, timestamp: 1.day.ago + 5.minutes),
build(:point, latitude: 0.00002, longitude: 0.00002, timestamp: 1.day.ago + 10.minutes),
build(:point, latitude: 0.00003, longitude: 0.00003, timestamp: 1.day.ago + 15.minutes),
build(:point, latitude: 0.00004, longitude: 0.00004, timestamp: 1.day.ago + 20.minutes),
build(:point, latitude: 0.00005, longitude: 0.00005, timestamp: 1.day.ago + 25.minutes),
build(:point, latitude: 0.00006, longitude: 0.00006, timestamp: 1.day.ago + 30.minutes),
build(:point, latitude: 0.00007, longitude: 0.00007, timestamp: 1.day.ago + 35.minutes),
build(:point, latitude: 0.00008, longitude: 0.00008, timestamp: 1.day.ago + 40.minutes),
build(:point, latitude: 0.00009, longitude: 0.00009, timestamp: 1.day.ago + 45.minutes),
build(:point, latitude: 0.0001, longitude: 0.0001, timestamp: 1.day.ago + 50.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 55.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 95.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 100.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 105.minutes)
build(:point, lonlat: 'POINT(0 0)', timestamp: 1.day.ago),
build(:point, lonlat: 'POINT(0.00001 0.00001)', timestamp: 1.day.ago + 5.minutes),
build(:point, lonlat: 'POINT(0.00002 0.00002)', timestamp: 1.day.ago + 10.minutes),
build(:point, lonlat: 'POINT(0.00003 0.00003)', timestamp: 1.day.ago + 15.minutes),
build(:point, lonlat: 'POINT(0.00004 0.00004)', timestamp: 1.day.ago + 20.minutes),
build(:point, lonlat: 'POINT(0.00005 0.00005)', timestamp: 1.day.ago + 25.minutes),
build(:point, lonlat: 'POINT(0.00006 0.00006)', timestamp: 1.day.ago + 30.minutes),
build(:point, lonlat: 'POINT(0.00007 0.00007)', timestamp: 1.day.ago + 35.minutes),
build(:point, lonlat: 'POINT(0.00008 0.00008)', timestamp: 1.day.ago + 40.minutes),
build(:point, lonlat: 'POINT(0.00009 0.00009)', timestamp: 1.day.ago + 45.minutes),
build(:point, lonlat: 'POINT(0.0001 0.0001)', timestamp: 1.day.ago + 50.minutes),
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: 1.day.ago + 55.minutes),
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: 1.day.ago + 95.minutes),
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: 1.day.ago + 100.minutes),
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: 1.day.ago + 105.minutes)
]
expect(group.call(points)).to \
eq({

View file

@ -7,21 +7,21 @@ RSpec.describe Visits::Prepare do
let(:static_time) { Time.zone.local(2021, 1, 1, 0, 0, 0) }
let(:points) do
[
build(:point, latitude: 0, longitude: 0, timestamp: static_time),
build(:point, latitude: 0.00001, longitude: 0.00001, timestamp: static_time + 5.minutes),
build(:point, latitude: 0.00002, longitude: 0.00002, timestamp: static_time + 10.minutes),
build(:point, latitude: 0.00003, longitude: 0.00003, timestamp: static_time + 15.minutes),
build(:point, latitude: 0.00004, longitude: 0.00004, timestamp: static_time + 20.minutes),
build(:point, latitude: 0.00005, longitude: 0.00005, timestamp: static_time + 25.minutes),
build(:point, latitude: 0.00006, longitude: 0.00006, timestamp: static_time + 30.minutes),
build(:point, latitude: 0.00007, longitude: 0.00007, timestamp: static_time + 35.minutes),
build(:point, latitude: 0.00008, longitude: 0.00008, timestamp: static_time + 40.minutes),
build(:point, latitude: 0.00009, longitude: 0.00009, timestamp: static_time + 45.minutes),
build(:point, latitude: 0.0001, longitude: 0.0001, timestamp: static_time + 50.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: static_time + 55.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: static_time + 95.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: static_time + 100.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: static_time + 105.minutes)
build(:point, lonlat: 'POINT(0 0)', timestamp: static_time),
build(:point, lonlat: 'POINT(0.00001 0.00001)', timestamp: static_time + 5.minutes),
build(:point, lonlat: 'POINT(0.00002 0.00002)', timestamp: static_time + 10.minutes),
build(:point, lonlat: 'POINT(0.00003 0.00003)', timestamp: static_time + 15.minutes),
build(:point, lonlat: 'POINT(0.00004 0.00004)', timestamp: static_time + 20.minutes),
build(:point, lonlat: 'POINT(0.00005 0.00005)', timestamp: static_time + 25.minutes),
build(:point, lonlat: 'POINT(0.00006 0.00006)', timestamp: static_time + 30.minutes),
build(:point, lonlat: 'POINT(0.00007 0.00007)', timestamp: static_time + 35.minutes),
build(:point, lonlat: 'POINT(0.00008 0.00008)', timestamp: static_time + 40.minutes),
build(:point, lonlat: 'POINT(0.00009 0.00009)', timestamp: static_time + 45.minutes),
build(:point, lonlat: 'POINT(0.0001 0.0001)', timestamp: static_time + 50.minutes),
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 55.minutes),
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 95.minutes),
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 100.minutes),
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 105.minutes)
]
end