|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | from math import ceil |
| 4 | +import warnings |
4 | 5 |
|
5 | 6 | import datashader as ds |
6 | 7 | import datashader.transfer_functions as tf |
@@ -449,3 +450,152 @@ def _convert_color(c): |
449 | 450 | _converted_colors = {k: _convert_color(v) for k, v in color_key.items()} |
450 | 451 | f = np.vectorize(lambda v: _converted_colors.get(v, 0)) |
451 | 452 | return tf.Image(f(agg.data)) |
| 453 | + |
| 454 | + |
| 455 | + |
| 456 | +def _infer_coord_unit_type(coord: xr.DataArray, cellsize: float) -> str: |
| 457 | + """ |
| 458 | + Heuristic to classify a spatial coordinate axis as: |
| 459 | + - 'degrees' |
| 460 | + - 'linear' (meters/feet/etc) |
| 461 | + - 'unknown' |
| 462 | +
|
| 463 | + Parameters |
| 464 | + ---------- |
| 465 | + coord : xr.DataArray |
| 466 | + 1D coordinate variable (x or y). |
| 467 | + cellsize : float |
| 468 | + Mean spacing along this coordinate. |
| 469 | +
|
| 470 | + Returns |
| 471 | + ------- |
| 472 | + str |
| 473 | + """ |
| 474 | + units = str(coord.attrs.get("units", "")).lower() |
| 475 | + |
| 476 | + # 1) Explicit units, if present |
| 477 | + if "degree" in units or units in ("deg", "degrees"): |
| 478 | + return "degrees" |
| 479 | + if units in ("m", "meter", "metre", "meters", "metres", |
| 480 | + "km", "kilometer", "kilometre", "kilometers", "kilometres", |
| 481 | + "ft", "foot", "feet"): |
| 482 | + return "linear" |
| 483 | + |
| 484 | + # 2) Numeric heuristics (very conservative) |
| 485 | + vals = coord.values |
| 486 | + if vals.size < 2 or not np.issubdtype(vals.dtype, np.number): |
| 487 | + return "unknown" |
| 488 | + |
| 489 | + vmin = float(np.nanmin(vals)) |
| 490 | + vmax = float(np.nanmax(vals)) |
| 491 | + span = abs(vmax - vmin) |
| 492 | + dx = abs(float(cellsize)) |
| 493 | + |
| 494 | + # Typical global geographic axes: span <= 360, spacing ~1e-5–0.5 deg |
| 495 | + if -360.0 <= vmin <= 360.0 and -360.0 <= vmax <= 360.0: |
| 496 | + if 1e-5 <= dx <= 0.5: |
| 497 | + return "degrees" |
| 498 | + |
| 499 | + # Typical projected axes in meters: span >> 1, spacing > ~0.1 |
| 500 | + # (e.g. UTM / national grids) |
| 501 | + if span > 1000.0 and dx >= 0.1: |
| 502 | + return "linear" |
| 503 | + |
| 504 | + return "unknown" |
| 505 | + |
| 506 | + |
| 507 | +def _infer_vertical_unit_type(agg: xr.DataArray) -> str: |
| 508 | + """ |
| 509 | + Heuristic to classify the DataArray values as: |
| 510 | + - 'elevation' (meters/feet etc) |
| 511 | + - 'angle' (degrees/radians) |
| 512 | + - 'unknown' |
| 513 | + """ |
| 514 | + units = str(agg.attrs.get("units", "")).lower() |
| 515 | + |
| 516 | + # 1) Explicit units |
| 517 | + if any(k in units for k in ("degree", "deg")): |
| 518 | + return "angle" |
| 519 | + if "rad" in units: |
| 520 | + return "angle" |
| 521 | + if units in ("m", "meter", "metre", "meters", "metres", |
| 522 | + "km", "kilometer", "kilometre", "kilometers", "kilometres", |
| 523 | + "ft", "foot", "feet"): |
| 524 | + return "elevation" |
| 525 | + |
| 526 | + # 2) Numeric heuristics on data range |
| 527 | + data = agg.values |
| 528 | + if not np.issubdtype(data.dtype, np.number): |
| 529 | + return "unknown" |
| 530 | + |
| 531 | + finite = np.isfinite(data) |
| 532 | + if not np.any(finite): |
| 533 | + return "unknown" |
| 534 | + |
| 535 | + vmin = float(data[finite].min()) |
| 536 | + vmax = float(data[finite].max()) |
| 537 | + span = vmax - vmin |
| 538 | + |
| 539 | + # Elevation-like: tens–thousands of units, typical DEM ranges. |
| 540 | + if 10.0 <= span <= 20000.0 and vmin > -500.0: |
| 541 | + return "elevation" |
| 542 | + |
| 543 | + # Angle-like: often 0–360, -180–180, or small (-pi, pi) |
| 544 | + if -360.0 <= vmin <= 360.0 and -360.0 <= vmax <= 360.0: |
| 545 | + # If the span is not huge, treat as angle-ish |
| 546 | + if span <= 720.0: |
| 547 | + return "angle" |
| 548 | + |
| 549 | + return "unknown" |
| 550 | + |
| 551 | +def warn_if_unit_mismatch(agg: xr.DataArray) -> None: |
| 552 | + """ |
| 553 | + Heuristic check for horizontal vs vertical unit mismatch. |
| 554 | +
|
| 555 | + Intended to catch the common case of: |
| 556 | + - coordinates in degrees (lon/lat) |
| 557 | + - elevation values in meters/feet |
| 558 | +
|
| 559 | + Emits a UserWarning if a likely mismatch is detected. |
| 560 | + """ |
| 561 | + try: |
| 562 | + cellsize_x, cellsize_y = get_dataarray_resolution(agg) |
| 563 | + except Exception: |
| 564 | + # If we can't even get a resolution, we also can't say much |
| 565 | + return |
| 566 | + |
| 567 | + # pick "x" and "y" coords in a generic way: |
| 568 | + # - typically dims are ('y', 'x') or ('lat', 'lon') |
| 569 | + # - fall back to last two dims |
| 570 | + if len(agg.dims) < 2: |
| 571 | + return |
| 572 | + |
| 573 | + dim_y, dim_x = agg.dims[-2], agg.dims[-1] |
| 574 | + coord_x = agg.coords.get(dim_x, None) |
| 575 | + coord_y = agg.coords.get(dim_y, None) |
| 576 | + |
| 577 | + if coord_x is None or coord_y is None: |
| 578 | + # Can't infer spatial types without coords |
| 579 | + return |
| 580 | + |
| 581 | + horiz_x = _infer_coord_unit_type(coord_x, cellsize_x) |
| 582 | + horiz_y = _infer_coord_unit_type(coord_y, cellsize_y) |
| 583 | + vert = _infer_vertical_unit_type(agg) |
| 584 | + |
| 585 | + horiz_types = {horiz_x, horiz_y} - {"unknown"} |
| 586 | + |
| 587 | + # Only act if we have some signal about horizontal AND vertical |
| 588 | + if not horiz_types or vert == "unknown": |
| 589 | + return |
| 590 | + |
| 591 | + # If any axis looks like degrees and vertical looks like elevation, |
| 592 | + # it's almost certainly "lat/lon degrees + meter elevations" |
| 593 | + if "degrees" in horiz_types and vert == "elevation": |
| 594 | + warnings.warn( |
| 595 | + "xrspatial: input DataArray appears to have coordinates in degrees " |
| 596 | + "but elevation values in a linear unit (e.g. meters/feet). " |
| 597 | + "Slope/aspect operations expect horizontal distances in the same " |
| 598 | + "units as vertical. Consider reprojecting to a projected CRS with " |
| 599 | + "meter-based coordinates before calling `slope`.", |
| 600 | + UserWarning, |
| 601 | + ) |
0 commit comments