diff --git a/environment.yml b/environment.yml index 342ac48..1ac2c41 100644 --- a/environment.yml +++ b/environment.yml @@ -3,6 +3,7 @@ channels: - conda-forge dependencies: - python>=3.10 + - pydantic - numpy - pip - xarray diff --git a/pyglider/_config_components.py b/pyglider/_config_components.py new file mode 100644 index 0000000..1584033 --- /dev/null +++ b/pyglider/_config_components.py @@ -0,0 +1,142 @@ +from typing import Optional + +from pydantic import BaseModel, HttpUrl + + +class Metadata(BaseModel): + acknowledgement: str + comment: str + contributor_name: str + contributor_role: str + creator_email: str + creator_name: str + creator_url: HttpUrl + deployment_id: str + deployment_name: str + deployment_start: str + deployment_end: str + format_version: str + glider_name: str + glider_serial: str + glider_model: str + glider_instrument_name: str + glider_wmo: str + institution: str + keywords: str + keywords_vocabulary: str + license: str + metadata_link: HttpUrl + Metadata_Conventions: str + naming_authority: str + platform_type: str + processing_level: str + project: str + project_url: HttpUrl + publisher_email: str + publisher_name: str + publisher_url: HttpUrl + references: str + sea_name: str + source: str + standard_name_vocabulary: str + summary: str + transmission_system: str + wmo_id: str + + +class Device(BaseModel): + make: str + model: str + serial: str + long_name: Optional[str] = None + make_model: Optional[str] = None + factory_calibrated: Optional[str] = None + calibration_date: Optional[str] = None + calibration_report: Optional[str] = None + comment: Optional[str] = None + + +class GliderDevices(BaseModel): + pressure: Device + ctd: Device + optics: Device + oxygen: Device + + +class NetCDFVariable(BaseModel): + source: str + long_name: Optional[str] = None + standard_name: Optional[str] = None + units: Optional[str] = None + axis: Optional[str] = None + coordinates: Optional[str] = None + conversion: Optional[str] = None + comment: Optional[str] = None + observation_type: Optional[str] = None + platform: Optional[str] = None + reference: Optional[str] = None + valid_max: Optional[float] = None + valid_min: Optional[float] = None + coordinate_reference_frame: Optional[str] = None + instrument: Optional[str] = None + accuracy: Optional[float] = None + precision: Optional[float] = None + resolution: Optional[float] = None + positive: Optional[str] = None + reference_datum: Optional[str] = None + coarsen: Optional[int] = None + + +class NetCDFVariables(BaseModel): + timebase: Optional[NetCDFVariable] = ( + None #! Is this required? `example-slocum`` doesn't have it + ) + time: NetCDFVariable + latitude: NetCDFVariable + longitude: NetCDFVariable + heading: NetCDFVariable + pitch: NetCDFVariable + roll: NetCDFVariable + conductivity: NetCDFVariable + temperature: NetCDFVariable + pressure: NetCDFVariable + chlorophyll: NetCDFVariable + cdom: NetCDFVariable + backscatter_700: NetCDFVariable + oxygen_concentration: NetCDFVariable + temperature_oxygen: Optional[NetCDFVariable] = ( + None #! Is this required? `example-slocum`` doesn't have it + ) + + +class ProfileVariable(BaseModel): + comment: str + long_name: str + valid_max: Optional[float] = None + valid_min: Optional[float] = None + observation_type: Optional[str] = None + platform: Optional[str] = None + standard_name: Optional[str] = None + units: Optional[str] = None + calendar: Optional[str] = None + type: Optional[str] = None + calibration_date: Optional[str] = None + calibration_report: Optional[str] = None + factory_calibrated: Optional[str] = None + make_model: Optional[str] = None + serial_number: Optional[str] = None + + +class ProfileVariables(BaseModel): + profile_id: ProfileVariable + profile_time: ProfileVariable + profile_time_start: ProfileVariable + profile_time_end: ProfileVariable + profile_lat: ProfileVariable + profile_lon: ProfileVariable + u: ProfileVariable + v: ProfileVariable + lon_uv: ProfileVariable + lat_uv: ProfileVariable + time_uv: ProfileVariable + instrument_ctd: ProfileVariable diff --git a/pyglider/config.py b/pyglider/config.py new file mode 100644 index 0000000..02fc01e --- /dev/null +++ b/pyglider/config.py @@ -0,0 +1,33 @@ +import yaml +from pydantic import BaseModel + +from pyglider._config_components import ( + GliderDevices, + Metadata, + NetCDFVariables, + ProfileVariables, +) + +__all__ = ['Deployment', 'dump_yaml'] + + +class Deployment(BaseModel): + metadata: Metadata + glider_devices: GliderDevices + netcdf_variables: NetCDFVariables + profile_variables: ProfileVariables + + @classmethod + def load_yaml(cls, yaml_str: str) -> 'Deployment': + """Load a yaml string into a Deployment model.""" + return _generic_load_yaml(yaml_str, cls) + + +def dump_yaml(model: BaseModel) -> str: + """Dump a pydantic model to a yaml string.""" + return yaml.safe_dump(model.model_dump(), default_flow_style=False) + + +def _generic_load_yaml(data: str, model: BaseModel) -> BaseModel: + """Load a yaml string into a pydantic model.""" + return model.model_validate(yaml.safe_load(data)) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..bcd0998 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,55 @@ +from pathlib import Path +from typing import Callable + +import pytest +import yaml + +from pyglider.config import Deployment + +library_dir = Path(__file__).parent.parent.absolute() +example_dir = library_dir / 'tests/example-data/' + +VALID_CONFIG_PATHS = [ + example_dir / 'example-slocum/deploymentRealtime.yml', + example_dir / 'example-seaexplorer/deploymentRealtime.yml', + # TODO: Add other valid example configs? +] + + +@pytest.fixture(params=VALID_CONFIG_PATHS) +def valid_config(request): + return request.param.read_text() + + +def test_valid_config(valid_config): + """Checks all valid configs can be loaded.""" + Deployment.load_yaml(valid_config) + + +def patch_config(yaml_str: str, f: Callable) -> str: + """Patch a yaml string with a function. + Function should do an in place operation on a dictionary/list. + """ + d = yaml.safe_load(yaml_str) + f(d) + return yaml.safe_dump(d, default_flow_style=False) + + +@pytest.mark.parametrize( + 'input_, expected, f', + [ + ( + 'a: 1\n' 'b: 2\n', + 'a: 1\n' 'b: 3\n', + lambda d: d.update({'b': 3}), + ), + ], +) +def test_patch_config(input_, expected, f): + assert patch_config(input_, f) == expected + + +# TODO: Stress test the model by taking existing configs and modifying them in breaking ways + + +def test_incorrect_date_format(): ...