diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fe8551d..0167426 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -# Lint the code using the defined pre-commits +# Lint the code using the configured prek hooks name: LintCode on: [push] @@ -10,17 +10,17 @@ jobs: if: github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'no_ci') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6.2.0 with: - python-version: '3.12' + python-version: '3.11' - name: install linting packages - run: uv tool install pre-commit + run: uv tool install prek - - name: run all precommits - run: uv tool run pre-commit run --all + - name: run all prek hooks + run: uv tool run prek run --all-files diff --git a/.github/workflows/runtests.yml b/.github/workflows/runtests.yml index dd3731a..0164437 100644 --- a/.github/workflows/runtests.yml +++ b/.github/workflows/runtests.yml @@ -18,7 +18,8 @@ concurrency: cancel-in-progress: true jobs: - # Runs the tests on combinations of the supported python/os matrix. + # Runs the full optional-dependency integration suite on the supported + # Python/OS matrix. test_code: timeout-minutes: 25 @@ -26,7 +27,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.10', '3.11', "3.12"] + python-version: ["3.11", "3.12"] # only run if CI isn't turned off if: github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'no_ci') @@ -36,10 +37,10 @@ jobs: env_file: "environment.yml" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 - name: run pytest run: uv run --all-extras --python ${{ matrix.python-version }} pytest -s --cov src --cov-append --cov-report=xml @@ -49,7 +50,7 @@ jobs: run: uv run --all-extras --python ${{ matrix.python-version }} pytest src --doctest-modules # Upload coverage files - - uses: codecov/codecov-action@v5 + - uses: codecov/codecov-action@v6.0.0 with: fail_ci_if_error: false files: ./coverage.xml @@ -57,6 +58,28 @@ jobs: name: PR_tests token: ${{ secrets.CODECOV_TOKEN }} + # Optional native dependencies lag new Python releases, so keep the broader + # Python matrix to a package import smoke test. + test_core_python: + + timeout-minutes: 10 + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13", "3.14"] + + # only run if CI isn't turned off + if: github.event_name == 'push' || !contains(github.event.pull_request.labels.*.name, 'no_ci') + + steps: + - uses: actions/checkout@v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + + - name: import package + run: uv run --python ${{ matrix.python-version }} python -c "import unidas; assert isinstance(unidas.__version__, str)" + # This is a very useful step for debugging, it allows you to ssh into the CI # machine (https://github.com/marketplace/actions/debugging-with-tmate). diff --git a/.github/workflows/upload_pypi.yml b/.github/workflows/upload_pypi.yml index 258953b..79adb96 100644 --- a/.github/workflows/upload_pypi.yml +++ b/.github/workflows/upload_pypi.yml @@ -1,24 +1,24 @@ -# Upload to PyPI when new code lands in main. -name: PublishPackage +# Upload to PyPI when a GitHub release is published. +name: ReleasePackage on: - push: - branches: - - main + release: + types: [published] jobs: - upload: + release: runs-on: ubuntu-latest environment: pypi permissions: + contents: write # This must be enabled for trusted publishing. id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v8.1.0 - - name: build and publish + - name: Build and publish shell: bash -l {0} run: | uv build diff --git a/README.md b/README.md index f1222c1..5e6b50d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ out = daspy_function(patch) assert isinstance(out, dc.Patch) ``` -You can also use `adpater` to wrap un-wrapped functions. +You can also use `adapter` to wrap un-wrapped functions. ```python import dascore as dc @@ -61,7 +61,7 @@ sec_out = unidas.convert(blast, to='daspy.Section') ``` ## Installation -Simply install unidas with pip or mamba: +Unidas requires Python 3.11 or newer. Simply install unidas with pip or mamba: ```bash pip install unidas @@ -73,6 +73,22 @@ mamba install unidas By design, unidas has no hard dependencies other than numpy, but an `ImportError` will be raised if the libraries needed to perform a requested conversion are not installed. +To install the supported DAS libraries with unidas: + +```bash +pip install "unidas[extras]" +``` + +Some optional libraries lag new Python releases. The aggregate `unidas[extras]` +install currently targets Python 3.11 and 3.12; on Python 3.13 and newer, +install optional DAS libraries directly once they publish compatible wheels. + +For development and testing: + +```bash +pip install "unidas[dev]" +``` + Unidas is single file (src/unidas.py) so it can also be vendored (copied directly into your project). If you do this, please consider sharing any improvements so the entire community can benefit. ## Guidance for package developers @@ -105,7 +121,7 @@ def fancy_machine_learning_function(sec): To add support for a new data structure/library, you need to do two things: 1. Create a subclass of `Converter` which has (at least) a conversion method to unidas' BaseDAS. -2. Add a conversion method to UnidasBasDASConverter to convert from unidas' BaseDAS back to your data structure. +2. Add a conversion method to UnidasBaseDASConverter to convert from unidas' BaseDAS back to your data structure. 3. Write a test in test/test_unidas.py (this is important for maintainability). Feel free to open a discussion if you need help. @@ -116,3 +132,11 @@ Feel free to open a discussion if you need help. - [DASPy](https://github.com/HMZ-03/DASPy) - [Lightguide](https://github.com/pyrocko/lightguide) - [Xdas](https://github.com/xdas-dev/xdas) + +## Compatibility notes + +DASPy sections require `time` and `distance` coordinates, evenly sampled coordinates, and an absolute datetime time coordinate. DASCore or XDAS objects with relative, numeric, or uneven time/distance coordinates may still convert to other formats, but will raise a `ValueError` when converting to `daspy.Section`. + +## Making releases + +To publish a release, bump `__version__` in `src/unidas.py`, merge the change to `main`, create a version tag such as `v0.1.0`, then publish a GitHub Release from that tag. Publishing the GitHub Release triggers the PyPI upload workflow. diff --git a/pyproject.toml b/pyproject.toml index 6144de9..b2a71ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,18 +2,24 @@ # --- Build system configuration [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["setuptools>=80"] +build-backend = "setuptools.build_meta" -[tool.hatch.build.targets.wheel] -packages = ["src/unidas.py"] +[tool.setuptools] +include-package-data = true +py-modules = ["unidas"] + +[tool.setuptools.package-dir] +"" = "src" + +[tool.setuptools.dynamic] +version = {attr = "unidas.__version__"} # --- Project Metadata [project] name = "unidas" - -version = "0.0.1" # Make sure to bump dascore.__version__ as well! +dynamic = ["version"] authors = [ { name="Derrick Chambers", email="chambers.ja.derrick@gmail.com" }, @@ -21,9 +27,9 @@ authors = [ description = "A DAS compatibility library" readme = "README.md" -requires-python = ">=3.10" +license = "MIT" +requires-python = ">=3.11" classifiers = [ - "License :: OSI Approved :: MIT License", "Topic :: Scientific/Engineering", ] @@ -32,21 +38,23 @@ keywords = ["geophysics", "distributed-acoustic-sensing"] # --- Dependencies dependencies = [ - "numpy" + "numpy>=1.26; python_version < '3.13'", + "numpy>=2; python_version >= '3.13'", ] [project.optional-dependencies] extras = [ - "dascore", - "daspy-toolbox", - "lightguide", - "xdas", - "numpy<2" # This is due to pyrocko. Remove when it has a new release. + "dascore; python_version < '3.13'", + "daspy-toolbox; python_version < '3.13'", + "lightguide; platform_system != 'Windows' and python_version < '3.13'", + "xdas; python_version < '3.13'", + # This is due to pyrocko. Remove when it has a new release. + "numpy<2; python_version < '3.13'", ] test = [ - "pre-commit", + "prek", "pytest", "ruff", "pooch", @@ -58,9 +66,9 @@ dev = ["unidas[test]", "unidas[extras]"] # --- URLs for project [project.urls] -"Bug Tracker" = "https://github.com/unidas-dev/unidas" -"Documentation" = "https://github.com/unidas-dev/unidas" -"Homepage" = "https://github.com/unidas-dev/unidas" +"Bug Tracker" = "https://github.com/dasdae/unidas/issues" +"Documentation" = "https://github.com/dasdae/unidas" +"Homepage" = "https://github.com/dasdae/unidas" # --- External tool configuration diff --git a/src/unidas.py b/src/unidas.py index f845ebd..eb52603 100644 --- a/src/unidas.py +++ b/src/unidas.py @@ -4,14 +4,6 @@ from __future__ import annotations -# Unidas version indicator. When incrementing, be sure to update -# pyproject.toml as well. -__version__ = "0.0.1" - -# Explicitly defines unidas' public API. -# https://peps.python.org/pep-0008/#public-and-internal-interfaces -__all__ = ("adapter", "convert") - import datetime import importlib import inspect @@ -25,6 +17,13 @@ import numpy as np +# Explicitly defines unidas' public API. +__all__ = ("adapter", "convert") + +# Keep the version hardcoded so vendored copies report their own version +# without requiring installed package metadata. +__version__ = "0.1.0" + # Define the urls to each project to provide helpful error messages. PROJECT_URLS = { "dascore": "https://github.com/dasdae/dascore", @@ -33,9 +32,6 @@ "xdas": "https://github.com/xdas-dev/xdas", } -# Datetime precision. This can change between python versions. -DT_PRECISION = datetime.datetime.resolution.total_seconds() - # A generic type variable. T = TypeVar("T") @@ -127,15 +123,14 @@ def time_to_float(obj): def time_to_datetime(obj): """Convert a time-like object to a datetime object.""" - if isinstance(obj, np.datetime64) and DT_PRECISION > 1e-9: - # On python 3.10 this can fail since the default time precision is - # for datetime.datetime is us not ns. Need to truncate to us precision. - # TODO: need to look into daspy's DASUTC to see if it can handle ns. - obj = obj.astype("datetime64[us]") - if not isinstance(obj, datetime.datetime): - # Lightguide expects a timezone to be attached, so we attach utc. - utc = zoneinfo.ZoneInfo("UTC") - obj = datetime.datetime.fromisoformat(str(obj)).astimezone(utc) + if isinstance(obj, np.datetime64): + obj = obj.astype("datetime64[us]").item() + elif isinstance(obj, np.timedelta64) or not isinstance(obj, datetime.datetime): + msg = "DASPy conversion requires an absolute datetime time coordinate." + raise ValueError(msg) + # Lightguide expects a timezone to be attached, so attach UTC to naive values. + utc = zoneinfo.ZoneInfo("UTC") + obj = obj.replace(tzinfo=utc) if obj.tzinfo is None else obj.astimezone(utc) return obj @@ -182,6 +177,14 @@ def to_xdas_coord(self): """Method to convert to xdas coordinate.""" raise NotImplementedError(f"Not implemented for {self.__class__}") + def get_step(self): + """Return the coordinate step when it is well-defined.""" + raise NotImplementedError(f"Not implemented for {self.__class__}") + + def get_start(self): + """Return the first coordinate value.""" + raise NotImplementedError(f"Not implemented for {self.__class__}") + @dataclass class EvenlySampledCoordinate(Coordinate): @@ -231,7 +234,7 @@ def to_dascore_coord(self): def to_xdas_coord(self): """Convert to an XDAS coordinate.""" - xcoords = optional_import("xdas.core.coordinates") + xdas = optional_import("xdas") # Currently, xdas expects a number or numpy datatime, need to convert # python datetimes to numpy. tie_values = self.tie_values @@ -239,12 +242,21 @@ def to_xdas_coord(self): if isinstance(self.tie_values[0], datetime.datetime): tie_values = [np.datetime64(to_stripped_utc(x)) for x in tie_values] data = {"tie_indices": self.tie_indices, "tie_values": tie_values} - out = xcoords.InterpCoordinate(data=data) + dim = self.dims[0] if len(self.dims) == 1 else None + out = xdas.InterpCoordinate(data=data, dim=dim) return out def __len__(self): return self.tie_indices[-1] - self.tie_indices[0] + 1 + def get_step(self): + """Return the coordinate step.""" + return self.step + + def get_start(self): + """Return the first coordinate value.""" + return self.tie_values[0] + @dataclass class ArrayCoordinate(Coordinate): @@ -270,7 +282,27 @@ class ArrayCoordinate(Coordinate): def to_dascore_coord(self): """Convert to a dascore coordinate.""" dc_core = optional_import("dascore.core") - return dc_core.get_coord(**self.to_dict()) + return dc_core.get_coord(data=self.data, units=self.units) + + def to_xdas_coord(self): + """Convert to an XDAS coordinate.""" + xdas = optional_import("xdas") + dim = self.dims[0] if len(self.dims) == 1 else None + return xdas.DenseCoordinate(data=self.data, dim=dim) + + def get_step(self): + """Return the coordinate step when it is evenly sampled.""" + if len(self) == 1: + return 1 + diff = np.diff(self.data) + if np.all(diff == diff[0]): + return diff[0] + msg = "Array coordinates must be evenly sampled to convert to DASPy." + raise ValueError(msg) + + def get_start(self): + """Return the first coordinate value.""" + return self.data[0] def __len__(self): return len(self.data) @@ -473,13 +505,14 @@ def to_daspy_section(self, base_das: BaseDAS): daspy = optional_import("daspy") dasdt = daspy.DASDateTime out = base_das.transpose("time", "distance").to_dict(flavor="simple") - time, dist = out["coords"]["time"], out["coords"]["distance"] - start_time = time_to_datetime(time["tie_values"][0]) + time_coord = base_das.coords["time"] + dist_coord = base_das.coords["distance"] + start_time = time_to_datetime(time_coord.get_start()) section = daspy.Section( - data=base_das.data, - fs=1 / time_to_float(time["step"]), # This is sampling rate in Hz - dx=dist["step"], - start_distance=dist["tie_values"][0], + data=out["data"].T, + fs=1 / time_to_float(time_coord.get_step()), + dx=dist_coord.get_step(), + start_distance=dist_coord.get_start(), start_time=dasdt.from_datetime(start_time), **out["attrs"], ) @@ -526,7 +559,7 @@ def _to_base_coords(self, coord, dims): step=coord.step, ) else: - return ArrayCoordinate(array=coord.array, units=coord.units) + return ArrayCoordinate(data=coord.data, units=coord.units, dims=dims) @converts_to("unidas.BaseDAS") def to_base(self, patch) -> BaseDAS: @@ -639,25 +672,23 @@ class XDASConverter(Converter): def _to_base_coords(self, data_array): """Convert the xdas coordinates to unidas coordinates.""" - xcoords = optional_import("xdas.core.coordinates") + xdas = optional_import("xdas") coords = data_array.coords coords_out = {} for name, coord in coords.items(): dims = (coord.dim,) if isinstance(coord.dim, str) else (name,) - # Other libraries handle gaps differently. For now, we raise if - # there are any gaps, which I interpret as more than 2 tie values. - # Need to double check that this is right. - if len(coord.tie_values) > 2: - msg = ( - "Tie values of xdas coordinates imply gaps, cant convert to " - "other formats" - ) - raise NotImplementedError(msg) # It seems the InterpCoordinate is evenly sampled, monotonic. - if isinstance(coord, xcoords.InterpCoordinate): - step = xcoords.get_sampling_interval( - da=data_array, dim=name, cast=False - ) + if isinstance(coord, xdas.InterpCoordinate): + # Other libraries handle gaps differently. For now, we raise if + # there are any gaps, which I interpret as more than 2 tie values. + # Need to double check that this is right. + if len(coord.tie_values) > 2: + msg = ( + "Tie values of xdas coordinates imply gaps, cant convert to " + "other formats" + ) + raise NotImplementedError(msg) + step = xdas.get_sampling_interval(da=data_array, dim=name, cast=False) ucoord = EvenlySampledCoordinate( tie_values=coord.tie_values, tie_indices=coord.tie_indices, diff --git a/test/conftest.py b/test/conftest.py index 5a0ef2b..323668f 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -2,6 +2,7 @@ Pytest configuration and global fixtures for unidas. """ +import importlib.util import platform import dascore as dc @@ -24,11 +25,13 @@ def daspy_section(): @pytest.fixture(scope="session") -# Currently, lightguide doesn't install on windows in CI. Just skip. +# Lightguide currently has platform and Python-version compatibility limits. def lightguide_blast(): """Get a Blast from lightguide.""" if platform.system().lower() == "windows": pytest.skip("Lightguide is not supported on Windows") + if importlib.util.find_spec("lightguide") is None: + pytest.skip("Lightguide is not installed") from lightguide.blast import Blast diff --git a/test/test_unidas.py b/test/test_unidas.py index 59193cc..646b4c5 100644 --- a/test/test_unidas.py +++ b/test/test_unidas.py @@ -2,6 +2,7 @@ Tests for core functionality of unidas. """ +import datetime import platform import dascore as dc @@ -10,19 +11,25 @@ import pandas as pd import pytest import unidas -from unidas import BaseDAS, adapter, convert, optional_import +from dascore.examples import EXAMPLE_PATCHES +from unidas import BaseDAS, Converter, adapter, convert, optional_import from xdas.core.dataarray import DataArray try: from lightguide.blast import Blast except ImportError: + LIGHTGUIDE_AVAILABLE = False class Blast: """A dummy blast.""" +else: + LIGHTGUIDE_AVAILABLE = True + ON_WINDOWS = platform.system().lower() == "windows" +LIGHTGUIDE_SUPPORTED = not ON_WINDOWS and LIGHTGUIDE_AVAILABLE # A tuple of format names for testing generic conversions. NAME_CLASS_MAP = { @@ -32,17 +39,46 @@ class Blast: "lightguide.Blast": Blast, } BASE_FORMATS = tuple(NAME_CLASS_MAP) +DASCORE_EXAMPLE_NAMES = tuple(EXAMPLE_PATCHES) +DASPY_COMPATIBLE_DASCORE_EXAMPLES = ( + "random_das", + "patch_with_null", + "random_patch_with_lat_lon", + "random_patch_with_xyz", + "sin_wav", + "chirp", + "example_event_1", + "deformation_rate_event_1", + "dispersion_event", +) +DASPY_UNSUPPORTED_DASCORE_EXAMPLES = { + "wacky_dim_coords_patch": "evenly sampled", + "example_event_2": "absolute datetime", + "forge_dss": "evenly sampled", + "forge_dts": "evenly sampled", + "ricker_moveout": "absolute datetime", +} # --- Tests for unidas utilities. +def assert_array_equal_with_nan(array_1, array_2): + """Assert arrays are equal, treating floating NaNs as equal.""" + array_1 = np.asarray(array_1) + array_2 = np.asarray(array_2) + if np.issubdtype(array_1.dtype, np.floating): + assert np.allclose(array_1, array_2, equal_nan=True) + else: + assert np.array_equal(array_1, array_2) + + @pytest.fixture(params=BASE_FORMATS) def format_name(request): """Fixture for returning format names.""" name = request.param - if ON_WINDOWS and name.startswith("lightguide"): - pytest.skip("waveguide does not support windows") + if name.startswith("lightguide") and not LIGHTGUIDE_SUPPORTED: + pytest.skip("Lightguide is not supported or installed") return request.param @@ -70,6 +106,73 @@ def test_version(self): assert hasattr(unidas, "__version__") assert isinstance(unidas.__version__, str) + def test_time_to_float_datetime(self): + """Ensure Python datetimes convert to timestamps.""" + time = datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=datetime.UTC) + + out = unidas.time_to_float(time) + + assert out == 1 + + def test_time_to_datetime_truncates_numpy_nanoseconds(self): + """Ensure numpy nanosecond datetimes convert to UTC Python datetimes.""" + time = np.datetime64("2020-01-01T00:00:00.123456789") + + out = unidas.time_to_datetime(time) + + expected = datetime.datetime( + 2020, + 1, + 1, + 0, + 0, + 0, + 123456, + tzinfo=datetime.UTC, + ) + assert out == expected + + +class TestCoordinate: + """Test suite for coordinate base behavior.""" + + def test_base_coordinate_methods_raise(self): + """Ensure base coordinate methods are abstract.""" + coord = unidas.Coordinate() + msg = "Not implemented" + + with pytest.raises(NotImplementedError, match=msg): + coord.to_dascore_coord() + with pytest.raises(NotImplementedError, match=msg): + coord.to_xdas_coord() + with pytest.raises(NotImplementedError, match=msg): + coord.get_step() + with pytest.raises(NotImplementedError, match=msg): + coord.get_start() + + def test_evenly_sampled_coordinate_with_gaps_to_dascore_raises(self): + """Ensure gapped coordinates cannot convert to DASCore.""" + coord = unidas.EvenlySampledCoordinate( + tie_values=(0, 1, 3), + tie_indices=(0, 2, 4), + step=1, + dims=("distance",), + ) + + with pytest.raises(NotImplementedError, match="gaps"): + coord.to_dascore_coord() + + +class TestConverterBase: + """Test suite for converter base behavior.""" + + def test_subclass_without_name_raises(self): + """Ensure converter subclasses must define a name.""" + with pytest.raises(ValueError, match="must define a name"): + + class BadConverter(Converter): + """Converter missing a name.""" + # --------- Tests for unidas conversions. @@ -106,6 +209,130 @@ def test_convert_patch_to_other(self, dascore_patch, format_name): out = convert(dascore_patch, to=format_name) assert isinstance(out, NAME_CLASS_MAP[format_name]) + @pytest.mark.parametrize("example_name", DASCORE_EXAMPLE_NAMES) + def test_example_patch_to_base_das(self, example_name): + """Ensure all DASCore generated examples convert to BaseDAS.""" + patch = dc.get_example_patch(example_name) + + out = convert(patch, to="unidas.BaseDAS") + + assert isinstance(out, BaseDAS) + assert out.dims == patch.dims + assert out.data.shape == patch.shape + out.validate() + + @pytest.mark.parametrize("example_name", DASCORE_EXAMPLE_NAMES) + def test_example_patch_round_trip_to_dascore(self, example_name): + """Ensure all DASCore generated examples round-trip through BaseDAS.""" + patch = dc.get_example_patch(example_name) + base = convert(patch, to="unidas.BaseDAS") + + out = convert(base, to="dascore.Patch") + + assert isinstance(out, dc.Patch) + assert out.dims == patch.dims + assert out.shape == patch.shape + assert_array_equal_with_nan(out.data, patch.data) + assert set(out.coords.coord_map) == set(patch.coords.coord_map) + for coord_name in patch.coords.coord_map: + assert out.coords.dim_map[coord_name] == patch.coords.dim_map[coord_name] + assert_array_equal_with_nan( + out.get_array(coord_name), + patch.get_array(coord_name), + ) + + @pytest.mark.parametrize("example_name", DASCORE_EXAMPLE_NAMES) + def test_example_patch_to_xdas_dataarray(self, example_name): + """Ensure all DASCore generated examples convert to XDAS.""" + patch = dc.get_example_patch(example_name) + + out = convert(patch, to="xdas.DataArray") + + assert isinstance(out, DataArray) + assert out.dims == patch.dims + assert out.shape == patch.shape + assert_array_equal_with_nan(out.data, patch.data) + assert set(out.coords) == set(patch.coords.coord_map) + for coord_name in patch.coords.coord_map: + assert out.coords[coord_name].dim == patch.coords.dim_map[coord_name][0] + assert_array_equal_with_nan( + out.coords[coord_name].values, + patch.get_array(coord_name), + ) + + def test_time_distance_patch_to_daspy_section_shape(self, dascore_patch): + """Ensure DASPy sections always use channel/time data order.""" + patch = dascore_patch.transpose("time", "distance") + + out = convert(patch, to="daspy.Section") + + assert isinstance(out, daspy.Section) + assert out.data.shape == dascore_patch.shape + + @pytest.mark.parametrize("example_name", DASPY_COMPATIBLE_DASCORE_EXAMPLES) + def test_example_patch_to_daspy_section(self, example_name): + """Ensure DASCore example patches can convert to DASPy sections.""" + patch = dc.get_example_patch(example_name) + expected_shape = patch.transpose("distance", "time").shape + + out = convert(patch, to="daspy.Section") + + assert isinstance(out, daspy.Section) + assert out.data.shape == expected_shape + + @pytest.mark.parametrize( + ("example_name", "message"), + DASPY_UNSUPPORTED_DASCORE_EXAMPLES.items(), + ) + def test_unsupported_example_patch_to_daspy_section(self, example_name, message): + """Ensure unsupported DASCore examples fail with expected errors.""" + patch = dc.get_example_patch(example_name) + + with pytest.raises(ValueError, match=message): + convert(patch, to="daspy.Section") + + def test_single_distance_patch_to_daspy_section(self): + """Ensure singleton distance coordinates default to dx=1.""" + time = dc.to_datetime64("2020-01-01") + dc.to_timedelta64(np.arange(10)) + patch = dc.Patch( + data=np.zeros((1, 10)), + coords={"distance": [0], "time": time}, + dims=("distance", "time"), + ) + + out = convert(patch, to="daspy.Section") + + assert isinstance(out, daspy.Section) + assert out.data.shape == patch.shape + assert out.dx == 1 + + def test_array_coordinates_to_daspy_section(self): + """Ensure evenly sampled array coordinates can convert to DASPy.""" + time = dc.to_datetime64("2020-01-01") + dc.to_timedelta64(np.arange(4)) + distance = np.arange(3) * 2 + base_das = BaseDAS( + data=np.zeros((3, 4)), + coords={ + "distance": unidas.ArrayCoordinate( + data=distance, + dims=("distance",), + ), + "time": unidas.ArrayCoordinate( + data=time, + dims=("time",), + ), + }, + attrs={}, + dims=("distance", "time"), + ) + + out = convert(base_das, to="daspy.Section") + + assert isinstance(out, daspy.Section) + assert out.data.shape == base_das.data.shape + assert out.dx == 2 + assert out.fs == 1 + class TestDASPySection: """Test suite for converting DASPy sections.""" @@ -162,15 +389,49 @@ def test_from_base_das(self, xdas_base_das, xdas_dataarray): assert attr1 == attr2 or (not attr1 and not attr2) assert out.dims == xdas_dataarray.dims + def test_dense_coordinate_to_base_das(self): + """Ensure XDAS dense coordinates convert to array coordinates.""" + xdas = optional_import("xdas") + data_array = xdas.DataArray( + np.zeros((3, 2)), + coords={ + "time": [0, 1, 2], + "distance": [0, 1], + "quality": ("time", [1, 2, 3]), + }, + dims=("time", "distance"), + ) + + out = convert(data_array, to="unidas.BaseDAS") + + assert isinstance(out.coords["quality"], unidas.ArrayCoordinate) + assert out.coords["quality"].dims == ("time",) + assert np.array_equal(out.coords["quality"].data, [1, 2, 3]) + + def test_gapped_interp_coordinate_to_base_das_raises(self): + """Ensure gapped XDAS coordinates are rejected.""" + xdas = optional_import("xdas") + data_array = xdas.DataArray( + np.zeros((5, 2)), + coords={ + "time": {"tie_indices": [0, 2, 4], "tie_values": [0.0, 1.0, 3.0]}, + "distance": [0, 1], + }, + dims=("time", "distance"), + ) + + with pytest.raises(NotImplementedError, match="gaps"): + convert(data_array, to="unidas.BaseDAS") + class TestLightGuideBlast: """Tests for Blast Conversions.""" @pytest.fixture(scope="class", autouse=True) - def skip_on_windows(self): - """Skip tests if on windows.""" - if ON_WINDOWS: - pytest.skip("Lightguide doesn't support windows") + def skip_if_unsupported(self): + """Skip tests if lightguide is unsupported or unavailable.""" + if not LIGHTGUIDE_SUPPORTED: + pytest.skip("Lightguide is not supported or installed") @pytest.fixture(scope="class") def lightguide_base_das(self, lightguide_blast): @@ -266,8 +527,8 @@ class TestIntegrations: def test_readme_1(self): """First test for readme examples.""" - if ON_WINDOWS: - pytest.skip("Lightguide doesn't support windows") + if not LIGHTGUIDE_SUPPORTED: + pytest.skip("Lightguide is not supported or installed") sec = daspy.read() blast = unidas.convert(sec, to="lightguide.Blast") blast.afk_filter(exponent=0.8)