Repository: geopandas/geopandas
Pull request #3561 (2025-05-10 12:18:40)

ENH: add support for M coordinates (.m, .has_m, m export in get_coordinates)
xref #2010


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8801ea28..f2ee03b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ New features and improvements:
 - Added ``method`` and ``keep_collapsed`` argument to ``make_valid`` (#3548).
 - Added `simplify_coverage` method for topological simplification of polygonal coverages
   to GeoSeries/GeoDataframe (#3541).
+- Added initial support of M coordinates (`m` and `has_m` properties, `include_m` in `get_coordinates`) (#3561).
 - Added `geom_equals_identical` method exposing `equals_identical` from shapely to GeoSeries/GeoDataFrame (#3560).
 
 Bug fixes:
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index ac293ef8..ea4f94ef 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -34,6 +34,7 @@ General methods and attributes
    GeoSeries.x
    GeoSeries.y
    GeoSeries.z
+   GeoSeries.m
    GeoSeries.get_coordinates
    GeoSeries.count_coordinates
    GeoSeries.count_geometries
@@ -56,6 +57,7 @@ Unary predicates
    GeoSeries.is_valid_reason
    GeoSeries.is_valid_coverage
    GeoSeries.invalid_coverage_edges
+   GeoSeries.has_m
    GeoSeries.has_z
    GeoSeries.is_ccw
 
diff --git a/geopandas/array.py b/geopandas/array.py
index f873a578..2cf88657 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -551,6 +551,13 @@ class GeometryArray(ExtensionArray):
     def has_z(self):
         return shapely.has_z(self._data)
 
+    @property
+    def has_m(self):
+        if not SHAPELY_GE_21:
+            raise ImportError("'has_m' requires shapely>=2.1.")
+
+        return shapely.has_m(self._data)
+
     @property
     def geom_type(self):
         res = shapely.get_type_id(self._data)
@@ -1238,6 +1245,25 @@ class GeometryArray(ExtensionArray):
             message = "z attribute access only provided for Point geometries"
             raise ValueError(message)
 
+    @property
+    def m(self):
+        """Return the m coordinate of point geometries in a GeoSeries"""
+        if not SHAPELY_GE_21:
+            raise ImportError("'m' requires shapely>=2.1.")
+
+        if (self.geom_type[~self.isna()] == "Point").all():
+            empty = self.is_empty
+            if empty.any():
+                nonempty = ~empty
+                coords = np.full_like(nonempty, dtype=float, fill_value=np.nan)
+                coords[nonempty] = shapely.get_m(self._data[nonempty])
+                return coords
+            else:
+                return shapely.get_m(self._data)
+        else:
+            message = "m attribute access only provided for Point geometries"
+            raise ValueError(message)
+
     @property
     def bounds(self):
         return shapely.bounds(self._data)
diff --git a/geopandas/base.py b/geopandas/base.py
index c54b3a07..8a9c9225 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -843,6 +843,36 @@ GeometryCollection
         """
         return _delegate_property("has_z", self)
 
+    @property
+    def has_m(self):
+        """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for
+        features that have a m-component.
+
+
+        Examples
+        --------
+        >>> from shapely.geometry import Point
+        >>> s = geopandas.GeoSeries.from_wkt(
+        ...     [
+        ...         "POINT M (2 3 5)",
+        ...         "POINT Z (1 2 3)",
+        ...         "POINT (0 0)",
+        ...     ]
+        ... )
+        >>> s
+        0    POINT M (2 3 5)
+        1    POINT Z (1 2 3)
+        2        POINT (0 0)
+        dtype: geometry
+
+        >>> s.has_m
+        0     True
+        1    False
+        2    False
+        dtype: bool
+        """
+        return _delegate_property("has_m", self)
+
     def get_precision(self):
         """Returns a ``Series`` of the precision of each geometry.
 
@@ -6203,7 +6233,9 @@ GeometryCollection
         """
         return _CoordinateIndexer(self)
 
-    def get_coordinates(self, include_z=False, ignore_index=False, index_parts=False):
+    def get_coordinates(
+        self, include_z=False, ignore_index=False, index_parts=False, include_m=False
+    ):
         """Gets coordinates from a :class:`GeoSeries` as a :class:`~pandas.DataFrame` of
         floats.
 
@@ -6223,6 +6255,8 @@ GeometryCollection
            If True, the resulting index will be a :class:`~pandas.MultiIndex` (original
            index with an additional level indicating the ordering of the coordinate
            pairs: a new zero-based index for each geometry in the original GeoSeries).
+        include_m : bool, default False
+            Include M coordinates. Requires shapely >= 2.1.
 
         Returns
         -------
@@ -6274,13 +6308,27 @@ GeometryCollection
           2  3.0  1.0
           3  3.0 -1.0
         """
-        coords, outer_idx = shapely.get_coordinates(
-            self.geometry.values._data, include_z=include_z, return_index=True
-        )
+        if include_m:
+            if not compat.SHAPELY_GE_21:
+                raise ImportError("Shapely >= 2.1 is required for include_m=True.")
+
+            # can be merged with the one below once min requirement is shapely 2.1
+            coords, outer_idx = shapely.get_coordinates(
+                self.geometry.values._data,
+                include_z=include_z,
+                include_m=include_m,
+                return_index=True,
+            )
+        else:
+            coords, outer_idx = shapely.get_coordinates(
+                self.geometry.values._data, include_z=include_z, return_index=True
+            )
 
         column_names = ["x", "y"]
         if include_z:
             column_names.append("z")
+        if include_m:
+            column_names.append("m")
 
         index = _get_index_for_parts(
             self.index,
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 4a90ecfb..aaf7d331 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -301,6 +301,7 @@ class GeoSeries(GeoPandasBase, Series):
 
         GeoSeries.x
         GeoSeries.z
+        GeoSeries.m
 
         """
         return _delegate_property("y", self)
@@ -329,10 +330,49 @@ class GeoSeries(GeoPandasBase, Series):
 
         GeoSeries.x
         GeoSeries.y
+        GeoSeries.m
 
         """
         return _delegate_property("z", self)
 
+    @property
+    def m(self) -> Series:
+        """Return the m coordinate of point geometries in a GeoSeries
+
+        Returns
+        -------
+        pandas.Series
+
+        Examples
+        --------
+
+        >>> from shapely.geometry import Point
+        >>> s = geopandas.GeoSeries.from_wkt(
+        ...     [
+        ...         "POINT M (2 3 5)",
+        ...         "POINT M (1 2 3)",
+        ...     ]
+        ... )
+        >>> s
+        0    POINT M (2 3 5)
+        1    POINT M (1 2 3)
+        dtype: geometry
+
+        >>> s.m
+        0    5.0
+        1    3.0
+        dtype: float64
+
+        See Also
+        --------
+
+        GeoSeries.x
+        GeoSeries.y
+        GeoSeries.z
+
+        """
+        return _delegate_property("m", self)
+
     @classmethod
     def from_file(cls, filename: os.PathLike | typing.IO, **kwargs) -> GeoSeries:
         """Alternate constructor to create a ``GeoSeries`` from a file.


Test patch:
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 433e91f6..956c5d09 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -959,6 +959,18 @@ class TestGeomMethods:
         expected = Series([False, True], self.g_3d.index)
         self._test_unary_real("has_z", expected, self.g_3d)
 
+    @pytest.mark.skipif(not SHAPELY_GE_21, reason="requires shapely 2.1")
+    @pytest.mark.skipif(shapely.geos_version < (3, 12, 0), reason="requires GEOS>=3.12")
+    def test_has_m(self):
+        s = GeoSeries.from_wkt(
+            [
+                "POINT M (2 3 5)",
+                "POINT Z (1 2 3)",
+            ],
+        )
+        expected = Series([True, False])
+        self._test_unary_real("has_m", expected, s)
+
     def test_xyz_points(self):
         expected_x = [-73.9847, -74.0446]
         expected_y = [40.7484, 40.6893]
@@ -972,6 +984,19 @@ class TestGeomMethods:
         expected_z = [30.3244, 31.2344, np.nan]
         assert_array_dtype_equal(expected_z, self.landmarks_mixed.geometry.z)
 
+    @pytest.mark.skipif(not SHAPELY_GE_21, reason="requires shapely 2.1")
+    def test_m_points(self):
+        s = GeoSeries.from_wkt(
+            [
+                "POINT M (2 3 5)",
+                "POINT M (1 2 3)",
+                "POINT (0 0)",
+            ]
+        )
+
+        expected = [5, 3, np.nan]
+        assert_array_dtype_equal(expected, s.m)
+
     def test_xyz_points_empty(self):
         expected_x = [-73.9847, -74.0446, -73.9847, np.nan]
         expected_y = [40.7484, 40.6893, 40.7484, np.nan]
@@ -1983,6 +2008,36 @@ class TestGeomMethods:
         )
         assert_frame_equal(self.g11.get_coordinates(include_z=True), expected)
 
+    @pytest.mark.skipif(not SHAPELY_GE_21, reason="requires shapely 2.1")
+    def test_get_coordinates_m(self):
+        s = GeoSeries.from_wkt(
+            [
+                "POINT M (2 3 5)",
+                "POINT ZM (1 2 3 4)",
+            ],
+        )
+
+        # only m
+        expected = DataFrame(
+            data=np.array([[2.0, 3.0, 5.0], [1.0, 2.0, 4.0]]),
+            columns=["x", "y", "m"],
+        )
+        assert_frame_equal(s.get_coordinates(include_m=True), expected)
+
+        # only z
+        expected = DataFrame(
+            data=np.array([[2.0, 3.0, np.nan], [1.0, 2.0, 3.0]]),
+            columns=["x", "y", "z"],
+        )
+        assert_frame_equal(s.get_coordinates(include_z=True), expected)
+
+        # both
+        expected = DataFrame(
+            data=np.array([[2.0, 3.0, np.nan, 5.0], [1.0, 2.0, 3.0, 4.0]]),
+            columns=["x", "y", "z", "m"],
+        )
+        assert_frame_equal(s.get_coordinates(include_z=True, include_m=True), expected)
+
     def test_get_coordinates_ignore(self):
         expected = DataFrame(
             data=self.expected_2d,