rastr 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of rastr might be problematic. Click here for more details.

rastr/_version.py CHANGED
@@ -1,7 +1,14 @@
1
1
  # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
3
 
4
- __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
5
12
 
6
13
  TYPE_CHECKING = False
7
14
  if TYPE_CHECKING:
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
9
16
  from typing import Union
10
17
 
11
18
  VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
12
20
  else:
13
21
  VERSION_TUPLE = object
22
+ COMMIT_ID = object
14
23
 
15
24
  version: str
16
25
  __version__: str
17
26
  __version_tuple__: VERSION_TUPLE
18
27
  version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
19
30
 
20
- __version__ = version = '0.1.0'
21
- __version_tuple__ = version_tuple = (0, 1, 0)
31
+ __version__ = version = '0.3.0'
32
+ __version_tuple__ = version_tuple = (0, 3, 0)
33
+
34
+ __commit_id__ = commit_id = None
rastr/arr/fill.py CHANGED
@@ -1,6 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
1
5
  import numpy as np
2
- from numpy.typing import NDArray
3
- from scipy.interpolate import NearestNDInterpolator
6
+
7
+ if TYPE_CHECKING:
8
+ from numpy.typing import NDArray
4
9
 
5
10
 
6
11
  def fillna_nearest_neighbours(arr: NDArray[np.float64]) -> NDArray[np.float64]:
@@ -11,6 +16,8 @@ def fillna_nearest_neighbours(arr: NDArray[np.float64]) -> NDArray[np.float64]:
11
16
  In the case of tiebreaks, the value from the neighbour with the lowest index is
12
17
  imputed.
13
18
  """
19
+ from scipy.interpolate import NearestNDInterpolator
20
+
14
21
  nonnan_mask = np.nonzero(~np.isnan(arr))
15
22
  nonnan_idxs = np.array(nonnan_mask).transpose()
16
23
 
rastr/create.py CHANGED
@@ -1,18 +1,32 @@
1
- from collections.abc import Iterable
2
- from functools import partial
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import warnings
5
+ from typing import TYPE_CHECKING, TypeVar
3
6
 
4
- import geopandas as gpd
5
7
  import numpy as np
6
- import pandas as pd
7
8
  import rasterio.features
9
+ import rasterio.transform
8
10
  from affine import Affine
9
- from shapely.geometry import Point, Polygon
10
- from tqdm.notebook import tqdm
11
+ from pyproj import CRS
12
+ from shapely.geometry import Point
11
13
 
12
14
  from rastr.gis.fishnet import create_point_grid, get_point_grid_shape
13
15
  from rastr.meta import RasterMeta
14
16
  from rastr.raster import RasterModel
15
17
 
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterable
20
+
21
+ import geopandas as gpd
22
+ from numpy.typing import ArrayLike
23
+ from shapely.geometry import Polygon
24
+
25
+
26
+ TQDM_INSTALLED = importlib.util.find_spec("tqdm") is not None
27
+
28
+ _T = TypeVar("_T")
29
+
16
30
 
17
31
  class MissingColumnsError(ValueError):
18
32
  """Raised when target columns are missing from the GeoDataFrame."""
@@ -61,17 +75,10 @@ def raster_distance_from_polygon(
61
75
  Raises:
62
76
  ValueError: If the provided CRS is geographic (lat/lon).
63
77
  """
64
- if extent_polygon is None and snap_raster is None:
65
- err_msg = "Either 'extent_polygon' or 'snap_raster' must be provided. "
66
- raise ValueError(err_msg)
67
- elif extent_polygon is not None and snap_raster is not None:
68
- err_msg = "Only one of 'extent_polygon' or 'snap_raster' can be provided. "
69
- raise ValueError(err_msg)
70
-
71
- if not show_pbar:
72
-
73
- def _pbar(x: Iterable) -> None:
74
- return x # No-op if no progress bar is needed
78
+ if show_pbar and not TQDM_INSTALLED:
79
+ msg = "The 'tqdm' package is not installed. Progress bars will not be shown."
80
+ warnings.warn(msg, UserWarning, stacklevel=2)
81
+ show_pbar = False
75
82
 
76
83
  # Check if the provided CRS is projected (cartesian)
77
84
  if raster_meta.crs.is_geographic:
@@ -80,36 +87,44 @@ def raster_distance_from_polygon(
80
87
  )
81
88
  raise ValueError(err_msg)
82
89
 
83
- # Calculate the coordinates
84
- if snap_raster is not None:
90
+ if extent_polygon is None and snap_raster is None:
91
+ err_msg = "Either 'extent_polygon' or 'snap_raster' must be provided. "
92
+ raise ValueError(err_msg)
93
+ elif extent_polygon is not None and snap_raster is not None:
94
+ err_msg = "Only one of 'extent_polygon' or 'snap_raster' can be provided. "
95
+ raise ValueError(err_msg)
96
+ elif extent_polygon is None and snap_raster is not None:
97
+ # Calculate the coordinates
85
98
  x, y = snap_raster.get_xy()
86
- else:
99
+
100
+ # Create a mask to identify points for which distance should be calculated
101
+ distance_extent = snap_raster.bbox.difference(polygon)
102
+ elif extent_polygon is not None and snap_raster is None:
87
103
  x, y = create_point_grid(
88
104
  bounds=extent_polygon.bounds, cell_size=raster_meta.cell_size
89
105
  )
90
-
91
- points = [Point(x, y) for x, y in zip(x.flatten(), y.flatten(), strict=True)]
92
-
93
- # Create a mask to identify points for which distance should be calculated
94
- if extent_polygon is not None:
95
106
  distance_extent = extent_polygon.difference(polygon)
96
107
  else:
97
- distance_extent = snap_raster.bbox.difference(polygon)
108
+ raise AssertionError
98
109
 
99
- if show_pbar:
100
- _pbar = partial(tqdm, desc="Finding points within extent")
101
- mask = [distance_extent.intersects(point) for point in _pbar(points)]
110
+ pts = [Point(x, y) for x, y in zip(x.flatten(), y.flatten(), strict=True)]
102
111
 
103
- if show_pbar:
104
- _pbar = partial(tqdm, desc="Calculating distances")
105
- distances = np.where(
106
- mask, np.array([polygon.distance(point) for point in _pbar(points)]), np.nan
107
- )
112
+ _pts = _pbar(pts, desc="Finding points within extent") if show_pbar else pts
113
+ mask = [distance_extent.intersects(pt) for pt in _pts]
114
+
115
+ _pts = _pbar(pts, desc="Calculating distances") if show_pbar else pts
116
+ distances = np.where(mask, np.array([polygon.distance(pt) for pt in _pts]), np.nan)
108
117
  distance_raster = distances.reshape(x.shape)
109
118
 
110
119
  return RasterModel(arr=distance_raster, raster_meta=raster_meta)
111
120
 
112
121
 
122
+ def _pbar(iterable: Iterable[_T], *, desc: str | None = None) -> Iterable[_T]:
123
+ from tqdm import tqdm
124
+
125
+ return tqdm(iterable, desc=desc)
126
+
127
+
113
128
  def full_raster(
114
129
  raster_meta: RasterMeta,
115
130
  *,
@@ -185,7 +200,8 @@ def rasterize_gdf(
185
200
  shapes,
186
201
  out_shape=shape,
187
202
  transform=transform,
188
- fill=np.nan, # Fill gaps with NaN
203
+ # Fill gaps with NaN
204
+ fill=np.nan, # type: ignore[reportArgumentType] docstring contradicts inferred annotation
189
205
  dtype=np.float32,
190
206
  )
191
207
 
@@ -222,6 +238,8 @@ def _validate_columns_numeric(gdf: gpd.GeoDataFrame, target_cols: list[str]) ->
222
238
  Raises:
223
239
  NonNumericColumnsError: If any columns contain non-numeric data.
224
240
  """
241
+ import pandas as pd
242
+
225
243
  non_numeric_cols = []
226
244
  for col in target_cols:
227
245
  if not pd.api.types.is_numeric_dtype(gdf[col]):
@@ -259,3 +277,105 @@ def _validate_no_overlapping_geometries(gdf: gpd.GeoDataFrame) -> None:
259
277
  "Overlapping geometries can lead to data loss during rasterization."
260
278
  )
261
279
  raise OverlappingGeometriesError(msg)
280
+
281
+
282
+ def raster_from_point_cloud(
283
+ x: ArrayLike,
284
+ y: ArrayLike,
285
+ z: ArrayLike,
286
+ *,
287
+ crs: CRS | str,
288
+ cell_size: float | None = None,
289
+ ) -> RasterModel:
290
+ """Create a raster from a point cloud via interpolation.
291
+
292
+ Interpolation is only possible within the convex hull of the points. Outside of
293
+ this, cells will be NaN-valued.
294
+
295
+ All (x,y) points must be unique.
296
+
297
+ Args:
298
+ x: X coordinates of points.
299
+ y: Y coordinates of points.
300
+ z: Values at each (x, y) point to assign the raster.
301
+ crs: Coordinate reference system for the (x, y) coordinates.
302
+ cell_size: Desired cell size for the raster. If None, a heuristic is used based
303
+ on the spacing between (x, y) points.
304
+
305
+ Returns:
306
+ Raster containing the interpolated values.
307
+
308
+ Raises:
309
+ ValueError: If any (x, y) points are duplicated, or if they are all collinear.
310
+ """
311
+ from scipy.interpolate import LinearNDInterpolator
312
+ from scipy.spatial import KDTree, QhullError
313
+
314
+ x = np.asarray(x).ravel()
315
+ y = np.asarray(y).ravel()
316
+ z = np.asarray(z).ravel()
317
+ crs = CRS.from_user_input(crs)
318
+
319
+ # Validate input arrays
320
+ if len(x) != len(y) or len(x) != len(z):
321
+ msg = "Length of x, y, and z must be equal."
322
+ raise ValueError(msg)
323
+ if len(x) < 3:
324
+ msg = "At least three (x, y, z) points are required to triangulate a surface."
325
+ raise ValueError(msg)
326
+ # Check for duplicate (x, y) points
327
+ xy_points = np.column_stack((x, y))
328
+ if len(xy_points) != len(np.unique(xy_points, axis=0)):
329
+ msg = "Duplicate (x, y) points found. Each (x, y) point must be unique."
330
+ raise ValueError(msg)
331
+
332
+ # Heuristic for cell size if not provided
333
+ if cell_size is None:
334
+ # Half the 5th percentile of nearest neighbor distances between the (x,y) points
335
+ tree = KDTree(np.column_stack((x, y)))
336
+ distances, _ = tree.query(np.column_stack((x, y)), k=2)
337
+ distances: np.ndarray
338
+ cell_size = float(np.percentile(distances[distances > 0], 5)) / 2
339
+
340
+ # Compute bounds from data
341
+ minx, miny, maxx, maxy = np.min(x), np.min(y), np.max(x), np.max(y)
342
+
343
+ # Compute grid shape
344
+ width = int(np.ceil((maxx - minx) / cell_size))
345
+ height = int(np.ceil((maxy - miny) / cell_size))
346
+ shape = (height, width)
347
+
348
+ # Compute transform: upper left corner is (minx, maxy)
349
+ transform = Affine.translation(minx, maxy) * Affine.scale(cell_size, -cell_size)
350
+
351
+ # Create grid coordinates for raster cells
352
+ rows, cols = np.indices(shape)
353
+ xs, ys = rasterio.transform.xy(
354
+ transform=transform, rows=rows, cols=cols, offset="center"
355
+ )
356
+ grid_x = np.array(xs).ravel()
357
+ grid_y = np.array(ys).ravel()
358
+
359
+ # Perform interpolation
360
+ try:
361
+ interpolator = LinearNDInterpolator(
362
+ points=xy_points, values=z, fill_value=np.nan
363
+ )
364
+ except QhullError as err:
365
+ msg = (
366
+ "Failed to interpolate. This may be due to insufficient or "
367
+ "degenerate input points. Ensure that the (x, y) points are not all "
368
+ "collinear (i.e. that the convex hull is non-degenerate)."
369
+ )
370
+ raise ValueError(msg) from err
371
+
372
+ grid_values = np.array(interpolator(np.column_stack((grid_x, grid_y))))
373
+
374
+ arr = grid_values.reshape(shape).astype(np.float32)
375
+
376
+ raster_meta = RasterMeta(
377
+ cell_size=cell_size,
378
+ crs=crs,
379
+ transform=transform,
380
+ )
381
+ return RasterModel(arr=arr, raster_meta=raster_meta)
rastr/gis/fishnet.py CHANGED
@@ -1,12 +1,18 @@
1
- import geopandas as gpd
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
2
5
  import numpy as np
3
- from geopandas.array import GeometryArray
4
6
  from shapely import BufferCapStyle, BufferJoinStyle
5
7
 
8
+ if TYPE_CHECKING:
9
+ from geopandas.array import GeometryArray
10
+ from numpy.typing import NDArray
11
+
6
12
 
7
13
  def create_point_grid(
8
14
  *, bounds: tuple[float, float, float, float], cell_size: float
9
- ) -> tuple[np.ndarray, np.ndarray]:
15
+ ) -> tuple[NDArray, NDArray]:
10
16
  """Create a regular grid of point coordinates for raster centers.
11
17
 
12
18
  This function replicates the original grid generation logic that uses
@@ -25,7 +31,8 @@ def create_point_grid(
25
31
  x_coords = np.arange(xmin + cell_size / 2, xmax + cell_size / 2, cell_size)
26
32
  y_coords = np.arange(ymax - cell_size / 2, ymin - cell_size / 2, -cell_size)
27
33
 
28
- return np.meshgrid(x_coords, y_coords)
34
+ x_points, y_points = np.meshgrid(x_coords, y_coords) # type: ignore[reportAssignmentType]
35
+ return x_points, y_points
29
36
 
30
37
 
31
38
  def get_point_grid_shape(
@@ -58,6 +65,8 @@ def create_fishnet(
58
65
  Returns:
59
66
  Shapely Polygons.
60
67
  """
68
+ import geopandas as gpd
69
+
61
70
  # Use the shared helper function to create the point grid
62
71
  xx, yy = create_point_grid(bounds=bounds, cell_size=res)
63
72
 
rastr/gis/smooth.py CHANGED
@@ -5,12 +5,15 @@ Fork + Port of <https://github.com/philipschall/shapelysmooth> (Public domain)
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- from typing import TypeAlias
8
+ from typing import TYPE_CHECKING, TypeAlias
9
9
 
10
10
  import numpy as np
11
11
  from shapely.geometry import LineString, Polygon
12
12
  from typing_extensions import assert_never
13
13
 
14
+ if TYPE_CHECKING:
15
+ from numpy.typing import NDArray
16
+
14
17
  T: TypeAlias = LineString | Polygon
15
18
 
16
19
 
@@ -46,7 +49,7 @@ def catmull_rom_smooth(geometry: T, alpha: float = 0.5, subdivs: int = 10) -> T:
46
49
 
47
50
 
48
51
  def _catmull_rom(
49
- coords: np.ndarray,
52
+ coords: NDArray,
50
53
  *,
51
54
  alpha: float = 0.5,
52
55
  subdivs: int = 8,
@@ -89,7 +92,7 @@ def _catmull_rom(
89
92
 
90
93
 
91
94
  def _recursive_eval(
92
- slice4: np.ndarray, tangents: list[float], ts: np.ndarray
95
+ slice4: NDArray, tangents: list[float], ts: NDArray
93
96
  ) -> list[tuple[float, float]]:
94
97
  """De Boor/De Casteljau-style recursive linear interpolation over 4 control points.
95
98
 
@@ -128,7 +131,7 @@ def _recursive_eval(
128
131
 
129
132
  def _get_coords(
130
133
  geometry: LineString | Polygon,
131
- ) -> tuple[np.ndarray, list[np.ndarray]]:
134
+ ) -> tuple[NDArray, list[NDArray]]:
132
135
  if isinstance(geometry, LineString):
133
136
  return np.array(geometry.coords), []
134
137
  elif isinstance(geometry, Polygon):
rastr/io.py CHANGED
@@ -1,16 +1,26 @@
1
+ from __future__ import annotations
2
+
1
3
  from pathlib import Path
4
+ from typing import TYPE_CHECKING
2
5
 
3
6
  import numpy as np
4
7
  import rasterio
5
- from numpy.typing import NDArray
6
- from pyproj.crs import CRS
8
+ import rasterio.merge
9
+ from pyproj.crs.crs import CRS
7
10
 
8
11
  from rastr.meta import RasterMeta
9
12
  from rastr.raster import RasterModel
10
13
 
14
+ if TYPE_CHECKING:
15
+ from numpy.typing import NDArray
16
+
11
17
 
12
- def read_raster_inmem(raster_path: Path, crs: CRS | None = None) -> RasterModel:
18
+ def read_raster_inmem(
19
+ raster_path: Path | str, *, crs: CRS | str | None = None
20
+ ) -> RasterModel:
13
21
  """Read raster data from a file and return an in-memory Raster object."""
22
+ crs = CRS.from_user_input(crs) if crs is not None else None
23
+
14
24
  with rasterio.open(raster_path, mode="r") as dst:
15
25
  # Read the entire array
16
26
  arr: NDArray[np.float64] = dst.read()
@@ -27,3 +37,58 @@ def read_raster_inmem(raster_path: Path, crs: CRS | None = None) -> RasterModel:
27
37
  raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
28
38
  raster_obj = RasterModel(arr=arr, raster_meta=raster_meta)
29
39
  return raster_obj
40
+
41
+
42
+ def read_raster_mosaic_inmem(
43
+ mosaic_dir: Path | str, *, glob: str = "*.tif", crs: CRS | None = None
44
+ ) -> RasterModel:
45
+ """Read a raster mosaic from a directory and return an in-memory Raster object.
46
+
47
+ This assumes that all rasters have the same metadata, e.g. coordinate system,
48
+ cell size, etc.
49
+ """
50
+ mosaic_dir = Path(mosaic_dir)
51
+ raster_paths = list(mosaic_dir.glob(glob))
52
+ if not raster_paths:
53
+ msg = f"No raster files found in {mosaic_dir} matching {glob}"
54
+ raise FileNotFoundError(msg)
55
+
56
+ # Sort raster_paths in alphabetical order by stem
57
+ raster_paths.sort(key=lambda p: p.stem)
58
+
59
+ # Open all TIFF datasets using context managers to ensure proper closure
60
+ sources = []
61
+ try:
62
+ for raster_path in raster_paths:
63
+ src = rasterio.open(raster_path)
64
+ sources.append(src)
65
+
66
+ # Merge into a single mosaic array & transform
67
+ arr, transform = rasterio.merge.merge(sources)
68
+
69
+ # Copy metadata from the first dataset
70
+ out_meta = sources[0].meta.copy()
71
+ out_meta.update(
72
+ {
73
+ "driver": "GTiff",
74
+ "height": arr.shape[1],
75
+ "width": arr.shape[2],
76
+ "transform": transform,
77
+ }
78
+ )
79
+ cell_size = sources[0].res[0]
80
+ if crs is None:
81
+ crs = CRS.from_user_input(sources[0].crs)
82
+
83
+ nodata = sources[0].nodata
84
+ if nodata is not None:
85
+ arr[arr == nodata] = np.nan
86
+
87
+ arr = arr.squeeze().astype(np.float64)
88
+
89
+ raster_meta = RasterMeta(cell_size=cell_size, crs=crs, transform=transform)
90
+ raster_obj = RasterModel(arr=arr, raster_meta=raster_meta)
91
+ return raster_obj
92
+ finally:
93
+ for src in sources:
94
+ src.close()
rastr/meta.py CHANGED
@@ -1,8 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
1
5
  import numpy as np
2
6
  from affine import Affine
3
7
  from pydantic import BaseModel, InstanceOf
4
8
  from pyproj import CRS
5
- from typing_extensions import Self
9
+
10
+ if TYPE_CHECKING:
11
+ from numpy.typing import NDArray
12
+ from typing_extensions import Self
6
13
 
7
14
 
8
15
  class RasterMeta(BaseModel, extra="forbid"):
@@ -11,6 +18,8 @@ class RasterMeta(BaseModel, extra="forbid"):
11
18
  Attributes:
12
19
  cell_size: Cell size in meters.
13
20
  crs: Coordinate reference system.
21
+ transform: The affine transformation associated with the raster. This is based
22
+ on the CRS, the cell size, as well as the offset/origin.
14
23
  """
15
24
 
16
25
  cell_size: float
@@ -26,7 +35,7 @@ class RasterMeta(BaseModel, extra="forbid"):
26
35
  transform=Affine.scale(2.0, 2.0),
27
36
  )
28
37
 
29
- def get_cell_centre_coords(self, shape: tuple[int, int]) -> np.ndarray:
38
+ def get_cell_centre_coords(self, shape: tuple[int, int]) -> NDArray:
30
39
  """Return an array of (x, y) coordinates for the center of each cell.
31
40
 
32
41
  The coordinates will be in the coordinate system defined by the
@@ -36,13 +45,43 @@ class RasterMeta(BaseModel, extra="forbid"):
36
45
  shape: (rows, cols) of the raster array.
37
46
 
38
47
  Returns:
39
- np.ndarray of shape (rows, cols, 2) with (x, y) coordinates for each
40
- cell center.
48
+ (x, y) coordinates for each cell center, with shape (rows, cols, 2)
41
49
  """
42
- rows, cols = shape
43
- x_idx = np.arange(cols)
44
- y_idx = np.arange(rows)
45
- xv, yv = np.meshgrid(x_idx, y_idx)
46
- x_coords, y_coords = self.transform * (xv + 0.5, yv + 0.5)
47
- coords = np.stack([x_coords, y_coords], axis=-1)
50
+ x_coords = self.get_cell_x_coords(shape[1]) # cols for x-coordinates
51
+ y_coords = self.get_cell_y_coords(shape[0]) # rows for y-coordinates
52
+ coords = np.stack(np.meshgrid(x_coords, y_coords), axis=-1)
48
53
  return coords
54
+
55
+ def get_cell_x_coords(self, n_columns: int) -> NDArray:
56
+ """Return an array of x coordinates for the center of each cell.
57
+
58
+ The coordinates will be in the coordinate system defined by the
59
+ raster's transform.
60
+
61
+ Args:
62
+ n_columns: Number of columns in the raster array.
63
+
64
+ Returns:
65
+ x_coordinates at cell centers, with shape (n_columns,)
66
+ """
67
+ x_idx = np.arange(n_columns) + 0.5
68
+ y_idx = np.zeros_like(x_idx) # Use y=0 for a single row
69
+ x_coords, _ = self.transform * (x_idx, y_idx) # type: ignore[reportAssignmentType] overloaded tuple size in affine
70
+ return x_coords
71
+
72
+ def get_cell_y_coords(self, n_rows: int) -> NDArray:
73
+ """Return an array of y coordinates for the center of each cell.
74
+
75
+ The coordinates will be in the coordinate system defined by the
76
+ raster's transform.
77
+
78
+ Args:
79
+ n_rows: Number of rows in the raster array.
80
+
81
+ Returns:
82
+ y_coordinates at cell centers, with shape (n_rows,)
83
+ """
84
+ x_idx = np.zeros(n_rows) # Use x=0 for a single column
85
+ y_idx = np.arange(n_rows) + 0.5
86
+ _, y_coords = self.transform * (x_idx, y_idx) # type: ignore[reportAssignmentType] overloaded tuple size in affine
87
+ return y_coords