diff --git a/lib/aprsme/geo_utils.ex b/lib/aprsme/geo_utils.ex new file mode 100644 index 0000000..8fec9eb --- /dev/null +++ b/lib/aprsme/geo_utils.ex @@ -0,0 +1,50 @@ +defmodule Aprsme.GeoUtils do + @moduledoc """ + Geographic utility functions for calculating distances and related operations. + """ + + @earth_radius_meters 6_371_000 + + @doc """ + Calculate the Haversine distance between two points in meters. + + ## Examples + + iex> Aprsme.GeoUtils.haversine_distance(33.16961, -96.4921, 33.16962, -96.4921) + 1.11 + """ + def haversine_distance(lat1, lon1, lat2, lon2) + when is_number(lat1) and is_number(lon1) and is_number(lat2) and is_number(lon2) do + # Convert to radians + lat1_rad = lat1 * :math.pi() / 180 + lat2_rad = lat2 * :math.pi() / 180 + dlat_rad = (lat2 - lat1) * :math.pi() / 180 + dlon_rad = (lon2 - lon1) * :math.pi() / 180 + + # Haversine formula + a = + :math.sin(dlat_rad / 2) * :math.sin(dlat_rad / 2) + + :math.cos(lat1_rad) * :math.cos(lat2_rad) * + :math.sin(dlon_rad / 2) * :math.sin(dlon_rad / 2) + + c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) + + # Distance in meters + @earth_radius_meters * c + end + + def haversine_distance(_, _, _, _), do: nil + + @doc """ + Check if the distance between two points exceeds a minimum threshold. + This helps filter out GPS drift and insignificant movements. + + Default threshold is 10 meters to account for typical GPS accuracy. + """ + def significant_movement?(lat1, lon1, lat2, lon2, threshold_meters \\ 10) do + case haversine_distance(lat1, lon1, lat2, lon2) do + nil -> false + distance -> distance > threshold_meters + end + end +end diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index f46eb2b..4e91314 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -8,6 +8,7 @@ defmodule AprsmeWeb.MapLive.Index do import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1] import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2] + alias Aprsme.GeoUtils alias Aprsme.Packets.Clustering alias AprsmeWeb.Endpoint alias AprsmeWeb.MapLive.MapHelpers @@ -638,6 +639,23 @@ defmodule AprsmeWeb.MapLive.Index do should_add_marker?(lat, lon, callsign_key, socket) -> handle_valid_postgres_packet(packet, lat, lon, socket) + should_update_marker?(lat, lon, callsign_key, socket) -> + # Marker exists and is within bounds - check if there's significant movement + existing_packet = socket.assigns.visible_packets[callsign_key] + {existing_lat, existing_lon, _} = MapHelpers.get_coordinates(existing_packet) + + # Check if we have valid existing coordinates + if is_number(existing_lat) and is_number(existing_lon) and + GeoUtils.significant_movement?(existing_lat, existing_lon, lat, lon, 15) do + # Significant movement detected (more than 15 meters), update the marker + handle_valid_postgres_packet(packet, lat, lon, socket) + else + # Just GPS drift or invalid coordinates, update the packet data but don't send visual update + new_visible_packets = Map.put(socket.assigns.visible_packets, callsign_key, packet) + socket = assign(socket, visible_packets: new_visible_packets) + {:noreply, socket} + end + true -> {:noreply, socket} end @@ -655,6 +673,12 @@ defmodule AprsmeWeb.MapLive.Index do MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) end + defp should_update_marker?(lat, lon, callsign_key, socket) do + !is_nil(lat) and !is_nil(lon) and + Map.has_key?(socket.assigns.visible_packets, callsign_key) and + MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) + end + defp remove_marker_from_map(callsign_key, socket) do socket = push_event(socket, "remove_marker", %{id: callsign_key}) new_visible_packets = Map.delete(socket.assigns.visible_packets, callsign_key) diff --git a/test/aprsme/geo_utils_test.exs b/test/aprsme/geo_utils_test.exs new file mode 100644 index 0000000..f163b26 --- /dev/null +++ b/test/aprsme/geo_utils_test.exs @@ -0,0 +1,81 @@ +defmodule Aprsme.GeoUtilsTest do + use ExUnit.Case, async: true + + alias Aprsme.GeoUtils + + describe "haversine_distance/4" do + test "calculates zero distance for same point" do + assert GeoUtils.haversine_distance(33.16961, -96.4921, 33.16961, -96.4921) == 0.0 + end + + test "calculates small distances accurately" do + # About 1.1 meters north + distance = GeoUtils.haversine_distance(33.16961, -96.4921, 33.16962, -96.4921) + assert_in_delta distance, 1.11, 0.1 + end + + test "calculates GPS drift-like distances" do + # Typical GPS drift of ~5 meters + distance = GeoUtils.haversine_distance(33.16961, -96.4921, 33.169655, -96.4921) + assert_in_delta distance, 5.0, 0.5 + end + + test "calculates larger distances" do + # About 100 meters + distance = GeoUtils.haversine_distance(33.16961, -96.4921, 33.17061, -96.4921) + assert_in_delta distance, 111.2, 1.0 + end + + test "handles nil inputs" do + assert GeoUtils.haversine_distance(nil, -96.4921, 33.16962, -96.4921) == nil + assert GeoUtils.haversine_distance(33.16961, nil, 33.16962, -96.4921) == nil + assert GeoUtils.haversine_distance(33.16961, -96.4921, nil, -96.4921) == nil + assert GeoUtils.haversine_distance(33.16961, -96.4921, 33.16962, nil) == nil + end + + test "handles non-numeric inputs" do + assert GeoUtils.haversine_distance("33.16961", -96.4921, 33.16962, -96.4921) == nil + assert GeoUtils.haversine_distance(33.16961, "invalid", 33.16962, -96.4921) == nil + end + end + + describe "significant_movement?/5" do + test "returns false for no movement" do + refute GeoUtils.significant_movement?(33.16961, -96.4921, 33.16961, -96.4921) + end + + test "returns false for GPS drift under default threshold" do + # About 5 meters - typical GPS drift + refute GeoUtils.significant_movement?(33.16961, -96.4921, 33.169655, -96.4921) + end + + test "returns true for movement over default threshold" do + # About 11 meters + assert GeoUtils.significant_movement?(33.16961, -96.4921, 33.1697, -96.4921) + end + + test "respects custom threshold" do + # About 5 meters movement + lat1 = 33.16961 + lon1 = -96.4921 + lat2 = 33.169655 + lon2 = -96.4921 + + # With 3 meter threshold, should be significant + assert GeoUtils.significant_movement?(lat1, lon1, lat2, lon2, 3) + + # With 10 meter threshold (default), should not be significant + refute GeoUtils.significant_movement?(lat1, lon1, lat2, lon2, 10) + + # With 20 meter threshold, definitely not significant + refute GeoUtils.significant_movement?(lat1, lon1, lat2, lon2, 20) + end + + test "handles nil inputs" do + refute GeoUtils.significant_movement?(nil, -96.4921, 33.16962, -96.4921) + refute GeoUtils.significant_movement?(33.16961, nil, 33.16962, -96.4921) + refute GeoUtils.significant_movement?(33.16961, -96.4921, nil, -96.4921) + refute GeoUtils.significant_movement?(33.16961, -96.4921, 33.16962, nil) + end + end +end diff --git a/test/aprsme_web/live/map_live/movement_test.exs b/test/aprsme_web/live/map_live/movement_test.exs new file mode 100644 index 0000000..7220642 --- /dev/null +++ b/test/aprsme_web/live/map_live/movement_test.exs @@ -0,0 +1,163 @@ +defmodule AprsmeWeb.MapLive.MovementTest do + use AprsmeWeb.ConnCase, async: false + + import Mox + import Phoenix.LiveViewTest + + alias Aprsme.GeoUtils + + setup :verify_on_exit! + + describe "GPS drift filtering" do + setup do + # Mock the Packets module to return empty results for historical queries + stub(Aprsme.PacketsMock, :get_recent_packets_optimized, fn _args -> [] end) + :ok + end + + test "does not update marker for GPS drift", %{conn: conn} do + # Start with initial location + {:ok, view, _html} = live(conn, "/") + + # First update the map state to set zoom level + assert render_hook(view, "update_map_state", %{ + "center" => %{"lat" => 33.16961, "lng" => -96.4921}, + "zoom" => 9 + }) + + # Then update bounds + bounds_params = %{ + "bounds" => %{ + "north" => 33.18, + "south" => 33.16, + "east" => -96.48, + "west" => -96.50 + } + } + + assert render_hook(view, "bounds_changed", bounds_params) + :timer.sleep(100) + + # Simulate initial packet + initial_packet = %{ + "id" => "TEST-1", + "sender" => "TEST-1", + "base_callsign" => "TEST", + "lat" => 33.16961, + "lon" => -96.4921, + "has_position" => true, + "received_at" => DateTime.utc_now() + } + + send(view.pid, {:postgres_packet, initial_packet}) + + # Wait for the initial packet to be processed + :timer.sleep(100) + + # Simulate GPS drift (5 meters movement) + drift_packet = %{ + "id" => "TEST-1", + "sender" => "TEST-1", + "base_callsign" => "TEST", + # About 5 meters north + "lat" => 33.169655, + "lon" => -96.4921, + "has_position" => true, + "received_at" => DateTime.utc_now() + } + + # Send the drift packet + send(view.pid, {:postgres_packet, drift_packet}) + + # The view should not push a new_packet event for GPS drift + refute_push_event(view, "new_packet", %{id: "TEST-1"}, 200) + end + + test "updates marker for significant movement", %{conn: conn} do + # Start with initial location + {:ok, view, _html} = live(conn, "/") + + # First update the map state to set zoom level + assert render_hook(view, "update_map_state", %{ + "center" => %{"lat" => 33.16961, "lng" => -96.4921}, + "zoom" => 9 + }) + + # Then update bounds + bounds_params = %{ + "bounds" => %{ + "north" => 33.18, + "south" => 33.16, + "east" => -96.48, + "west" => -96.50 + } + } + + assert render_hook(view, "bounds_changed", bounds_params) + :timer.sleep(100) + + # Simulate initial packet + initial_packet = %{ + "id" => "TEST-2", + "sender" => "TEST-2", + "base_callsign" => "TEST", + "lat" => 33.16961, + "lon" => -96.4921, + "has_position" => true, + "received_at" => DateTime.utc_now() + } + + send(view.pid, {:postgres_packet, initial_packet}) + + # Wait for the initial packet to be processed + :timer.sleep(100) + + # Simulate significant movement (20+ meters) + moved_packet = %{ + "id" => "TEST-2", + "sender" => "TEST-2", + "base_callsign" => "TEST", + # About 20 meters north + "lat" => 33.16980, + "lon" => -96.4921, + "has_position" => true, + "received_at" => DateTime.utc_now() + } + + # Send the moved packet + send(view.pid, {:postgres_packet, moved_packet}) + + # The view should push a new_packet event for significant movement + assert_push_event(view, "new_packet", %{"lat" => 33.1698}, 500) + end + end + + describe "distance calculations" do + test "calculates correct distances for GPS drift scenarios" do + # Example from the URL provided + lat1 = 33.16961 + lon1 = -96.4921 + + # Tiny drift - should be filtered + lat2 = 33.169615 + lon2 = -96.492095 + distance = GeoUtils.haversine_distance(lat1, lon1, lat2, lon2) + # Less than 10 meters + assert distance < 10 + + # Slightly larger drift + lat3 = 33.16965 + lon3 = -96.4921 + distance2 = GeoUtils.haversine_distance(lat1, lon1, lat3, lon3) + # Still GPS drift range + assert distance2 < 10 + + # Actual movement + lat4 = 33.17000 + lon4 = -96.4921 + distance3 = GeoUtils.haversine_distance(lat1, lon1, lat4, lon4) + # Significant movement + assert distance3 > 20 + end + end +end