diff --git a/backend/active_statistics/statistics/images/polyline_grid.py b/backend/active_statistics/statistics/images/polyline_grid.py index 478a2c8..bf3c4b0 100644 --- a/backend/active_statistics/statistics/images/polyline_grid.py +++ b/backend/active_statistics/statistics/images/polyline_grid.py @@ -25,6 +25,7 @@ def create_images(activity_iterator: Iterable[Activity], path: str) -> None: ) for activity in activity_iterator if activity.start_date_local is not None + and activity.map.summary_polyline is not None ] compact_activities.sort(key=lambda x: x.start_date_local) @@ -118,13 +119,18 @@ def create_image( for encoded_polyline in encoded_polylines: polyline: Polyline = pl.decode(encoded_polyline) - # If the polyline has nothing in it, just return. - if not polyline: + if len(polyline) <= 1: + # I think we have ran into instances where people have ONLY activities with a single + # lat-long pair here, which means that the max cheby distance is 0, and then we do a + # divide by zero. So lets just continue if there is a single point, because lets be + # real, a single point is silly to plot. continue polyline = apply_equirectangular_approximation(polyline) - polyline = custom_scale_polyline(polyline) - polylines.append(polyline) + optional_polyline = custom_scale_polyline(polyline) + + if optional_polyline is not None: + polylines.append(optional_polyline) if not polylines: return None @@ -188,7 +194,7 @@ def equirectangular_approximation(lat, lon, reference_lat): return polyline -def custom_scale_polyline(polyline: Polyline) -> Polyline: +def custom_scale_polyline(polyline: Polyline) -> None | Polyline: """ The goal here is to scale some arbitrary polyline into the center of a unit square. This will make it easier to place them all in the future. @@ -206,6 +212,11 @@ def custom_scale_polyline(polyline: Polyline) -> Polyline: width = max_x - min_x height = max_y - min_y + # Handle the case where all points are in the same spot. To avoid a div/0 + # error here, just return None because we can't scale this. + if max([height, width]) == 0: + return None + # Calculate the scaling factors for x and y to fit the polyline into a unit square if width > height: scaling_factor = 1 / width diff --git a/backend/active_statistics/statistics/images/polyline_overlay.py b/backend/active_statistics/statistics/images/polyline_overlay.py index 47c12ae..609b899 100644 --- a/backend/active_statistics/statistics/images/polyline_overlay.py +++ b/backend/active_statistics/statistics/images/polyline_overlay.py @@ -98,7 +98,11 @@ def create_image( # If the polyline has nothing in it, just return. polyline = decode_polyline(encoded_polyline) - if len(polyline) == 0: + if len(polyline) <= 1: + # I think we have ran into instances where people have ONLY activities with a single + # lat-long pair here, which means that the max cheby distance is 0, and then we do a + # divide by zero. So lets just continue if there is a single point, because lets be + # real, a single point is silly to plot. continue polyline = apply_equirectangular_approximation(polyline) @@ -117,6 +121,13 @@ def create_image( max_chebychev_distance(polyline) for polyline in polylines ) + if max_all_max_chebychev_distances == 0: + # This can occur if a polyline is any number of points all at the exact + # same location, and that polyline if the only polyline that that + # activity (or possible all polylines for that activity have identical + # points.) We just want to return if that's the case. + return None + scaled_polylines = [ aesthetically_scale_polyline( polyline, max_all_max_chebychev_distances, min([height, width]) diff --git a/backend/tests/test_visualisations/test_historigram_of_activity_time.py b/backend/tests/test_visualisations/test_histogram_of_activity_time.py similarity index 100% rename from backend/tests/test_visualisations/test_historigram_of_activity_time.py rename to backend/tests/test_visualisations/test_histogram_of_activity_time.py diff --git a/backend/tests/test_visualisations/test_polyline_grid.py b/backend/tests/test_visualisations/test_polyline_grid.py new file mode 100644 index 0000000..df0145b --- /dev/null +++ b/backend/tests/test_visualisations/test_polyline_grid.py @@ -0,0 +1,82 @@ +import shutil + +import pytest +from active_statistics.statistics.images.polyline_grid import create_images +from stravalib.model import Activity, PolylineMap +from tests.factories.activity_factories import ActivityFactory + + +@pytest.fixture(scope="function") +def temp_directory(request, tmp_path_factory): + # Create a temporary directory + temp_dir = tmp_path_factory.mktemp("temp_directory") + + # Define a finalizer to clean up the directory after the test + def cleanup_temp_directory(): + # Delete the directory and its contents + shutil.rmtree(temp_dir) + + # Add the finalizer to the request to ensure cleanup + request.addfinalizer(cleanup_temp_directory) + + return temp_dir + + +class TestPolylineGrid: + def test_no_activities(self, temp_directory) -> None: + activities: list[Activity] = [] + create_images((_ for _ in activities), temp_directory) + + def test_activities_with_no_polylines(self, temp_directory) -> None: + activities = [ + ActivityFactory( + type="Run", + map=PolylineMap(id="123", polyline=None, summary_polyline=None), + ), + ] + create_images(activities, temp_directory) + + def test_activities_with_blank_polylines(self, temp_directory) -> None: + activities: list[Activity] = [ + ActivityFactory( + type="Run", + map=PolylineMap(id="123", polyline=None, summary_polyline=""), + ), + ] + create_images((_ for _ in activities), temp_directory) + + def test_activities_with_single_point_polylines(self, temp_directory) -> None: + activities: list[Activity] = [ + ActivityFactory( + type="Run", + map=PolylineMap(id="123", polyline=None, summary_polyline="??"), + ), + ] + create_images((_ for _ in activities), temp_directory) + + def test_activities_with_multi_point_identical_polylines( + self, temp_directory + ) -> None: + # Yes, this polyline is from real data. + activities: list[Activity] = [ + ActivityFactory( + type="Run", + map=PolylineMap( + id="123", polyline=None, summary_polyline="fszfDwsbe\\??" + ), + ), + ] + create_images((_ for _ in activities), temp_directory) + + def test_activities_with_normal_polylines(self, temp_directory) -> None: + activities: list[Activity] = [ + ActivityFactory( + type="Run", + map=PolylineMap( + id="123", + polyline=None, + summary_polyline="tdufD}|}d\C?e@Nm@\eAn@u@j@_Aj@i@b@q@\EN]HcAZyBdAWVcAt@g@Vs@n@{@j@c@r@mAnAW`@Mb@MVJR@Tw@jAGp@LtALl@RVTf@Vr@b@bBP`@FX?JCp@Jt@Vh@P^PTf@`@l@pABJ`@fA\t@~@hAd@`ARl@z@dBV^rA~AV\hBjDh@lA~BlC`@j@rCxERXdBbBbArAVf@jAjAXRb@LXPzA|ARJVD^TdAz@v@x@Rb@^l@x@`Az@t@dAl@vBvAjBtA`@^hBvAvA`Ap@j@dB`AlDlC~@n@hAh@hA\^Rd@\jAp@RFf@Df@Cf@BbB~@fAp@f@`@`@b@^Tf@Fr@Ah@OX[|@sBvAsDLc@d@eAHe@Hq@N_@FIFUAc@D{@DiEF}B?gBBsAD~CAfCErAEdEG~AJj@@h@GjAc@tD_@zAQf@Sd@{@rAIFa@RWDe@Ga@AWQM[[YWIkAk@s@g@{@g@k@[[Mi@[]IkAFSCWGiAi@{@c@eAs@cAa@mCgB}B}AeCoB{@g@mAcA_Am@uAkAeBgAiBoAc@_@a@c@e@_@}@_Aw@{A[a@u@o@a@WaA_@]Yy@}@w@_@]I_@Wk@i@eDeEWg@aBoB{@mAe@{@aBwBWg@y@gAqA{BqAgB{AeCi@u@sAmC[w@gAkBi@_BUe@w@mA{@iBKgA_@yAMq@Qi@q@_BKsAGc@Ak@Fe@JW`@m@Ie@Ng@PQZe@`@a@p@_AVi@bByAVYhCwAnAaAZO\Kb@Wd@O", + ), + ), + ] + create_images((_ for _ in activities), temp_directory) diff --git a/backend/tests/test_visualisations/test_polyline_overlay.py b/backend/tests/test_visualisations/test_polyline_overlay.py new file mode 100644 index 0000000..b46b77d --- /dev/null +++ b/backend/tests/test_visualisations/test_polyline_overlay.py @@ -0,0 +1,82 @@ +import shutil + +import pytest +from active_statistics.statistics.images.polyline_overlay import create_images +from stravalib.model import Activity, PolylineMap +from tests.factories.activity_factories import ActivityFactory + + +@pytest.fixture(scope="function") +def temp_directory(request, tmp_path_factory): + # Create a temporary directory + temp_dir = tmp_path_factory.mktemp("temp_directory") + + # Define a finalizer to clean up the directory after the test + def cleanup_temp_directory(): + # Delete the directory and its contents + shutil.rmtree(temp_dir) + + # Add the finalizer to the request to ensure cleanup + request.addfinalizer(cleanup_temp_directory) + + return temp_dir + + +class TestPolylineOverlay: + def test_no_activities(self, temp_directory) -> None: + activities: list[Activity] = [] + create_images((_ for _ in activities), temp_directory) + + def test_activities_with_no_polylines(self, temp_directory) -> None: + activities = [ + ActivityFactory( + type="Run", + map=PolylineMap(id="123", polyline=None, summary_polyline=None), + ), + ] + create_images((_ for _ in activities), temp_directory) + + def test_activities_with_blank_polylines(self, temp_directory) -> None: + activities: list[Activity] = [ + ActivityFactory( + type="Run", + map=PolylineMap(id="123", polyline=None, summary_polyline=""), + ), + ] + create_images((_ for _ in activities), temp_directory) + + def test_activities_with_single_point_polylines(self, temp_directory) -> None: + activities: list[Activity] = [ + ActivityFactory( + type="Run", + map=PolylineMap(id="123", polyline=None, summary_polyline="??"), + ), + ] + create_images((_ for _ in activities), temp_directory) + + def test_activities_with_multi_point_identical_polylines( + self, temp_directory + ) -> None: + # Yes, this polyline is from real data. + activities: list[Activity] = [ + ActivityFactory( + type="Run", + map=PolylineMap( + id="123", polyline=None, summary_polyline="fszfDwsbe\\??" + ), + ), + ] + create_images((_ for _ in activities), temp_directory) + + def test_activities_with_normal_polylines(self, temp_directory) -> None: + activities: list[Activity] = [ + ActivityFactory( + type="Run", + map=PolylineMap( + id="123", + polyline=None, + summary_polyline="tdufD}|}d\C?e@Nm@\eAn@u@j@_Aj@i@b@q@\EN]HcAZyBdAWVcAt@g@Vs@n@{@j@c@r@mAnAW`@Mb@MVJR@Tw@jAGp@LtALl@RVTf@Vr@b@bBP`@FX?JCp@Jt@Vh@P^PTf@`@l@pABJ`@fA\t@~@hAd@`ARl@z@dBV^rA~AV\hBjDh@lA~BlC`@j@rCxERXdBbBbArAVf@jAjAXRb@LXPzA|ARJVD^TdAz@v@x@Rb@^l@x@`Az@t@dAl@vBvAjBtA`@^hBvAvA`Ap@j@dB`AlDlC~@n@hAh@hA\^Rd@\jAp@RFf@Df@Cf@BbB~@fAp@f@`@`@b@^Tf@Fr@Ah@OX[|@sBvAsDLc@d@eAHe@Hq@N_@FIFUAc@D{@DiEF}B?gBBsAD~CAfCErAEdEG~AJj@@h@GjAc@tD_@zAQf@Sd@{@rAIFa@RWDe@Ga@AWQM[[YWIkAk@s@g@{@g@k@[[Mi@[]IkAFSCWGiAi@{@c@eAs@cAa@mCgB}B}AeCoB{@g@mAcA_Am@uAkAeBgAiBoAc@_@a@c@e@_@}@_Aw@{A[a@u@o@a@WaA_@]Yy@}@w@_@]I_@Wk@i@eDeEWg@aBoB{@mAe@{@aBwBWg@y@gAqA{BqAgB{AeCi@u@sAmC[w@gAkBi@_BUe@w@mA{@iBKgA_@yAMq@Qi@q@_BKsAGc@Ak@Fe@JW`@m@Ie@Ng@PQZe@`@a@p@_AVi@bByAVYhCwAnAaAZO\Kb@Wd@O", + ), + ), + ] + create_images((_ for _ in activities), temp_directory) diff --git a/captions.json b/captions.json new file mode 100644 index 0000000..03a483f --- /dev/null +++ b/captions.json @@ -0,0 +1 @@ +{"Run.png": "All Run activities overlaid as if they have the same starting point.", "Run_animation.gif": "An animation of overlaid Run activities."}