Skip to content

canvod.adapters API Reference

Interfaces

Interfaces of the adapters, one per kind of data canvodpy exchanges.

A tool implements the interfaces of the data it exchanges with canvodpy, and only those. Everything entering canvodpy is checked against canvodpy's own contract for that kind of data:

  • observations: :func:canvod.readers.validate_dataset, the contract of every canvodpy reader. Observations are imported with a reader of the tool's files, a :class:canvod.readers.GNSSDataReader.
  • VOD: :func:canvod.readers.validate_vod_dataset, the contract of VOD calculator output and of the VOD store.

A subclass converts; checking the result and recording provenance is done here, the same for every tool.

ObservationsAdapter

Bases: _Adapter, ABC

Observations: the tool's observation files <-> canvodpy observations.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
class ObservationsAdapter(_Adapter, ABC):
    """Observations: the tool's observation files <-> canvodpy observations."""

    @abstractmethod
    def reader(self, path: Path) -> GNSSDataReader:
        """A canvodpy reader of one of the tool's observation files."""

    @abstractmethod
    def convert_from_canvodpy(self, obs_ds: xr.Dataset) -> xr.Dataset:
        """canvodpy observations in the tool's data structure."""

    @abstractmethod
    def save(self, tool_ds: xr.Dataset, path: Path) -> None:
        """Write a dataset in the tool's data structure to its file format."""

    def import_file(self, path: Path | str, keep_data_vars: list[str] | None = None) -> xr.Dataset:
        """Read one of the tool's observation files as canvodpy observations.

        Parameters
        ----------
        path : Path or str
            The tool's observation file.
        keep_data_vars : list of str, optional
            Data variables to keep and require; ``None`` keeps all and
            requires ``SNR`` (:func:`canvod.readers.validate_dataset`).

        Returns
        -------
        xarray.Dataset
            canvodpy observations with provenance attributes.

        Raises
        ------
        ValueError
            If the result does not meet the observations contract.
        """
        path = Path(path)
        ds = self.reader(path).to_ds(keep_data_vars=keep_data_vars)
        ds.attrs.update(Provenance.now(self.tool, "import", path.name).to_attrs())
        validate_dataset(ds, required_vars=keep_data_vars)
        return ds

    def export_file(self, obs_ds: xr.Dataset, path: Path | str, source: str) -> Path:
        """Write canvodpy observations as one of the tool's files.

        Parameters
        ----------
        obs_ds : xarray.Dataset
            canvodpy observations.
        path : Path or str
            File to write.
        source : str
            What ``obs_ds`` is, recorded as provenance (e.g. a store group).

        Returns
        -------
        Path
            ``path``.
        """
        path = Path(path)
        tool_ds = self.convert_from_canvodpy(obs_ds)
        tool_ds.attrs.update(Provenance.now(self.tool, "export", source).to_attrs())
        self.save(tool_ds, path)
        return path

reader(path) abstractmethod

A canvodpy reader of one of the tool's observation files.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
41
42
43
@abstractmethod
def reader(self, path: Path) -> GNSSDataReader:
    """A canvodpy reader of one of the tool's observation files."""

convert_from_canvodpy(obs_ds) abstractmethod

canvodpy observations in the tool's data structure.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
45
46
47
@abstractmethod
def convert_from_canvodpy(self, obs_ds: xr.Dataset) -> xr.Dataset:
    """canvodpy observations in the tool's data structure."""

save(tool_ds, path) abstractmethod

Write a dataset in the tool's data structure to its file format.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
49
50
51
@abstractmethod
def save(self, tool_ds: xr.Dataset, path: Path) -> None:
    """Write a dataset in the tool's data structure to its file format."""

import_file(path, keep_data_vars=None)

Read one of the tool's observation files as canvodpy observations.

Parameters

path : Path or str The tool's observation file. keep_data_vars : list of str, optional Data variables to keep and require; None keeps all and requires SNR (:func:canvod.readers.validate_dataset).

Returns

xarray.Dataset canvodpy observations with provenance attributes.

Raises

ValueError If the result does not meet the observations contract.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def import_file(self, path: Path | str, keep_data_vars: list[str] | None = None) -> xr.Dataset:
    """Read one of the tool's observation files as canvodpy observations.

    Parameters
    ----------
    path : Path or str
        The tool's observation file.
    keep_data_vars : list of str, optional
        Data variables to keep and require; ``None`` keeps all and
        requires ``SNR`` (:func:`canvod.readers.validate_dataset`).

    Returns
    -------
    xarray.Dataset
        canvodpy observations with provenance attributes.

    Raises
    ------
    ValueError
        If the result does not meet the observations contract.
    """
    path = Path(path)
    ds = self.reader(path).to_ds(keep_data_vars=keep_data_vars)
    ds.attrs.update(Provenance.now(self.tool, "import", path.name).to_attrs())
    validate_dataset(ds, required_vars=keep_data_vars)
    return ds

export_file(obs_ds, path, source)

Write canvodpy observations as one of the tool's files.

Parameters

obs_ds : xarray.Dataset canvodpy observations. path : Path or str File to write. source : str What obs_ds is, recorded as provenance (e.g. a store group).

Returns

Path path.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def export_file(self, obs_ds: xr.Dataset, path: Path | str, source: str) -> Path:
    """Write canvodpy observations as one of the tool's files.

    Parameters
    ----------
    obs_ds : xarray.Dataset
        canvodpy observations.
    path : Path or str
        File to write.
    source : str
        What ``obs_ds`` is, recorded as provenance (e.g. a store group).

    Returns
    -------
    Path
        ``path``.
    """
    path = Path(path)
    tool_ds = self.convert_from_canvodpy(obs_ds)
    tool_ds.attrs.update(Provenance.now(self.tool, "export", source).to_attrs())
    self.save(tool_ds, path)
    return path

VodAdapter

Bases: _Adapter, ABC

VOD: the tool's VOD files <-> canvodpy VOD.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
class VodAdapter(_Adapter, ABC):
    """VOD: the tool's VOD files <-> canvodpy VOD."""

    @abstractmethod
    def open(self, path: Path) -> xr.Dataset:
        """Read one of the tool's VOD files in its own data structure."""

    @abstractmethod
    def save(self, tool_ds: xr.Dataset, path: Path) -> None:
        """Write a dataset in the tool's data structure to its file format."""

    @abstractmethod
    def convert_to_canvodpy(self, tool_ds: xr.Dataset) -> xr.Dataset:
        """VOD in the tool's data structure as canvodpy VOD."""

    @abstractmethod
    def convert_from_canvodpy(self, vod_ds: xr.Dataset) -> xr.Dataset:
        """canvodpy VOD in the tool's data structure."""

    def import_dataset(self, tool_ds: xr.Dataset, source: str) -> xr.Dataset:
        """The tool's VOD as canvodpy VOD.

        Parameters
        ----------
        tool_ds : xarray.Dataset
            VOD in the tool's data structure.
        source : str
            What ``tool_ds`` is, recorded as provenance (e.g. a file name).

        Returns
        -------
        xarray.Dataset
            canvodpy VOD with provenance attributes.

        Raises
        ------
        ValueError
            If the result does not meet the VOD dataset contract.
        """
        ds = self.convert_to_canvodpy(tool_ds)
        ds.attrs.update(Provenance.now(self.tool, "import", source).to_attrs())
        validate_vod_dataset(ds)
        return ds

    def import_file(self, path: Path | str) -> xr.Dataset:
        """Read one of the tool's VOD files as canvodpy VOD.

        See :meth:`import_dataset`.
        """
        path = Path(path)
        return self.import_dataset(self.open(path), source=path.name)

    def export_dataset(self, vod_ds: xr.Dataset, source: str) -> xr.Dataset:
        """canvodpy VOD in the tool's data structure, with provenance.

        Parameters
        ----------
        vod_ds : xarray.Dataset
            canvodpy VOD.
        source : str
            What ``vod_ds`` is, recorded as provenance (e.g. a store group).
        """
        tool_ds = self.convert_from_canvodpy(vod_ds)
        tool_ds.attrs.update(Provenance.now(self.tool, "export", source).to_attrs())
        return tool_ds

    def export_file(self, vod_ds: xr.Dataset, path: Path | str, source: str) -> Path:
        """Write canvodpy VOD as one of the tool's VOD files.

        See :meth:`export_dataset`. Returns ``path``.
        """
        path = Path(path)
        self.save(self.export_dataset(vod_ds, source), path)
        return path

open(path) abstractmethod

Read one of the tool's VOD files in its own data structure.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
107
108
109
@abstractmethod
def open(self, path: Path) -> xr.Dataset:
    """Read one of the tool's VOD files in its own data structure."""

save(tool_ds, path) abstractmethod

Write a dataset in the tool's data structure to its file format.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
111
112
113
@abstractmethod
def save(self, tool_ds: xr.Dataset, path: Path) -> None:
    """Write a dataset in the tool's data structure to its file format."""

convert_to_canvodpy(tool_ds) abstractmethod

VOD in the tool's data structure as canvodpy VOD.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
115
116
117
@abstractmethod
def convert_to_canvodpy(self, tool_ds: xr.Dataset) -> xr.Dataset:
    """VOD in the tool's data structure as canvodpy VOD."""

convert_from_canvodpy(vod_ds) abstractmethod

canvodpy VOD in the tool's data structure.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
119
120
121
@abstractmethod
def convert_from_canvodpy(self, vod_ds: xr.Dataset) -> xr.Dataset:
    """canvodpy VOD in the tool's data structure."""

import_dataset(tool_ds, source)

The tool's VOD as canvodpy VOD.

Parameters

tool_ds : xarray.Dataset VOD in the tool's data structure. source : str What tool_ds is, recorded as provenance (e.g. a file name).

Returns

xarray.Dataset canvodpy VOD with provenance attributes.

Raises

ValueError If the result does not meet the VOD dataset contract.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def import_dataset(self, tool_ds: xr.Dataset, source: str) -> xr.Dataset:
    """The tool's VOD as canvodpy VOD.

    Parameters
    ----------
    tool_ds : xarray.Dataset
        VOD in the tool's data structure.
    source : str
        What ``tool_ds`` is, recorded as provenance (e.g. a file name).

    Returns
    -------
    xarray.Dataset
        canvodpy VOD with provenance attributes.

    Raises
    ------
    ValueError
        If the result does not meet the VOD dataset contract.
    """
    ds = self.convert_to_canvodpy(tool_ds)
    ds.attrs.update(Provenance.now(self.tool, "import", source).to_attrs())
    validate_vod_dataset(ds)
    return ds

import_file(path)

Read one of the tool's VOD files as canvodpy VOD.

See :meth:import_dataset.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
148
149
150
151
152
153
154
def import_file(self, path: Path | str) -> xr.Dataset:
    """Read one of the tool's VOD files as canvodpy VOD.

    See :meth:`import_dataset`.
    """
    path = Path(path)
    return self.import_dataset(self.open(path), source=path.name)

export_dataset(vod_ds, source)

canvodpy VOD in the tool's data structure, with provenance.

Parameters

vod_ds : xarray.Dataset canvodpy VOD. source : str What vod_ds is, recorded as provenance (e.g. a store group).

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
156
157
158
159
160
161
162
163
164
165
166
167
168
def export_dataset(self, vod_ds: xr.Dataset, source: str) -> xr.Dataset:
    """canvodpy VOD in the tool's data structure, with provenance.

    Parameters
    ----------
    vod_ds : xarray.Dataset
        canvodpy VOD.
    source : str
        What ``vod_ds`` is, recorded as provenance (e.g. a store group).
    """
    tool_ds = self.convert_from_canvodpy(vod_ds)
    tool_ds.attrs.update(Provenance.now(self.tool, "export", source).to_attrs())
    return tool_ds

export_file(vod_ds, path, source)

Write canvodpy VOD as one of the tool's VOD files.

See :meth:export_dataset. Returns path.

Source code in packages/canvod-adapters/src/canvod/adapters/base.py
170
171
172
173
174
175
176
177
def export_file(self, vod_ds: xr.Dataset, path: Path | str, source: str) -> Path:
    """Write canvodpy VOD as one of the tool's VOD files.

    See :meth:`export_dataset`. Returns ``path``.
    """
    path = Path(path)
    self.save(self.export_dataset(vod_ds, source), path)
    return path

Provenance

Provenance of a conversion between canvodpy and another tool.

Tool

Bases: BaseModel

A program canvodpy exchanges data with.

Parameters

name : str Short name, also the model layer of imported VOD in the VOD store (e.g. "gnssvod"). distribution : str Name of its installed Python distribution, to record its version. url : str Where to find it.

Source code in packages/canvod-adapters/src/canvod/adapters/provenance.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Tool(BaseModel):
    """A program canvodpy exchanges data with.

    Parameters
    ----------
    name : str
        Short name, also the model layer of imported VOD in the VOD store
        (e.g. ``"gnssvod"``).
    distribution : str
        Name of its installed Python distribution, to record its version.
    url : str
        Where to find it.
    """

    model_config = ConfigDict(frozen=True)

    name: str
    distribution: str
    url: str

Provenance

Bases: BaseModel

Which tool data was converted from or to, by what, when.

Parameters

tool : str Name of the other tool. tool_url : str Where to find it. tool_version : str Its installed version at conversion time, or :data:NOT_INSTALLED. adapter_version : str Version of canvod-adapters. direction : {"import", "export"} "import": from the tool into canvodpy; "export": the reverse. source : str What was converted, e.g. a file name or a VOD store group. created : datetime When (UTC).

Source code in packages/canvod-adapters/src/canvod/adapters/provenance.py
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
class Provenance(BaseModel):
    """Which tool data was converted from or to, by what, when.

    Parameters
    ----------
    tool : str
        Name of the other tool.
    tool_url : str
        Where to find it.
    tool_version : str
        Its installed version at conversion time, or :data:`NOT_INSTALLED`.
    adapter_version : str
        Version of canvod-adapters.
    direction : {"import", "export"}
        ``"import"``: from the tool into canvodpy; ``"export"``: the reverse.
    source : str
        What was converted, e.g. a file name or a VOD store group.
    created : datetime
        When (UTC).
    """

    model_config = ConfigDict(frozen=True)

    tool: str
    tool_url: str
    tool_version: str
    adapter_version: str
    direction: Literal["import", "export"]
    source: str
    created: datetime

    @classmethod
    def now(cls, tool: Tool, direction: Literal["import", "export"], source: str) -> Provenance:
        """Provenance of a conversion running now, with installed versions."""
        return cls(
            tool=tool.name,
            tool_url=tool.url,
            tool_version=package_version(tool.distribution),
            adapter_version=package_version("canvod-adapters"),
            direction=direction,
            source=source,
            created=datetime.now(UTC),
        )

    def to_attrs(self) -> dict[str, str]:
        """Dataset attributes, each name prefixed with :data:`ATTR_PREFIX`."""
        return {
            f"{ATTR_PREFIX}{key}": str(value)
            for key, value in self.model_dump(mode="json").items()
        }

    @classmethod
    def from_attrs(cls, attrs: dict[str, Any]) -> Provenance:
        """Read provenance back from dataset attributes.

        Raises
        ------
        pydantic.ValidationError
            If the attributes hold no complete provenance.
        """
        return cls.model_validate(
            {
                key.removeprefix(ATTR_PREFIX): value
                for key, value in attrs.items()
                if key.startswith(ATTR_PREFIX)
            }
        )

now(tool, direction, source) classmethod

Provenance of a conversion running now, with installed versions.

Source code in packages/canvod-adapters/src/canvod/adapters/provenance.py
82
83
84
85
86
87
88
89
90
91
92
93
@classmethod
def now(cls, tool: Tool, direction: Literal["import", "export"], source: str) -> Provenance:
    """Provenance of a conversion running now, with installed versions."""
    return cls(
        tool=tool.name,
        tool_url=tool.url,
        tool_version=package_version(tool.distribution),
        adapter_version=package_version("canvod-adapters"),
        direction=direction,
        source=source,
        created=datetime.now(UTC),
    )

to_attrs()

Dataset attributes, each name prefixed with :data:ATTR_PREFIX.

Source code in packages/canvod-adapters/src/canvod/adapters/provenance.py
 95
 96
 97
 98
 99
100
def to_attrs(self) -> dict[str, str]:
    """Dataset attributes, each name prefixed with :data:`ATTR_PREFIX`."""
    return {
        f"{ATTR_PREFIX}{key}": str(value)
        for key, value in self.model_dump(mode="json").items()
    }

from_attrs(attrs) classmethod

Read provenance back from dataset attributes.

Raises

pydantic.ValidationError If the attributes hold no complete provenance.

Source code in packages/canvod-adapters/src/canvod/adapters/provenance.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
@classmethod
def from_attrs(cls, attrs: dict[str, Any]) -> Provenance:
    """Read provenance back from dataset attributes.

    Raises
    ------
    pydantic.ValidationError
        If the attributes hold no complete provenance.
    """
    return cls.model_validate(
        {
            key.removeprefix(ATTR_PREFIX): value
            for key, value in attrs.items()
            if key.startswith(ATTR_PREFIX)
        }
    )

package_version(distribution)

Installed version of distribution, or :data:NOT_INSTALLED.

A conversion need not have the other tool installed (e.g. importing a gnssvod NetCDF file), so a missing package is recorded, not an error.

Source code in packages/canvod-adapters/src/canvod/adapters/provenance.py
39
40
41
42
43
44
45
46
47
48
def package_version(distribution: str) -> str:
    """Installed version of ``distribution``, or :data:`NOT_INSTALLED`.

    A conversion need not have the other tool installed (e.g. importing a
    gnssvod NetCDF file), so a missing package is recorded, not an error.
    """
    try:
        return importlib_metadata.version(distribution)
    except importlib_metadata.PackageNotFoundError:
        return NOT_INSTALLED

VOD store I/O

Requires the store extra.

VOD store I/O through any :class:~canvod.adapters.base.VodAdapter.

Requires the store extra (canvod-adapters[store]).

import_vod(adapter, path, store, analysis_name, *, branch='main', dedup=True)

Import one of a tool's VOD files into a canvodpy VOD store.

The data goes to the group {tool}/{analysis_name}: the tool takes the place of the VOD model, so imported VOD stays apart from VOD that canvodpy computed.

Parameters

adapter : VodAdapter Adapter of the tool that wrote the file. path : Path or str The tool's VOD file. store : MyIcechunkStore Destination VOD store (e.g. site.vod_store). analysis_name : str Analysis the VOD belongs to (e.g. "canopy_01_vs_reference_01"). branch : str, default "main" Icechunk branch to write to. dedup : bool, default True Skip the write if this file was already imported, or if its epochs overlap data already in the group.

Returns

bool True if written, False if skipped as a duplicate.

Raises

ValueError If the converted data does not meet the VOD dataset contract.

Source code in packages/canvod-adapters/src/canvod/adapters/store.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def import_vod(
    adapter: VodAdapter,
    path: Path | str,
    store: MyIcechunkStore,
    analysis_name: str,
    *,
    branch: str = "main",
    dedup: bool = True,
) -> bool:
    """Import one of a tool's VOD files into a canvodpy VOD store.

    The data goes to the group ``{tool}/{analysis_name}``: the tool takes
    the place of the VOD model, so imported VOD stays apart from VOD that
    canvodpy computed.

    Parameters
    ----------
    adapter : VodAdapter
        Adapter of the tool that wrote the file.
    path : Path or str
        The tool's VOD file.
    store : MyIcechunkStore
        Destination VOD store (e.g. ``site.vod_store``).
    analysis_name : str
        Analysis the VOD belongs to (e.g. ``"canopy_01_vs_reference_01"``).
    branch : str, default "main"
        Icechunk branch to write to.
    dedup : bool, default True
        Skip the write if this file was already imported, or if its epochs
        overlap data already in the group.

    Returns
    -------
    bool
        ``True`` if written, ``False`` if skipped as a duplicate.

    Raises
    ------
    ValueError
        If the converted data does not meet the VOD dataset contract.
    """
    path = Path(path)
    ds = adapter.import_file(path)
    tool = adapter.tool.name
    return store.write_or_append_vod_group(
        ds,
        f"{tool}/{analysis_name}",
        source_file_hashes={tool: file_hash(path)},
        source_gnss_stores={tool: str(path)},
        calculator_name=tool,
        branch=branch,
        commit_message=f"Imported {tool} VOD from {path.name}",
        dedup=dedup,
    )

export_vod(adapter, store, group_name, path, *, branch='main')

Export a VOD store group as one of a tool's VOD files.

Parameters

adapter : VodAdapter Adapter of the tool to write for. store : MyIcechunkStore Source VOD store (e.g. site.vod_store). group_name : str VOD group, {model}/{analysis_name} (e.g. "tau_omega_zeroth_order/canopy_01_vs_reference_01"). path : Path or str File to write. branch : str, default "main" Icechunk branch to read from.

Returns

Path path.

Source code in packages/canvod-adapters/src/canvod/adapters/store.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def export_vod(
    adapter: VodAdapter,
    store: MyIcechunkStore,
    group_name: str,
    path: Path | str,
    *,
    branch: str = "main",
) -> Path:
    """Export a VOD store group as one of a tool's VOD files.

    Parameters
    ----------
    adapter : VodAdapter
        Adapter of the tool to write for.
    store : MyIcechunkStore
        Source VOD store (e.g. ``site.vod_store``).
    group_name : str
        VOD group, ``{model}/{analysis_name}`` (e.g.
        ``"tau_omega_zeroth_order/canopy_01_vs_reference_01"``).
    path : Path or str
        File to write.
    branch : str, default "main"
        Icechunk branch to read from.

    Returns
    -------
    Path
        ``path``.
    """
    vod_ds = store.read_group(group_name, branch=branch).load()
    return adapter.export_file(vod_ds, path, source=group_name)

gnssvod

Adapters between canvodpy and gnssvod (Humphrey et al.).

GnssvodObservations

Bases: ObservationsAdapter

gnssvod observation files <-> canvodpy observations.

Parameters

time_system : str Time scale of the epochs of the gnssvod files to import, as in the RINEX files they came from (e.g. "GPS"); see :class:~canvod.adapters.gnssvod.reader.GnssvodObsReader.

Examples

adapter = GnssvodObservations(time_system="GPS") obs = adapter.import_file("ROSA_obs.nc") adapter.export_file(obs, "back_obs.nc", source="ROSA_obs.nc")

Source code in packages/canvod-adapters/src/canvod/adapters/gnssvod/adapter.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class GnssvodObservations(ObservationsAdapter):
    """gnssvod observation files <-> canvodpy observations.

    Parameters
    ----------
    time_system : str
        Time scale of the epochs of the gnssvod files to import, as in the
        RINEX files they came from (e.g. ``"GPS"``); see
        :class:`~canvod.adapters.gnssvod.reader.GnssvodObsReader`.

    Examples
    --------
    >>> adapter = GnssvodObservations(time_system="GPS")
    >>> obs = adapter.import_file("ROSA_obs.nc")
    >>> adapter.export_file(obs, "back_obs.nc", source="ROSA_obs.nc")
    """

    tool: ClassVar[Tool] = GNSSVOD

    time_system: str

    def reader(self, path: Path) -> GnssvodObsReader:
        return GnssvodObsReader(fpath=path, time_system=self.time_system)

    def convert_from_canvodpy(self, obs_ds: xr.Dataset) -> xr.Dataset:
        return to_gnssvod_observations(obs_ds)

    def save(self, tool_ds: xr.Dataset, path: Path) -> None:
        tool_ds.to_netcdf(path)

GnssvodVod

Bases: VodAdapter

gnssvod VOD files <-> canvodpy VOD.

A gnssvod VOD file is the result of gnssvod.calc_vod for one station pair (DataFrame.to_xarray()) written as NetCDF: dims Epoch, SV, one variable per band, Azimuth/Elevation.

Parameters

bands : dict[str, list[str]] gnssvod's bands argument: band variable to the signal-strength observation codes calc_vod merges into it, e.g. {"VOD_L1": ["S1C", "S1W"], "VOD_L2": ["S2W"]}. Importing gives each band's VOD the sids of its code if the band has one code ("S2W" -> "G01|L2|W"), and of its RINEX 2 form if it merges several ("S1" -> "G01|L1|u", canvodpy's marker for a band whose tracking code is not known). Exporting merges canvodpy's VOD the same way as calc_vod, a merged band also taking the sids an import gives it, so imported VOD exports unchanged.

Notes

calc_vod reports the reference receiver's Azimuth/ Elevation; canvodpy reports the canopy receiver's. Imported VOD keeps gnssvod's angles.

Examples

adapter = GnssvodVod(bands={"VOD_L1": ["S1C", "S1W"]}) vod = adapter.import_file("vod_canopy_vs_reference.nc")

Source code in packages/canvod-adapters/src/canvod/adapters/gnssvod/adapter.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
class GnssvodVod(VodAdapter):
    """gnssvod VOD files <-> canvodpy VOD.

    A gnssvod VOD file is the result of ``gnssvod.calc_vod`` for one
    station pair (``DataFrame.to_xarray()``) written as NetCDF: dims
    ``Epoch``, ``SV``, one variable per band, ``Azimuth``/``Elevation``.

    Parameters
    ----------
    bands : dict[str, list[str]]
        gnssvod's ``bands`` argument: band variable to the signal-strength
        observation codes ``calc_vod`` merges into it, e.g.
        ``{"VOD_L1": ["S1C", "S1W"], "VOD_L2": ["S2W"]}``. Importing gives
        each band's VOD the sids of its code if the band has one code
        (``"S2W"`` -> ``"G01|L2|W"``), and of its RINEX 2 form if it merges
        several (``"S1"`` -> ``"G01|L1|u"``, canvodpy's marker for a band
        whose tracking code is not known). Exporting merges canvodpy's VOD
        the same way as ``calc_vod``, a merged band also taking the sids
        an import gives it, so imported VOD exports unchanged.

    Notes
    -----
    ``calc_vod`` reports the reference receiver's ``Azimuth``/
    ``Elevation``; canvodpy reports the canopy receiver's. Imported VOD
    keeps gnssvod's angles.

    Examples
    --------
    >>> adapter = GnssvodVod(bands={"VOD_L1": ["S1C", "S1W"]})
    >>> vod = adapter.import_file("vod_canopy_vs_reference.nc")
    """

    tool: ClassVar[Tool] = GNSSVOD

    bands: dict[str, list[str]]

    @field_validator("bands")
    @classmethod
    def _check_bands(cls, bands: dict[str, list[str]]) -> dict[str, list[str]]:
        if not bands:
            raise ValueError("bands is empty")
        for band, codes in bands.items():
            if not codes:
                raise ValueError(f"band {band!r} has no observation codes")
            for code in codes:
                if len(code) not in (2, 3) or code[0] != "S" or code[1] not in _FREQUENCY_DIGITS:
                    raise ValueError(
                        f"band {band!r}: {code!r} is not a signal-strength "
                        "observation code (e.g. 'S1C', or 'S1' in RINEX 2)"
                    )
            if len({code[1] for code in codes}) > 1:
                raise ValueError(f"band {band!r} merges codes of different frequencies: {codes}")
        imported = [cls._import_code(codes) for codes in bands.values()]
        if len(set(imported)) < len(imported):
            raise ValueError(
                f"two bands would import to the same signals: {dict(zip(bands, imported, strict=True))}"
            )
        return bands

    @staticmethod
    def _import_code(codes: list[str]) -> str:
        """Observation code the sids of an imported band get."""
        return codes[0] if len(codes) == 1 else codes[0][:2]

    def open(self, path: Path) -> xr.Dataset:
        return _netcdf(path)

    def save(self, tool_ds: xr.Dataset, path: Path) -> None:
        tool_ds.to_netcdf(path)

    def convert_to_canvodpy(self, tool_ds: xr.Dataset) -> xr.Dataset:
        return from_gnssvod_vod(
            tool_ds,
            {band: self._import_code(codes) for band, codes in self.bands.items()},
        )

    def convert_from_canvodpy(self, vod_ds: xr.Dataset) -> xr.Dataset:
        # A merged band also takes the sids an import gives it ("S1" ->
        # "G01|L1|u"), so imported VOD exports as it came in
        bands = {
            band: sorted({*codes, self._import_code(codes)}) for band, codes in self.bands.items()
        }
        return to_gnssvod_vod(vod_ds, bands)

canvodpy reader of gnssvod observation files.

GnssvodObsReader

Bases: GNSSDataReader

Reads the observation NetCDF files gnssvod.preprocess writes.

A canvodpy reader like the RINEX and SBF readers: its datasets meet the reader contract (:func:canvod.readers.validate_dataset), carry the file hash for deduplication and the global attributes of every canvodpy reader.

Parameters

fpath : Path gnssvod observation file (dims Epoch, SV). time_system : str Time scale of the file's epochs, which gnssvod keeps as in the RINEX file (its TIME OF FIRST OBS record), e.g. "GPS". Recorded on the epoch coordinate, like the other readers do.

Source code in packages/canvod-adapters/src/canvod/adapters/gnssvod/reader.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
class GnssvodObsReader(GNSSDataReader):
    """Reads the observation NetCDF files ``gnssvod.preprocess`` writes.

    A canvodpy reader like the RINEX and SBF readers: its datasets meet
    the reader contract (:func:`canvod.readers.validate_dataset`), carry
    the file hash for deduplication and the global attributes of every
    canvodpy reader.

    Parameters
    ----------
    fpath : Path
        gnssvod observation file (dims ``Epoch``, ``SV``).
    time_system : str
        Time scale of the file's epochs, which gnssvod keeps as in the
        RINEX file (its ``TIME OF FIRST OBS`` record), e.g. ``"GPS"``.
        Recorded on the epoch coordinate, like the other readers do.
    """

    time_system: str

    @property
    def source_format(self) -> str:
        return "gnssvod"

    @cached_property
    def _gnssvod_ds(self) -> xr.Dataset:
        with xr.open_dataset(self.fpath) as ds:
            return ds.load()

    @cached_property
    def _file_hash(self) -> str:
        return file_hash(self.fpath)

    @property
    def file_hash(self) -> str:
        return self._file_hash

    def to_ds(
        self,
        keep_data_vars: list[str] | None = None,
        **kwargs: object,
    ) -> xr.Dataset:
        """The file's observations as a canvodpy dataset.

        Parameters
        ----------
        keep_data_vars : list of str, optional
            Data variables to keep (e.g. ``["SNR"]``); ``None`` keeps all.
        **kwargs
            Not used; accepted for the reader interface.

        Raises
        ------
        ValueError
            If the result does not meet the reader contract.
        """
        ds = from_gnssvod_observations(self._gnssvod_ds)
        if keep_data_vars is not None:
            ds = ds[[v for v in keep_data_vars if v in ds.data_vars]]
        ds["epoch"].attrs.update(epoch_coord_attrs(self.time_system))
        ds.attrs.update(self._build_attrs())
        validate_dataset(ds, required_vars=keep_data_vars)
        return ds

    def iter_epochs(self) -> Iterator[xr.Dataset]:
        """The file's observations, one epoch at a time."""
        ds = self.to_ds()
        for i in range(ds.sizes["epoch"]):
            yield ds.isel(epoch=i)

    def _epochs(self) -> np.ndarray:
        return np.sort(self._gnssvod_ds["Epoch"].values)

    @property
    def start_time(self) -> datetime:
        return self._epochs()[0].astype("datetime64[us]").item()

    @property
    def end_time(self) -> datetime:
        return self._epochs()[-1].astype("datetime64[us]").item()

    @property
    def num_epochs(self) -> int:
        return int(self._gnssvod_ds.sizes["Epoch"])

    @property
    def systems(self) -> list[str]:
        return sorted({str(sv)[0] for sv in self._gnssvod_ds["SV"].values})

    @property
    def num_satellites(self) -> int:
        return int(self._gnssvod_ds.sizes["SV"])

to_ds(keep_data_vars=None, **kwargs)

The file's observations as a canvodpy dataset.

Parameters

keep_data_vars : list of str, optional Data variables to keep (e.g. ["SNR"]); None keeps all. **kwargs Not used; accepted for the reader interface.

Raises

ValueError If the result does not meet the reader contract.

Source code in packages/canvod-adapters/src/canvod/adapters/gnssvod/reader.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def to_ds(
    self,
    keep_data_vars: list[str] | None = None,
    **kwargs: object,
) -> xr.Dataset:
    """The file's observations as a canvodpy dataset.

    Parameters
    ----------
    keep_data_vars : list of str, optional
        Data variables to keep (e.g. ``["SNR"]``); ``None`` keeps all.
    **kwargs
        Not used; accepted for the reader interface.

    Raises
    ------
    ValueError
        If the result does not meet the reader contract.
    """
    ds = from_gnssvod_observations(self._gnssvod_ds)
    if keep_data_vars is not None:
        ds = ds[[v for v in keep_data_vars if v in ds.data_vars]]
    ds["epoch"].attrs.update(epoch_coord_attrs(self.time_system))
    ds.attrs.update(self._build_attrs())
    validate_dataset(ds, required_vars=keep_data_vars)
    return ds

iter_epochs()

The file's observations, one epoch at a time.

Source code in packages/canvod-adapters/src/canvod/adapters/gnssvod/reader.py
81
82
83
84
85
def iter_epochs(self) -> Iterator[xr.Dataset]:
    """The file's observations, one epoch at a time."""
    ds = self.to_ds()
    for i in range(ds.sizes["epoch"]):
        yield ds.isel(epoch=i)

Conversion between canvodpy and gnssvod (Humphrey et al.) data.

gnssvod Observations: dims (Epoch, SV), one variable per RINEX observation code (S1C, C1C, L1C, D1C; RINEX 2: S1, P2, ...), plus Azimuth and Elevation in degrees (Observation objects, gnssvod.preprocess). VOD: dims (Epoch, SV), one variable per band named in the bands argument of gnssvod.calc_vod, which merges the band's observation codes present in the data in sorted order (numpy.intersect1d), each filling the gaps of the previous.

canvodpy dims (epoch, sid), sid "SV|BAND|CODE" with the coordinates sv, system, band, code and the band frequencies; SNR, Pseudorange, Phase, Doppler, VOD; theta (polar angle from zenith) and phi (azimuth from North, clockwise) in radians.

Signal IDs and observation codes are mapped with :func:canvod.readers.gnss_specs.obs_codes.sid_for_obs_code and :func:~canvod.readers.gnss_specs.obs_codes.obs_code_for_sid, the rules of canvodpy's RINEX readers. Angles: Elevation = 90 - degrees(theta), Azimuth = degrees(phi) mod 360.

to_gnssvod_observations(ds)

canvodpy observations as gnssvod observations.

Parameters

ds : xarray.Dataset canvodpy receiver dataset: dims (epoch, sid), an sv coordinate, any of SNR/Pseudorange/Phase/Doppler, optionally theta/phi.

Returns

xarray.Dataset dims (Epoch, SV): one variable per observation code with data, e.g. S1C, for the satellites with data; Azimuth/ Elevation in degrees. All-NaN sids (canvodpy pads to a global sid set) are left out.

Raises

ValueError If two sids of a satellite map to the same observation code (RINEX 2 P2 and C2 are both C2 in RINEX 3 terms).

Source code in packages/canvod-adapters/src/canvod/adapters/gnssvod/convert.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def to_gnssvod_observations(ds: xr.Dataset) -> xr.Dataset:
    """canvodpy observations as gnssvod observations.

    Parameters
    ----------
    ds : xarray.Dataset
        canvodpy receiver dataset: dims ``(epoch, sid)``, an ``sv``
        coordinate, any of ``SNR``/``Pseudorange``/``Phase``/``Doppler``,
        optionally ``theta``/``phi``.

    Returns
    -------
    xarray.Dataset
        dims ``(Epoch, SV)``: one variable per observation code with data,
        e.g. ``S1C``, for the satellites with data; ``Azimuth``/
        ``Elevation`` in degrees. All-NaN sids (canvodpy pads to a global
        sid set) are left out.

    Raises
    ------
    ValueError
        If two sids of a satellite map to the same observation code
        (RINEX 2 ``P2`` and ``C2`` are both ``C2`` in RINEX 3 terms).
    """
    ds = ds.transpose("epoch", "sid")
    svs = _sv_of_sids(ds)
    observables = [name for name in OBS_TYPES if name in ds.data_vars]
    has_data = np.zeros(ds.sizes["sid"], dtype=bool)
    for name in observables:
        has_data |= ~np.all(np.isnan(np.asarray(ds[name].values, dtype=np.float64)), axis=0)
    sv_index = sorted(set(svs[has_data]))
    sv_col = {sv: j for j, sv in enumerate(sv_index)}
    data_vars: dict[str, tuple] = {}
    for name, obs_type in OBS_TYPES.items():
        source: dict[tuple[str, str], str] = {}
        if name not in ds.data_vars:
            continue
        values = np.asarray(ds[name].values, dtype=np.float64)
        sids = _strings(ds["sid"].values)
        # canvodpy pads to a global sid set: an all-NaN sid is no signal
        for i in np.flatnonzero(~np.all(np.isnan(values), axis=0)):
            sid = sids[i]
            code = obs_code_for_sid(sid, obs_type)
            if (code, svs[i]) in source:
                msg = (
                    f"sids {source[code, svs[i]]!r} and {sid!r} both map to "
                    f"observation code {code!r}"
                )
                raise ValueError(msg)
            source[code, svs[i]] = sid
            if code not in data_vars:
                data_vars[code] = (
                    ("Epoch", "SV"),
                    np.full((values.shape[0], len(sv_index)), np.nan),
                )
            data_vars[code][1][:, sv_col[svs[i]]] = values[:, i]
    data_vars.update(_angles_to_gnssvod(ds, svs, sv_index))
    return xr.Dataset(
        data_vars,
        coords={"Epoch": ds["epoch"].values, "SV": np.array(sv_index, dtype=object)},
    )

from_gnssvod_observations(gnssvod_ds)

gnssvod observations as canvodpy observations.

Parameters

gnssvod_ds : xarray.Dataset gnssvod observations, dims (Epoch, SV) (e.g. Observation.observation.to_xarray()).

Returns

xarray.Dataset dims (epoch, sid) with the sid coordinates; SNR, Pseudorange, Phase, Doppler as present; theta/phi if Elevation/Azimuth are present. Only satellite/code columns with data become sids.

Raises

ValueError If a satellite has data for a code its system has no band for.

Source code in packages/canvod-adapters/src/canvod/adapters/gnssvod/convert.py
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
def from_gnssvod_observations(gnssvod_ds: xr.Dataset) -> xr.Dataset:
    """gnssvod observations as canvodpy observations.

    Parameters
    ----------
    gnssvod_ds : xarray.Dataset
        gnssvod observations, dims ``(Epoch, SV)`` (e.g.
        ``Observation.observation.to_xarray()``).

    Returns
    -------
    xarray.Dataset
        dims ``(epoch, sid)`` with the sid coordinates; ``SNR``,
        ``Pseudorange``, ``Phase``, ``Doppler`` as present; ``theta``/``phi``
        if ``Elevation``/``Azimuth`` are present. Only satellite/code
        columns with data become sids.

    Raises
    ------
    ValueError
        If a satellite has data for a code its system has no band for.
    """
    ds = _gnssvod_dims(gnssvod_ds).transpose("Epoch", "SV")
    svs = [str(sv) for sv in ds["SV"].values]
    columns: dict[str, dict[str, tuple[int, np.ndarray]]] = {}
    for code in ds.data_vars:
        code = str(code)
        if len(code) not in (2, 3) or code[0] not in _OBSERVABLES:
            continue
        values = np.asarray(ds[code].values, dtype=np.float64)
        per_sid = columns.setdefault(_OBSERVABLES[code[0]], {})
        for j, sid in _sids_of_code(svs, code, values).items():
            per_sid[sid] = (j, values[:, j])
    return _to_canvodpy(ds, columns)

to_gnssvod_vod(vod_ds, bands)

canvodpy VOD as gnssvod VOD, merged per band like gnssvod.calc_vod.

Parameters

vod_ds : xarray.Dataset canvodpy VOD dataset: dims (epoch, sid), an sv coordinate, VOD, optionally theta/phi. bands : dict[str, list[str]] gnssvod's bands argument: band name to the signal-strength observation codes it merges, e.g. {"VOD_L1": ["S1C", "S1W"]}.

Returns

xarray.Dataset dims (Epoch, SV): one variable per band; Azimuth/ Elevation in degrees. canvodpy's angles are the canopy receiver's; calc_vod reports the reference receiver's.

Source code in packages/canvod-adapters/src/canvod/adapters/gnssvod/convert.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
def to_gnssvod_vod(vod_ds: xr.Dataset, bands: dict[str, list[str]]) -> xr.Dataset:
    """canvodpy VOD as gnssvod VOD, merged per band like ``gnssvod.calc_vod``.

    Parameters
    ----------
    vod_ds : xarray.Dataset
        canvodpy VOD dataset: dims ``(epoch, sid)``, an ``sv`` coordinate,
        ``VOD``, optionally ``theta``/``phi``.
    bands : dict[str, list[str]]
        gnssvod's ``bands`` argument: band name to the signal-strength
        observation codes it merges, e.g. ``{"VOD_L1": ["S1C", "S1W"]}``.

    Returns
    -------
    xarray.Dataset
        dims ``(Epoch, SV)``: one variable per band; ``Azimuth``/
        ``Elevation`` in degrees. canvodpy's angles are the canopy
        receiver's; ``calc_vod`` reports the reference receiver's.
    """
    obs = to_gnssvod_observations(vod_ds[["VOD"]].rename({"VOD": "SNR"}))
    data_vars: dict[str, tuple] = {}
    for band, codes in bands.items():
        merged = np.full((obs.sizes["Epoch"], obs.sizes["SV"]), np.nan)
        for code in np.intersect1d([str(v) for v in obs.data_vars], codes):
            gap = np.isnan(merged)
            merged[gap] = np.asarray(obs[code].values)[gap]
        data_vars[band] = (("Epoch", "SV"), merged)
    svs = _sv_of_sids(vod_ds)
    data_vars.update(
        _angles_to_gnssvod(
            vod_ds.transpose("epoch", "sid"), svs, [str(sv) for sv in obs["SV"].values]
        )
    )
    return xr.Dataset(data_vars, coords=obs.coords)

from_gnssvod_vod(gnssvod_ds, bands)

gnssvod VOD as canvodpy VOD.

Parameters

gnssvod_ds : xarray.Dataset gnssvod VOD, dims (Epoch, SV) (e.g. the calc_vod result converted with DataFrame.to_xarray()). bands : dict[str, str] Band variable to the observation code its sids get. gnssvod merges the codes of a band, so give the RINEX 2 form for a merged band, e.g. {"VOD_L1": "S1"}: the sid is "G01|L1|u", canvodpy's marker for a band whose tracking code is not known.

Returns

xarray.Dataset dims (epoch, sid) with the sid coordinates; VOD; theta/phi if Elevation/Azimuth are present. Only satellite/band columns with data become sids.

Raises

ValueError If a band variable is missing, or a satellite has VOD for a band its system does not have.

Source code in packages/canvod-adapters/src/canvod/adapters/gnssvod/convert.py
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
def from_gnssvod_vod(gnssvod_ds: xr.Dataset, bands: dict[str, str]) -> xr.Dataset:
    """gnssvod VOD as canvodpy VOD.

    Parameters
    ----------
    gnssvod_ds : xarray.Dataset
        gnssvod VOD, dims ``(Epoch, SV)`` (e.g. the ``calc_vod`` result
        converted with ``DataFrame.to_xarray()``).
    bands : dict[str, str]
        Band variable to the observation code its sids get. gnssvod merges
        the codes of a band, so give the RINEX 2 form for a merged band,
        e.g. ``{"VOD_L1": "S1"}``: the sid is ``"G01|L1|u"``, canvodpy's
        marker for a band whose tracking code is not known.

    Returns
    -------
    xarray.Dataset
        dims ``(epoch, sid)`` with the sid coordinates; ``VOD``;
        ``theta``/``phi`` if ``Elevation``/``Azimuth`` are present. Only
        satellite/band columns with data become sids.

    Raises
    ------
    ValueError
        If a band variable is missing, or a satellite has VOD for a band
        its system does not have.
    """
    ds = _gnssvod_dims(gnssvod_ds).transpose("Epoch", "SV")
    missing = [band for band in bands if band not in ds.data_vars]
    if missing:
        msg = f"Band variables {missing} not in the gnssvod dataset"
        raise ValueError(msg)
    svs = [str(sv) for sv in ds["SV"].values]
    per_sid: dict[str, tuple[int, np.ndarray]] = {}
    for band, code in bands.items():
        values = np.asarray(ds[band].values, dtype=np.float64)
        for j, sid in _sids_of_code(svs, code, values).items():
            per_sid[sid] = (j, values[:, j])
    return _to_canvodpy(ds, {"VOD": per_sid})

See the Overview for usage.