Skip to content

canvod.utils API Reference

Shared utilities: configuration management, date handling, and CLI tools.

Configuration

Configuration management for canvodpy.

This package provides: - Pydantic models for type-safe configuration - YAML-based configuration loading - CLI for configuration management - Validation and error reporting

Examples

from canvod.utils.config import load_config config = load_config() print(config.nasa_earthdata_acc_mail) print(config.processing.aux_data.agency)

CanvodConfig

Bases: BaseModel

Complete canvodpy configuration.

This is the top-level configuration object that combines all configuration sections. It's fully serializable and can be used for local development (YAML files) or API-based configuration.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
class CanvodConfig(BaseModel):
    """
    Complete canvodpy configuration.

    This is the top-level configuration object that combines all
    configuration sections. It's fully serializable and can be used
    for local development (YAML files) or API-based configuration.
    """

    processing: ProcessingConfig
    sites: SitesConfig
    sids: SidsConfig

    model_config = {"extra": "forbid"}  # Catch typos in config files!

    @property
    def nasa_earthdata_acc_mail(self) -> str | None:
        """Return the configured NASA Earthdata email for CDDIS authentication.

        Returns
        -------
        str | None
            NASA Earthdata email address.
        """
        return self.processing.credentials.nasa_earthdata_acc_mail

nasa_earthdata_acc_mail property

Return the configured NASA Earthdata email for CDDIS authentication.

Returns

str | None NASA Earthdata email address.

MetadataConfig

Bases: BaseModel

Metadata to be written to processed files.

Notes

This is a Pydantic model for configuration validation.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
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
class MetadataConfig(BaseModel):
    """Metadata to be written to processed files.

    Notes
    -----
    This is a Pydantic model for configuration validation.
    """

    author: str = Field(..., description="Author name")
    email: EmailStr = Field(..., description="Author email")
    orcid: str | None = Field(None, description="ORCID identifier")
    institution: str = Field(..., description="Institution name")
    institution_ror: str | None = Field(None, description="ROR identifier")
    department: str | None = Field(None, description="Department name")
    research_group: str | None = Field(
        None,
        description="Research group name",
    )
    website: str | None = Field(
        None,
        description="Institution/group website",
    )
    license: str | None = Field(None, description="SPDX license identifier")
    publisher: str | None = Field(None, description="Publisher name")
    publisher_url: str | None = Field(None, description="Publisher URL")
    naming_authority: str | None = Field(None, description="Naming authority URI")

    def to_attrs_dict(self) -> dict[str, str]:
        """Convert to a dictionary for xarray attributes.

        Returns
        -------
        dict[str, str]
            Metadata as xarray-compatible attributes.
        """
        attrs = {
            "author": self.author,
            "email": self.email,
            "institution": self.institution,
        }
        if self.department:
            attrs["department"] = self.department
        if self.research_group:
            attrs["research_group"] = self.research_group
        if self.website:
            attrs["website"] = self.website
        return attrs

to_attrs_dict()

Convert to a dictionary for xarray attributes.

Returns

dict[str, str] Metadata as xarray-compatible attributes.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def to_attrs_dict(self) -> dict[str, str]:
    """Convert to a dictionary for xarray attributes.

    Returns
    -------
    dict[str, str]
        Metadata as xarray-compatible attributes.
    """
    attrs = {
        "author": self.author,
        "email": self.email,
        "institution": self.institution,
    }
    if self.department:
        attrs["department"] = self.department
    if self.research_group:
        attrs["research_group"] = self.research_group
    if self.website:
        attrs["website"] = self.website
    return attrs

ProcessingConfig

Bases: BaseModel

Complete processing configuration.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
class ProcessingConfig(BaseModel):
    """Complete processing configuration."""

    metadata: MetadataConfig
    credentials: CredentialsConfig = Field(
        default_factory=CredentialsConfig,
        description="Credentials for external data services",
    )
    aux_data: AuxDataConfig = Field(default_factory=AuxDataConfig)
    processing: ProcessingParams = Field(default_factory=ProcessingParams)
    compression: CompressionConfig = Field(default_factory=CompressionConfig)
    icechunk: IcechunkConfig = Field(default_factory=IcechunkConfig)
    storage: StorageConfig = Field(default_factory=StorageConfig)
    logging: LoggingConfig = Field(default_factory=LoggingConfig)
    preprocessing: PreprocessingConfig = Field(
        default_factory=PreprocessingConfig,
    )
    references: ReferencesConfig = Field(
        default_factory=ReferencesConfig,
        description="Publication and funding references",
    )

SiteConfig

Bases: BaseModel

Research site configuration.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
class SiteConfig(BaseModel):
    """Research site configuration."""

    gnss_site_data_root: str = Field(
        ..., description="Root directory for site GNSS data"
    )
    description: str | None = Field(None, description="Site description")
    country: str | None = Field(None, description="Country code (ISO 3166-1)")
    latitude: float | None = Field(None, description="WGS84 latitude")
    longitude: float | None = Field(None, description="WGS84 longitude")
    altitude_m: float | None = Field(None, description="Altitude in meters")
    receivers: dict[str, ReceiverConfig] = Field(..., description="Site receivers")
    vod_analyses: dict[str, VodAnalysisConfig] | None = Field(
        None,
        description="VOD analysis pairs",
    )
    naming: dict | None = Field(
        None,
        description="Naming configuration (validated by canvod-virtualiconvname package)",
    )

    @model_validator(mode="after")
    def validate_scs_from_targets(self) -> SiteConfig:
        """Validate that scs_from entries reference existing canopy receivers."""
        canopy_names = self.get_canopy_receiver_names()
        for name, cfg in self.receivers.items():
            if cfg.type != "reference" or cfg.scs_from is None:
                continue
            if isinstance(cfg.scs_from, str) and cfg.scs_from == "all":
                continue
            targets = cfg.scs_from if isinstance(cfg.scs_from, list) else [cfg.scs_from]
            for target in targets:
                if target not in canopy_names:
                    msg = (
                        f"Receiver '{name}' scs_from references '{target}' "
                        f"which is not a canopy receiver. "
                        f"Available canopy receivers: {canopy_names}"
                    )
                    raise ValueError(msg)
        return self

    def get_base_path(self) -> Path:
        """Get gnss_site_data_root as a Path.

        Returns
        -------
        Path
            Site data root directory as a Path object.
        """
        return Path(self.gnss_site_data_root)

    def get_canopy_receiver_names(self) -> list[str]:
        """Get names of all canopy receivers.

        Returns
        -------
        list[str]
            Canopy receiver names.
        """
        return [name for name, cfg in self.receivers.items() if cfg.type == "canopy"]

    def resolve_scs_from(self, receiver_name: str) -> list[str]:
        """Resolve scs_from for a reference receiver to a list of canopy names.

        Parameters
        ----------
        receiver_name : str
            Name of the reference receiver.

        Returns
        -------
        list[str]
            List of canopy receiver names for SCS computation.
        """
        cfg = self.receivers[receiver_name]
        if cfg.type != "reference":
            msg = f"resolve_scs_from only applies to reference receivers, got '{cfg.type}'"
            raise ValueError(msg)
        if cfg.scs_from == "all":
            return self.get_canopy_receiver_names()
        if isinstance(cfg.scs_from, list):
            return cfg.scs_from
        # Single canopy name as string
        if cfg.scs_from is None:
            msg = f"Receiver '{receiver_name}' has no scs_from configured"
            raise ValueError(msg)
        return [cfg.scs_from]

    def get_reference_canopy_pairs(self) -> list[tuple[str, str]]:
        """Expand scs_from into (reference_name, canopy_name) pairs.

        Returns
        -------
        list[tuple[str, str]]
            List of (reference_name, canopy_name) pairs.
        """
        pairs = []
        for name, cfg in self.receivers.items():
            if cfg.type != "reference":
                continue
            for canopy_name in self.resolve_scs_from(name):
                pairs.append((name, canopy_name))
        return pairs

validate_scs_from_targets()

Validate that scs_from entries reference existing canopy receivers.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
@model_validator(mode="after")
def validate_scs_from_targets(self) -> SiteConfig:
    """Validate that scs_from entries reference existing canopy receivers."""
    canopy_names = self.get_canopy_receiver_names()
    for name, cfg in self.receivers.items():
        if cfg.type != "reference" or cfg.scs_from is None:
            continue
        if isinstance(cfg.scs_from, str) and cfg.scs_from == "all":
            continue
        targets = cfg.scs_from if isinstance(cfg.scs_from, list) else [cfg.scs_from]
        for target in targets:
            if target not in canopy_names:
                msg = (
                    f"Receiver '{name}' scs_from references '{target}' "
                    f"which is not a canopy receiver. "
                    f"Available canopy receivers: {canopy_names}"
                )
                raise ValueError(msg)
    return self

get_base_path()

Get gnss_site_data_root as a Path.

Returns

Path Site data root directory as a Path object.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
813
814
815
816
817
818
819
820
821
def get_base_path(self) -> Path:
    """Get gnss_site_data_root as a Path.

    Returns
    -------
    Path
        Site data root directory as a Path object.
    """
    return Path(self.gnss_site_data_root)

get_canopy_receiver_names()

Get names of all canopy receivers.

Returns

list[str] Canopy receiver names.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
823
824
825
826
827
828
829
830
831
def get_canopy_receiver_names(self) -> list[str]:
    """Get names of all canopy receivers.

    Returns
    -------
    list[str]
        Canopy receiver names.
    """
    return [name for name, cfg in self.receivers.items() if cfg.type == "canopy"]

resolve_scs_from(receiver_name)

Resolve scs_from for a reference receiver to a list of canopy names.

Parameters

receiver_name : str Name of the reference receiver.

Returns

list[str] List of canopy receiver names for SCS computation.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
def resolve_scs_from(self, receiver_name: str) -> list[str]:
    """Resolve scs_from for a reference receiver to a list of canopy names.

    Parameters
    ----------
    receiver_name : str
        Name of the reference receiver.

    Returns
    -------
    list[str]
        List of canopy receiver names for SCS computation.
    """
    cfg = self.receivers[receiver_name]
    if cfg.type != "reference":
        msg = f"resolve_scs_from only applies to reference receivers, got '{cfg.type}'"
        raise ValueError(msg)
    if cfg.scs_from == "all":
        return self.get_canopy_receiver_names()
    if isinstance(cfg.scs_from, list):
        return cfg.scs_from
    # Single canopy name as string
    if cfg.scs_from is None:
        msg = f"Receiver '{receiver_name}' has no scs_from configured"
        raise ValueError(msg)
    return [cfg.scs_from]

get_reference_canopy_pairs()

Expand scs_from into (reference_name, canopy_name) pairs.

Returns

list[tuple[str, str]] List of (reference_name, canopy_name) pairs.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
def get_reference_canopy_pairs(self) -> list[tuple[str, str]]:
    """Expand scs_from into (reference_name, canopy_name) pairs.

    Returns
    -------
    list[tuple[str, str]]
        List of (reference_name, canopy_name) pairs.
    """
    pairs = []
    for name, cfg in self.receivers.items():
        if cfg.type != "reference":
            continue
        for canopy_name in self.resolve_scs_from(name):
            pairs.append((name, canopy_name))
    return pairs

SitesConfig

Bases: BaseModel

All research sites.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
class SitesConfig(BaseModel):
    """All research sites."""

    sites: dict[str, SiteConfig]

    @field_validator("sites")
    @classmethod
    def validate_at_least_one_site(
        cls,
        v: dict[str, SiteConfig],
    ) -> dict[str, SiteConfig]:
        """Warn if no sites are defined.

        Parameters
        ----------
        v : dict[str, SiteConfig]
            Sites dictionary to validate.

        Returns
        -------
        dict[str, SiteConfig]
            Validated sites dictionary.
        """
        if not v:
            import warnings

            warnings.warn(
                "No research sites defined in sites.yaml. Run: just config-init",
                UserWarning,
                stacklevel=2,
            )
        return v

validate_at_least_one_site(v) classmethod

Warn if no sites are defined.

Parameters

v : dict[str, SiteConfig] Sites dictionary to validate.

Returns

dict[str, SiteConfig] Validated sites dictionary.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
@field_validator("sites")
@classmethod
def validate_at_least_one_site(
    cls,
    v: dict[str, SiteConfig],
) -> dict[str, SiteConfig]:
    """Warn if no sites are defined.

    Parameters
    ----------
    v : dict[str, SiteConfig]
        Sites dictionary to validate.

    Returns
    -------
    dict[str, SiteConfig]
        Validated sites dictionary.
    """
    if not v:
        import warnings

        warnings.warn(
            "No research sites defined in sites.yaml. Run: just config-init",
            UserWarning,
            stacklevel=2,
        )
    return v

SidsConfig

Bases: BaseModel

Signal ID configuration.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
class SidsConfig(BaseModel):
    """Signal ID configuration."""

    mode: Literal["all", "preset", "custom"] = Field(
        "all",
        description="SID selection mode",
    )
    preset: str | None = Field(
        None,
        description="Preset name when mode=preset",
    )
    custom_sids: list[str] = Field(
        default_factory=list,
        description="Custom SID list when mode=custom",
    )

    @field_validator("preset")
    @classmethod
    def validate_preset_when_mode_preset(
        cls,
        v: str | None,
        info: ValidationInfo,
    ) -> str | None:
        """Ensure preset is set when mode is preset.

        Parameters
        ----------
        v : str | None
            Preset name.
        info : ValidationInfo
            Pydantic validation info.

        Returns
        -------
        str | None
            Preset value if valid.
        """
        mode = info.data.get("mode")
        if mode == "preset" and not v:
            msg = "preset must be specified when mode is 'preset'"
            raise ValueError(msg)
        return v

    def get_sids(self) -> list[str] | None:
        """Get the effective SID list.

        Returns
        -------
        list[str] | None
            None if mode is "all" (keep all SIDs), otherwise a SID list.
        """
        if self.mode == "all":
            return None
        if self.mode == "preset":
            return self._get_preset_sids()
        # CUSTOM
        return self.custom_sids

    def _get_preset_sids(self) -> list[str]:
        """Load the preset SID list.

        Returns
        -------
        list[str]
            Preset SID list.
        """
        # TODO: Implement preset loading from package defaults
        # For now, return empty list
        return []

validate_preset_when_mode_preset(v, info) classmethod

Ensure preset is set when mode is preset.

Parameters

v : str | None Preset name. info : ValidationInfo Pydantic validation info.

Returns

str | None Preset value if valid.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
@field_validator("preset")
@classmethod
def validate_preset_when_mode_preset(
    cls,
    v: str | None,
    info: ValidationInfo,
) -> str | None:
    """Ensure preset is set when mode is preset.

    Parameters
    ----------
    v : str | None
        Preset name.
    info : ValidationInfo
        Pydantic validation info.

    Returns
    -------
    str | None
        Preset value if valid.
    """
    mode = info.data.get("mode")
    if mode == "preset" and not v:
        msg = "preset must be specified when mode is 'preset'"
        raise ValueError(msg)
    return v

get_sids()

Get the effective SID list.

Returns

list[str] | None None if mode is "all" (keep all SIDs), otherwise a SID list.

Source code in packages/canvod-utils/src/canvod/utils/config/models.py
959
960
961
962
963
964
965
966
967
968
969
970
971
972
def get_sids(self) -> list[str] | None:
    """Get the effective SID list.

    Returns
    -------
    list[str] | None
        None if mode is "all" (keep all SIDs), otherwise a SID list.
    """
    if self.mode == "all":
        return None
    if self.mode == "preset":
        return self._get_preset_sids()
    # CUSTOM
    return self.custom_sids

load_config(config_dir=None)

Load configuration from YAML files.

This is the main entry point for loading configuration.

Parameters

config_dir : Path | None, optional Directory containing config files. If None, automatically finds monorepo root and uses {monorepo_root}/config.

Returns

CanvodConfig Validated configuration object.

Examples

from canvod.utils.config import load_config config = load_config() print(config.nasa_earthdata_acc_mail) print(config.processing.aux_data.agency)

Source code in packages/canvod-utils/src/canvod/utils/config/loader.py
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
def load_config(config_dir: Path | None = None) -> CanvodConfig:
    """
    Load configuration from YAML files.

    This is the main entry point for loading configuration.

    Parameters
    ----------
    config_dir : Path | None, optional
        Directory containing config files. If None, automatically finds
        monorepo root and uses {monorepo_root}/config.

    Returns
    -------
    CanvodConfig
        Validated configuration object.

    Examples
    --------
    >>> from canvod.utils.config import load_config
    >>> config = load_config()
    >>> print(config.nasa_earthdata_acc_mail)
    >>> print(config.processing.aux_data.agency)
    """
    if config_dir is None:
        env_dir = os.environ.get("CANVOD_CONFIG_DIR")
        if env_dir:
            config_dir = Path(env_dir)
    loader = ConfigLoader(config_dir)
    return loader.load()

Tools

Utility tools for canVODpy packages.

This module provides common utilities used across all canVODpy packages: - Version management - Date/time utilities for GNSS data - Validation helpers - File hashing

Examples

from canvod.utils.tools import get_version_from_pyproject version = get_version_from_pyproject()

from canvod.utils.tools import YYYYDOY date = YYYYDOY.from_str("2025024") print(date.to_datetime())

from canvod.utils.tools import gpsweekday week, day = gpsweekday("2025-01-15")

from canvod.utils.tools import isfloat isfloat("3.14") # True

gpsweekday = YYYYDOY.gpsweekday module-attribute

YYYYDOY

Year and day-of-year (DOY) date representation.

Used throughout canvodpy for GNSS data organization and file naming.

Notes

This is a Pydantic dataclass and uses total_ordering for comparisons.

Attributes

year : int The year (e.g., 2025). doy : int Day of year (1-366). date : datetime.date Calculated calendar date. yydoy : str Short format (YYDDD, e.g., "25001").

Examples

From components

d = YYYYDOY(year=2025, doy=1) d.to_str() '2025001'

From date object

import datetime d = YYYYDOY.from_date(datetime.date(2025, 1, 1)) d.doy '001'

From string

d = YYYYDOY.from_str("2025001") d.year 2025

From short string (YYDDD)

d = YYYYDOY.from_yydoy_str("25001") d.to_str() '2025001'

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.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
 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
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
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
230
231
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
266
267
268
269
270
271
272
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
307
308
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
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
@total_ordering
@dataclass
class YYYYDOY:
    """Year and day-of-year (DOY) date representation.

    Used throughout canvodpy for GNSS data organization and file naming.

    Notes
    -----
    This is a Pydantic dataclass and uses ``total_ordering`` for comparisons.

    Attributes
    ----------
    year : int
        The year (e.g., 2025).
    doy : int
        Day of year (1-366).
    date : datetime.date
        Calculated calendar date.
    yydoy : str
        Short format (YYDDD, e.g., "25001").

    Examples
    --------
    >>> # From components
    >>> d = YYYYDOY(year=2025, doy=1)
    >>> d.to_str()
    '2025001'

    >>> # From date object
    >>> import datetime
    >>> d = YYYYDOY.from_date(datetime.date(2025, 1, 1))
    >>> d.doy
    '001'

    >>> # From string
    >>> d = YYYYDOY.from_str("2025001")
    >>> d.year
    2025

    >>> # From short string (YYDDD)
    >>> d = YYYYDOY.from_yydoy_str("25001")
    >>> d.to_str()
    '2025001'

    """

    year: int = Field(..., ge=1)
    doy: int = Field(..., ge=1, le=366)
    yydoy: str | None = None
    date: datetime.date | None = None

    def __post_init__(self) -> None:
        """Calculate derived fields after initialization.

        Returns
        -------
        None
        """
        doy_int = int(self.doy)
        self._validate_doy(doy_int)
        self.date = self._calculate_date()
        self.yydoy = f"{str(self.year)[-2:]}{doy_int:03}"

    def __repr__(self) -> str:
        """Return the developer-focused representation.

        Returns
        -------
        str
            Detailed representation of the date.
        """
        return (
            f"YYYYDOY(year={self.year}, doy={self.doy}, date={self.date}, "
            f"yydoy={self.yydoy}, gps_week={self.gps_week}, "
            f"gps_day_of_week={self.gps_day_of_week})"
        )

    def __str__(self) -> str:
        """Return the user-facing date string.

        Returns
        -------
        str
            Date string in YYYYDDD format.
        """
        return self.to_str()

    def __eq__(self, other: object) -> bool:
        """Compare equality based on the date string.

        Parameters
        ----------
        other : object
            Object to compare with.

        Returns
        -------
        bool
            True if the other object is an equal YYYYDOY instance.
        """
        if not isinstance(other, YYYYDOY):
            return False
        return self.to_str() == other.to_str()

    def __lt__(self, other: object) -> bool:
        """Compare ordering for sorting.

        Parameters
        ----------
        other : object
            Object to compare with.

        Returns
        -------
        bool
            True if this date is earlier than the other date.
        """
        if not isinstance(other, YYYYDOY):
            return NotImplemented
        if self.date is None or other.date is None:
            return self.to_str() < other.to_str()
        return self.date < other.date

    def __hash__(self) -> int:
        """Return the hash of the date string.

        Returns
        -------
        int
            Hash value for use in sets and dicts.
        """
        return hash(self.to_str())

    def _calculate_date(self) -> datetime.date:
        """Calculate the calendar date from year and DOY.

        Returns
        -------
        datetime.date
            Calculated calendar date.
        """
        return datetime.date(self.year, 1, 1) + datetime.timedelta(
            days=int(self.doy) - 1
        )

    @staticmethod
    def _validate_doy(doy: int) -> None:
        """Validate day of year is in range [1, 366].

        Parameters
        ----------
        doy : int
            Day of year value to validate.

        Returns
        -------
        None
        """
        if not 1 <= doy <= 366:
            raise ValueError(f"Day of year (DOY) must be in range [1, 366], got {doy}")

    @classmethod
    def from_date(cls, date: datetime.date) -> YYYYDOY:
        """Create from datetime.date object.

        Parameters
        ----------
        date : datetime.date
            Calendar date

        Returns
        -------
        YYYYDOY
        """
        if isinstance(date, datetime.datetime):
            date = date.date()
        year = date.year
        doy = (date - datetime.date(year, 1, 1)).days + 1
        cls._validate_doy(doy)
        return cls(year=year, doy=doy)

    @classmethod
    def from_str(cls, yyyydoy: str | int) -> YYYYDOY:
        """Create from YYYYDDD string.

        Parameters
        ----------
        yyyydoy : str or int
            Date in YYYYDDD format (e.g., "2025001" or 2025001)

        Returns
        -------
        YYYYDOY
        """
        if isinstance(yyyydoy, int):
            yyyydoy = str(yyyydoy)
        if len(yyyydoy) != 7:
            raise ValueError(f"Invalid format. Expected 'YYYYDDD', got '{yyyydoy}'")
        year = int(yyyydoy[:4])
        doy = int(yyyydoy[4:])
        cls._validate_doy(doy)
        jan_first = datetime.datetime(year, 1, 1)
        final_date = jan_first + datetime.timedelta(days=doy - 1)
        return cls.from_date(final_date.date())

    @classmethod
    def from_int(cls, yyyydoy: int) -> YYYYDOY:
        """Create from YYYYDDD integer.

        Parameters
        ----------
        yyyydoy : int
            Date as integer (e.g., 2025001)

        Returns
        -------
        YYYYDOY
        """
        return cls.from_str(str(yyyydoy))

    @classmethod
    def from_yydoy_str(cls, yydoy: str) -> YYYYDOY:
        """Create from YYDDD short string.

        Assumes current millennium (20XX).

        Parameters
        ----------
        yydoy : str
            Short date format (e.g., "25001" for 2025 DOY 001)

        Returns
        -------
        YYYYDOY
        """
        current_millennium = str(datetime.datetime.now().year)[0:2]
        return cls.from_str(f"{current_millennium}{yydoy}")

    def to_str(self) -> str:
        """Convert to YYYYDDD string.

        Returns
        -------
        str
            Date in YYYYDDD format (e.g., "2025001").

        """
        return f"{self.year}{int(self.doy):03}"

    @staticmethod
    def gpsweekday(
        input_date: datetime.datetime | datetime.date | str,
        is_datetime: bool = False,
    ) -> tuple[int, int]:
        """
        Calculate GPS week number and day of week from a given date.

        GPS time started on January 6, 1980.

        Parameters
        ----------
        input_date : datetime.datetime, datetime.date, or str
            The date to calculate GPS week for. If string, format: "dd-mm-yyyy"
        is_datetime : bool, optional
            Whether input_date is a datetime object (default: False)

        Returns
        -------
        tuple[int, int]
            (GPS week number, day of week)
        """
        gps_start_date = datetime.date(1980, 1, 6)

        # Convert string to date if needed
        if not is_datetime and isinstance(input_date, str):
            input_date = datetime.datetime.strptime(input_date, "%d-%m-%Y").date()
        elif isinstance(input_date, datetime.datetime):
            input_date = input_date.date()

        if isinstance(input_date, datetime.date):
            # Calculate weeks and days since GPS epoch
            return divmod((input_date - gps_start_date).days, 7)
        raise TypeError(f"Unsupported input_date type: {type(input_date)!r}")

    @property
    def gps_week(self) -> int:
        """GPS week number.

        Returns
        -------
        int
            GPS week number since GPS epoch (1980-01-06).
        """
        if self.date is None:
            raise ValueError("Date is not initialized")
        return self.gpsweekday(self.date)[0]

    @property
    def gps_day_of_week(self) -> int:
        """GPS day of week (0=Sunday, 6=Saturday).

        Returns
        -------
        int
            Day of week where 0=Sunday, 6=Saturday.
        """
        if self.date is None:
            raise ValueError("Date is not initialized")
        return self.gpsweekday(self.date)[1]

gps_week property

GPS week number.

Returns

int GPS week number since GPS epoch (1980-01-06).

gps_day_of_week property

GPS day of week (0=Sunday, 6=Saturday).

Returns

int Day of week where 0=Sunday, 6=Saturday.

__post_init__()

Calculate derived fields after initialization.

Returns

None

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
105
106
107
108
109
110
111
112
113
114
115
def __post_init__(self) -> None:
    """Calculate derived fields after initialization.

    Returns
    -------
    None
    """
    doy_int = int(self.doy)
    self._validate_doy(doy_int)
    self.date = self._calculate_date()
    self.yydoy = f"{str(self.year)[-2:]}{doy_int:03}"

__repr__()

Return the developer-focused representation.

Returns

str Detailed representation of the date.

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
117
118
119
120
121
122
123
124
125
126
127
128
129
def __repr__(self) -> str:
    """Return the developer-focused representation.

    Returns
    -------
    str
        Detailed representation of the date.
    """
    return (
        f"YYYYDOY(year={self.year}, doy={self.doy}, date={self.date}, "
        f"yydoy={self.yydoy}, gps_week={self.gps_week}, "
        f"gps_day_of_week={self.gps_day_of_week})"
    )

__str__()

Return the user-facing date string.

Returns

str Date string in YYYYDDD format.

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
131
132
133
134
135
136
137
138
139
def __str__(self) -> str:
    """Return the user-facing date string.

    Returns
    -------
    str
        Date string in YYYYDDD format.
    """
    return self.to_str()

__eq__(other)

Compare equality based on the date string.

Parameters

other : object Object to compare with.

Returns

bool True if the other object is an equal YYYYDOY instance.

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def __eq__(self, other: object) -> bool:
    """Compare equality based on the date string.

    Parameters
    ----------
    other : object
        Object to compare with.

    Returns
    -------
    bool
        True if the other object is an equal YYYYDOY instance.
    """
    if not isinstance(other, YYYYDOY):
        return False
    return self.to_str() == other.to_str()

__lt__(other)

Compare ordering for sorting.

Parameters

other : object Object to compare with.

Returns

bool True if this date is earlier than the other date.

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def __lt__(self, other: object) -> bool:
    """Compare ordering for sorting.

    Parameters
    ----------
    other : object
        Object to compare with.

    Returns
    -------
    bool
        True if this date is earlier than the other date.
    """
    if not isinstance(other, YYYYDOY):
        return NotImplemented
    if self.date is None or other.date is None:
        return self.to_str() < other.to_str()
    return self.date < other.date

__hash__()

Return the hash of the date string.

Returns

int Hash value for use in sets and dicts.

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
177
178
179
180
181
182
183
184
185
def __hash__(self) -> int:
    """Return the hash of the date string.

    Returns
    -------
    int
        Hash value for use in sets and dicts.
    """
    return hash(self.to_str())

from_date(date) classmethod

Create from datetime.date object.

Parameters

date : datetime.date Calendar date

Returns

YYYYDOY

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
@classmethod
def from_date(cls, date: datetime.date) -> YYYYDOY:
    """Create from datetime.date object.

    Parameters
    ----------
    date : datetime.date
        Calendar date

    Returns
    -------
    YYYYDOY
    """
    if isinstance(date, datetime.datetime):
        date = date.date()
    year = date.year
    doy = (date - datetime.date(year, 1, 1)).days + 1
    cls._validate_doy(doy)
    return cls(year=year, doy=doy)

from_str(yyyydoy) classmethod

Create from YYYYDDD string.

Parameters

yyyydoy : str or int Date in YYYYDDD format (e.g., "2025001" or 2025001)

Returns

YYYYDOY

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
@classmethod
def from_str(cls, yyyydoy: str | int) -> YYYYDOY:
    """Create from YYYYDDD string.

    Parameters
    ----------
    yyyydoy : str or int
        Date in YYYYDDD format (e.g., "2025001" or 2025001)

    Returns
    -------
    YYYYDOY
    """
    if isinstance(yyyydoy, int):
        yyyydoy = str(yyyydoy)
    if len(yyyydoy) != 7:
        raise ValueError(f"Invalid format. Expected 'YYYYDDD', got '{yyyydoy}'")
    year = int(yyyydoy[:4])
    doy = int(yyyydoy[4:])
    cls._validate_doy(doy)
    jan_first = datetime.datetime(year, 1, 1)
    final_date = jan_first + datetime.timedelta(days=doy - 1)
    return cls.from_date(final_date.date())

from_int(yyyydoy) classmethod

Create from YYYYDDD integer.

Parameters

yyyydoy : int Date as integer (e.g., 2025001)

Returns

YYYYDOY

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
@classmethod
def from_int(cls, yyyydoy: int) -> YYYYDOY:
    """Create from YYYYDDD integer.

    Parameters
    ----------
    yyyydoy : int
        Date as integer (e.g., 2025001)

    Returns
    -------
    YYYYDOY
    """
    return cls.from_str(str(yyyydoy))

from_yydoy_str(yydoy) classmethod

Create from YYDDD short string.

Assumes current millennium (20XX).

Parameters

yydoy : str Short date format (e.g., "25001" for 2025 DOY 001)

Returns

YYYYDOY

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
@classmethod
def from_yydoy_str(cls, yydoy: str) -> YYYYDOY:
    """Create from YYDDD short string.

    Assumes current millennium (20XX).

    Parameters
    ----------
    yydoy : str
        Short date format (e.g., "25001" for 2025 DOY 001)

    Returns
    -------
    YYYYDOY
    """
    current_millennium = str(datetime.datetime.now().year)[0:2]
    return cls.from_str(f"{current_millennium}{yydoy}")

to_str()

Convert to YYYYDDD string.

Returns

str Date in YYYYDDD format (e.g., "2025001").

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
292
293
294
295
296
297
298
299
300
301
def to_str(self) -> str:
    """Convert to YYYYDDD string.

    Returns
    -------
    str
        Date in YYYYDDD format (e.g., "2025001").

    """
    return f"{self.year}{int(self.doy):03}"

gpsweekday(input_date, is_datetime=False) staticmethod

Calculate GPS week number and day of week from a given date.

GPS time started on January 6, 1980.

Parameters

input_date : datetime.datetime, datetime.date, or str The date to calculate GPS week for. If string, format: "dd-mm-yyyy" is_datetime : bool, optional Whether input_date is a datetime object (default: False)

Returns

tuple[int, int] (GPS week number, day of week)

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
303
304
305
306
307
308
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
@staticmethod
def gpsweekday(
    input_date: datetime.datetime | datetime.date | str,
    is_datetime: bool = False,
) -> tuple[int, int]:
    """
    Calculate GPS week number and day of week from a given date.

    GPS time started on January 6, 1980.

    Parameters
    ----------
    input_date : datetime.datetime, datetime.date, or str
        The date to calculate GPS week for. If string, format: "dd-mm-yyyy"
    is_datetime : bool, optional
        Whether input_date is a datetime object (default: False)

    Returns
    -------
    tuple[int, int]
        (GPS week number, day of week)
    """
    gps_start_date = datetime.date(1980, 1, 6)

    # Convert string to date if needed
    if not is_datetime and isinstance(input_date, str):
        input_date = datetime.datetime.strptime(input_date, "%d-%m-%Y").date()
    elif isinstance(input_date, datetime.datetime):
        input_date = input_date.date()

    if isinstance(input_date, datetime.date):
        # Calculate weeks and days since GPS epoch
        return divmod((input_date - gps_start_date).days, 7)
    raise TypeError(f"Unsupported input_date type: {type(input_date)!r}")

YYDOY

Two-digit year + day of year (DOY) date representation.

This is a compact format often used in GNSS filenames.

Examples

date = YYDOY.from_str("25001") # 2025, day 1

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
@dataclass
class YYDOY:
    """
    Two-digit year + day of year (DOY) date representation.

    This is a compact format often used in GNSS filenames.

    Examples
    --------
    >>> date = YYDOY.from_str("25001")  # 2025, day 1
    """

    @classmethod
    def from_str(cls, yydoy: str) -> YYYYDOY:
        """
        Convert two-digit year DOY string to four-digit YYYYDOY.

        Parameters
        ----------
        yydoy : str
            Two-digit year + DOY (e.g., "25001" for 2025-01-01)

        Returns
        -------
        YYYYDOY
            Four-digit year DOY object
        """
        date_str = f"20{yydoy}"
        return YYYYDOY.from_str(date_str)

from_str(yydoy) classmethod

Convert two-digit year DOY string to four-digit YYYYDOY.

Parameters

yydoy : str Two-digit year + DOY (e.g., "25001" for 2025-01-01)

Returns

YYYYDOY Four-digit year DOY object

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
@classmethod
def from_str(cls, yydoy: str) -> YYYYDOY:
    """
    Convert two-digit year DOY string to four-digit YYYYDOY.

    Parameters
    ----------
    yydoy : str
        Two-digit year + DOY (e.g., "25001" for 2025-01-01)

    Returns
    -------
    YYYYDOY
        Four-digit year DOY object
    """
    date_str = f"20{yydoy}"
    return YYYYDOY.from_str(date_str)

get_gps_week_from_filename(file_name)

Extract GPS week from various GNSS product filenames.

Parameters

file_name : Path GNSS product filename (e.g., SP3, CLK, TRO, IONEX)

Returns

str GPS week as string

Raises

ValueError If file type is not recognized

Examples

from pathlib import Path week = get_gps_week_from_filename(Path("igs12345.sp3"))

Source code in packages/canvod-utils/src/canvod/utils/tools/date_utils.py
11
12
13
14
15
16
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
def get_gps_week_from_filename(file_name: Path) -> str:
    """
    Extract GPS week from various GNSS product filenames.

    Parameters
    ----------
    file_name : Path
        GNSS product filename (e.g., SP3, CLK, TRO, IONEX)

    Returns
    -------
    str
        GPS week as string

    Raises
    ------
    ValueError
        If file type is not recognized

    Examples
    --------
    >>> from pathlib import Path
    >>> week = get_gps_week_from_filename(Path("igs12345.sp3"))
    """
    if file_name.suffix in [".clk", ".clk_05s", ".CLK", ".sp3", ".SP3"]:
        if file_name.suffix == ".clk":
            return str(file_name)[3:-7]
        else:
            # Parse date from filename and convert to GPS week
            date_str = str(file_name).split("_")[1]
            date = datetime.datetime.strptime(date_str, "%Y%j%H%M").date()
            return str(YYYYDOY.gpsweekday(date)[0])
    elif any(ext in str(file_name) for ext in ("TRO", "IONEX")):
        return str(file_name).split("_")[1][:4]

    msg = (
        "Invalid file type. The filename must end with "
        ".clk, clk_05s, .CLK, .SP3, .TRO, or .IONEX"
    )
    raise ValueError(msg)

isfloat(value)

Check if a variable can be converted to float.

Parameters

value : Any Value to check.

Returns

bool True if value can be converted to float, False otherwise.

Examples

isfloat("3.14") True isfloat("hello") False isfloat(42) True

Source code in packages/canvod-utils/src/canvod/utils/tools/validation.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def isfloat(value: Any) -> bool:
    """
    Check if a variable can be converted to float.

    Parameters
    ----------
    value : Any
        Value to check.

    Returns
    -------
    bool
        True if value can be converted to float, False otherwise.

    Examples
    --------
    >>> isfloat("3.14")
    True
    >>> isfloat("hello")
    False
    >>> isfloat(42)
    True
    """
    try:
        float(value)
        return True
    except ValueError, TypeError:
        return False

get_version_from_pyproject(pyproject_path=None)

Get version from pyproject.toml.

Parameters

pyproject_path : Path, optional Path to pyproject.toml. If None, automatically finds it by traversing up from the current file location.

Returns

str Version string from pyproject.toml.

Examples

version = get_version_from_pyproject() print(version) # e.g., "0.1.0"

Source code in packages/canvod-utils/src/canvod/utils/tools/version.py
 8
 9
10
11
12
13
14
15
16
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
def get_version_from_pyproject(pyproject_path: Path | None = None) -> str:
    """
    Get version from pyproject.toml.

    Parameters
    ----------
    pyproject_path : Path, optional
        Path to pyproject.toml. If None, automatically finds it by traversing
        up from the current file location.

    Returns
    -------
    str
        Version string from pyproject.toml.

    Examples
    --------
    >>> version = get_version_from_pyproject()
    >>> print(version)  # e.g., "0.1.0"
    """
    if pyproject_path is None:
        # Automatically find pyproject.toml at package root
        # Start from this file and go up until we find pyproject.toml
        current = Path(__file__).resolve()
        for parent in current.parents:
            candidate = parent / "pyproject.toml"
            if candidate.exists():
                pyproject_path = candidate
                break

        if pyproject_path is None:
            msg = "Could not find pyproject.toml"
            raise FileNotFoundError(msg)

    with open(pyproject_path, "rb") as f:
        data = tomli.load(f)

    return data["project"]["version"]