Skip to content

Commit 9365135

Browse files
authored
Use length spheroid to full compatibility with Pseudo Mercator projection (3857) (#5038)
* refactor: use length spheroid
1 parent f5d1a18 commit 9365135

File tree

27 files changed

+294
-231
lines changed

27 files changed

+294
-231
lines changed

docs/changelog.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ CHANGELOG
55
2.120.0+dev (XXXX-XX-XX)
66
----------------------------
77

8+
**Improvements**
9+
10+
* Length are now computed with Earth spheroïd. We can now support using SRID 3857 (Pseudo mercator) to cover - almost - all Earth surface.
11+
812

913
2.120.0 (2025-10-22)
1014
----------------------------

geotrek/altimetry/templates/altimetry/elevationinfo_fragment.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
{# ``length`` attribute comes from AltimetryMixin and triggers #}
77
<span title="{% trans "3D" %}">&#8605; {{ object.length|floatformat }} m</span>
88
{# ``geom.length`` is 2D and is computed by GEOS #}
9-
<span title="{% trans "2D" %}">(&#8594; {{ object.geom.length|floatformat }} m)</span></td>
9+
<span title="{% trans "2D" %}">(&#8594; {{ object.length_2d|floatformat }} m)</span></td>
1010
</tr>
1111
{% endif %}
1212
{% if object.slope %}

geotrek/altimetry/tests/test_elevation.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.test import TestCase
77

88
from geotrek.altimetry.helpers import AltimetryHelper
9+
from geotrek.common.tests.utils import LineStringInBounds
910
from geotrek.core.models import Path, Topology
1011
from geotrek.core.tests.factories import TopologyFactory
1112

@@ -338,7 +339,7 @@ def setUpTestData(cls):
338339
# Create a simple fake DEM
339340
with connection.cursor() as cur:
340341
cur.execute(
341-
"INSERT INTO altimetry_dem (rast) VALUES (ST_MakeEmptyRaster(100, 125, 0, 125, 25, -25, 0, 0, %s))",
342+
"INSERT INTO altimetry_dem (rast) VALUES (ST_MakeEmptyRaster(100, 125, 489353.59, 6587677.2, 25, -25, 0, 0, %s))",
342343
[settings.SRID],
343344
)
344345
cur.execute("UPDATE altimetry_dem SET rast = ST_AddBand(rast, '16BSI')")
@@ -356,17 +357,17 @@ def setUpTestData(cls):
356357
[x + 1, y + 1, demvalues[y][x]],
357358
)
358359
cls.path = Path.objects.create(
359-
geom=LineString((1, 101), (81, 101), (81, 99))
360+
geom=LineStringInBounds((1, 101), (81, 101), (81, 99))
360361
)
361362

362363
def test_2dlength_is_preserved(self):
363-
self.assertEqual(self.path.geom_3d.length, self.path.geom.length)
364+
self.assertAlmostEqual(self.path.geom.length, self.path.geom_3d.length)
364365

365366
def test_3dlength(self):
366367
# before smoothing: (1 101 0, 21 101 0, 41 101 0, 61 101 3, 81 101 5, 81 99 15)
367368
# after smoothing: (1 101 0, 21 101 0, 41 101 0, 61 101 1, 81 101 3, 81 99 9)
368369
# length: 20 + 20 + (20 ** 2 + 1) ** .5 + (20 ** 2 + 2 ** 2) ** .5 + (2 ** 2 + 6 ** 2) ** .5
369-
self.assertEqual(round(self.path.length, 9), 83.127128724)
370+
self.assertEqual(round(self.path.length, 9), 83.203851919)
370371

371372

372373
@skipIf(not settings.TREKKING_TOPOLOGY_ENABLED, "Test with dynamic segmentation only")

geotrek/api/v2/functions.py

Lines changed: 0 additions & 11 deletions
This file was deleted.

geotrek/api/v2/serializers.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from rest_framework_gis.serializers import GeoFeatureModelSerializer
2323

2424
from geotrek.api.v2.filters import get_published_filter_expression
25-
from geotrek.api.v2.functions import Length3D
2625
from geotrek.api.v2.mixins import (
2726
PDFSerializerMixin,
2827
PublishedRelatedObjectsSerializerMixin,
@@ -788,7 +787,7 @@ class PathSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
788787
provider = serializers.SlugRelatedField(read_only=True, slug_field="name")
789788

790789
def get_length_3d(self, obj):
791-
return round(obj.length_3d_m, 1)
790+
return round(obj.length, 1)
792791

793792
class Meta:
794793
model = core_models.Path
@@ -936,7 +935,7 @@ def get_description_teaser(self, obj):
936935
)
937936

938937
def get_length_3d(self, obj):
939-
return round(obj.length_3d_m, 1)
938+
return round(obj.length, 1)
940939

941940
def get_gpx_url(self, obj):
942941
return build_url(
@@ -1132,7 +1131,6 @@ def get_steps(self, obj):
11321131
)
11331132
.annotate(
11341133
geom3d_transformed=Transform(F("geom_3d"), settings.API_SRID),
1135-
length_3d_m=Length3D("geom_3d"),
11361134
)
11371135
)
11381136
FinalClass = override_serializer(

geotrek/api/v2/views/core.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from geotrek.api.v2 import filters as api_filters
77
from geotrek.api.v2 import serializers as api_serializers
88
from geotrek.api.v2 import viewsets as api_viewsets
9-
from geotrek.api.v2.functions import Length3D
109
from geotrek.core import models as core_models
1110

1211

@@ -28,7 +27,6 @@ def get_queryset(self):
2827
.prefetch_related("usages", "networks")
2928
.annotate(
3029
geom3d_transformed=Transform(F("geom_3d"), settings.API_SRID),
31-
length_3d_m=Length3D("geom_3d"),
3230
)
3331
.order_by("pk")
3432
) # Required for reliable pagination

geotrek/api/v2/views/trekking.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from geotrek.api.v2 import serializers as api_serializers
1313
from geotrek.api.v2 import viewsets as api_viewsets
1414
from geotrek.api.v2.decorators import cache_response_detail
15-
from geotrek.api.v2.functions import Length3D
1615
from geotrek.api.v2.renderers import SVGProfileRenderer
1716
from geotrek.common.models import AccessibilityAttachment, Attachment, HDViewPoint
1817
from geotrek.trekking import models as trekking_models
@@ -71,7 +70,6 @@ def get_queryset(self):
7170
)
7271
.annotate(
7372
geom3d_transformed=Transform(F("geom_3d"), settings.API_SRID),
74-
length_3d_m=Length3D("geom_3d"),
7573
)
7674
.order_by("name")
7775
) # Required for reliable pagination

geotrek/common/functions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ class Length(GeoFunc):
99
output_field = FloatField()
1010

1111

12+
class LengthSpheroid(GeoFunc):
13+
output_field = FloatField()
14+
15+
1216
class SimplifyPreserveTopology(GeomOutputGeoFunc):
1317
"""ST_SimplifyPreserveTopology postgis function"""
1418

geotrek/common/tests/utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import fiona
2+
from django.conf import settings
3+
from django.contrib.gis.geos import LineString, Point
24

35

46
def update_gis(input_file_path: str, output_file_path: str, new_properties: dict):
@@ -22,3 +24,37 @@ def update_gis(input_file_path: str, output_file_path: str, new_properties: dict
2224
properties={**feat.properties, **new_properties},
2325
)
2426
)
27+
28+
29+
def _get_y(y):
30+
return 6587552.2 + y
31+
32+
33+
def _get_x(x):
34+
return 489353.59 + x
35+
36+
37+
class LineStringInBounds(LineString):
38+
"""
39+
A LineString that is guaranteed to be within the bounds of default projection.
40+
Useful for tests that require geometries within certain bounds,
41+
because ST_LENGTHSPHEROID can compute very bad data if coords is out of bounds.
42+
"""
43+
44+
def __init__(self, *args, **kwargs):
45+
# Define a simple LineString within typical world bounds
46+
kwargs.setdefault("srid", settings.SRID) # Default SRID for French Lambert-93
47+
coords = [[_get_x(point[0]), _get_y(point[1])] for point in args]
48+
super().__init__(*coords, **kwargs)
49+
50+
51+
class PointInBounds(Point):
52+
"""
53+
A Point that is guaranteed to be within the bounds of default projection.
54+
Useful for tests that require geometries within certain bounds,
55+
because ST_LENGTHSPHEROID can compute very bad data if coords is out of bounds.
56+
"""
57+
58+
def __init__(self, x=0, y=0, **kwargs):
59+
kwargs.setdefault("srid", settings.SRID)
60+
super().__init__((_get_x(x), _get_y(y)), **kwargs)

geotrek/core/managers.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from django.contrib.gis.db import models
2+
from django.contrib.gis.db.models.functions import Transform
3+
from django.db.models import Value
24

3-
from geotrek.common.functions import Length
5+
from geotrek.common.functions import LengthSpheroid
46
from geotrek.common.mixins.managers import NoDeleteManager
57

68

@@ -10,12 +12,7 @@ class PathManager(models.Manager):
1012

1113
def get_queryset(self):
1214
"""Hide all ``Path`` records that are not marked as visible."""
13-
return (
14-
super()
15-
.get_queryset()
16-
.filter(visible=True)
17-
.annotate(length_2d=Length("geom"))
18-
)
15+
return super().get_queryset().filter(visible=True)
1916

2017

2118
class PathInvisibleManager(models.Manager):
@@ -30,7 +27,14 @@ class TopologyManager(NoDeleteManager):
3027
use_for_related_fields = True
3128

3229
def get_queryset(self):
33-
return super().get_queryset().annotate(length_2d=Length("geom"))
30+
qs = super().get_queryset()
31+
qs = qs.annotate(
32+
length_2d=LengthSpheroid(
33+
Transform("geom", 4326),
34+
Value('SPHEROID["GRS_1980",6378137,298.257222101]'),
35+
),
36+
)
37+
return qs
3438

3539

3640
class PathAggregationManager(models.Manager):

0 commit comments

Comments
 (0)