From 9c806baec15a372f1b9a4bf8dba2199654f9a47c Mon Sep 17 00:00:00 2001 From: Eleanor Frajka-Williams Date: Fri, 12 Sep 2025 22:19:41 +0200 Subject: [PATCH 1/2] [CLEANUP] Better separation of legacy/rodb format code. --- CLAUDE.md | 96 +++++++ docs/source/project_structure.md | 269 ++++++++++-------- docs/source/roadmap.rst | 6 +- notebooks/demo_batch_instrument.ipynb | 58 +--- notebooks/demo_check_clock.ipynb | 32 +-- notebooks/demo_climatology.ipynb | 11 +- notebooks/demo_clock_offset.ipynb | 18 +- notebooks/demo_instrument.ipynb | 13 +- notebooks/demo_instrument_rdb.ipynb | 46 +-- notebooks/demo_mooring_rdb.ipynb | 31 +- notebooks/demo_stage1.ipynb | 10 +- notebooks/demo_stage2.ipynb | 50 +++- notebooks/demo_step1.ipynb | 155 +--------- .../dsE_1_2018_microcat_7518_temperature.png | Bin 0 -> 51365 bytes oceanarray/convertOS.py | 5 +- oceanarray/{mooring.py => mooring_rodb.py} | 37 ++- oceanarray/{instrument.py => process_rodb.py} | 150 +++++++++- oceanarray/read_rapid.py | 133 --------- oceanarray/readers.py | 29 -- oceanarray/stage1.py | 9 +- oceanarray/stage2.py | 123 +++++++- oceanarray/tools.py | 266 +---------------- tests/test_convertOS.py | 11 +- .../{test_mooring.py => test_mooring_rodb.py} | 15 +- ...est_instrument.py => test_process_rodb.py} | 11 +- tests/test_rodb.py | 6 +- tests/test_stage1.py | 141 ++++++++- tests/test_stage2.py | 101 ++++++- tests/test_tools.py | 28 +- 29 files changed, 913 insertions(+), 947 deletions(-) create mode 100644 CLAUDE.md create mode 100644 notebooks/dsE_1_2018_microcat_7518_temperature.png rename oceanarray/{mooring.py => mooring_rodb.py} (92%) rename oceanarray/{instrument.py => process_rodb.py} (62%) delete mode 100644 oceanarray/read_rapid.py rename tests/{test_mooring.py => test_mooring_rodb.py} (96%) rename tests/{test_instrument.py => test_process_rodb.py} (95%) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0cfe58b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Common Development Commands + +### Testing +```bash +pytest # Run all tests +pytest tests/test_process_rodb.py # Run specific test file +pytest tests/test_mooring_rodb.py # Run mooring RODB test file +pytest -v # Verbose output +pytest --cov=oceanarray # With coverage report +``` + +### Code Quality and Linting +```bash +black . # Format code with black +ruff check . # Run ruff linter +ruff check . --fix # Auto-fix issues where possible +pre-commit run --all-files # Run all pre-commit hooks +codespell # Check for spelling errors +``` + +### Documentation +```bash +cd docs +make html # Build documentation locally +make clean html # Clean build and rebuild +``` + +### Environment Setup +```bash +pip install -r requirements-dev.txt # Install development dependencies +pip install -e . # Install package in development mode +``` + +### Jupyter Notebooks +```bash +jupyter nbconvert --clear-output --inplace notebooks/*.ipynb # Clear notebook outputs +``` + +## High-Level Architecture + +### Core Processing Stages +The codebase implements a multi-stage processing pipeline for oceanographic mooring data: + +1. **Stage 1** (`stage1.py`): Raw data conversion and initial processing using ctd_tools readers +2. **Stage 2** (`stage2.py`): Advanced processing, calibration, and quality control +3. **Time Gridding** (`time_gridding.py`): Multi-instrument coordination, filtering, and interpolation onto common time grids (supersedes `mooring_rodb.py`) +4. **Array Level** (`transports.py`): Cross-mooring calculations and transport computations (work in progress) + +### Key Components + +- **Data Readers** (`readers.py`, `rodb.py`): Handle various oceanographic data formats +- **Data Writers** (`writers.py`): Output processed data in standardized formats +- **Processing Tools** (`tools.py`, `utilities.py`): Core algorithms for data manipulation +- **Time Operations** (`time_gridding.py`, `clock_offset.py`, `find_deployment.py`): Temporal processing +- **Visualization** (`plotters.py`): Data visualization and quality assessment +- **Logging** (`logger.py`): Configurable logging system + +### Data Flow Architecture +1. Raw instrument files → Stage1 → CF-NetCDF standardized format +2. Stage1 outputs → Stage2 → Advanced processing and quality control +3. Multiple instruments → Time Gridding → Common time grid with optional filtering +4. Multiple moorings → Array-level transport calculations (in development) + +### File Type Support +Supports multiple instrument formats via ctd_tools: +- SeaBird CNV/ASC files (`sbe-cnv`, `sbe-asc`) +- RBR RSK/DAT files (`rbr-rsk`, `rbr-dat`) +- Nortek AQD files (`nortek-aqd`) + +### Key Design Patterns +- Uses xarray.Dataset as primary data structure throughout pipeline +- Implements CF conventions for metadata and naming +- Modular processing stages that can be run independently +- Configurable logging with different verbosity levels +- YAML-based configuration for processing parameters + +### Legacy Modules +- `process_rodb.py`: Legacy RODB-format processing functions (for RAPID-style workflows) +- `mooring_rodb.py`: Legacy RODB mooring-level processing (superseded by time_gridding.py) + +### Testing Structure +- Comprehensive test coverage with pytest +- Tests organized by module (`test_*.py` files) +- Uses sample data for integration testing +- Pre-commit hooks ensure code quality + +### Dependencies +- **Core**: numpy, pandas, xarray, netcdf4, scipy +- **Oceanographic**: gsw (seawater calculations), ioos_qc (quality control), ctd_tools +- **Development**: pytest, black, ruff, pre-commit, sphinx + +The codebase emphasizes reproducible scientific data processing with clear documentation and methodological transparency. \ No newline at end of file diff --git a/docs/source/project_structure.md b/docs/source/project_structure.md index bacc873..5400c70 100644 --- a/docs/source/project_structure.md +++ b/docs/source/project_structure.md @@ -1,156 +1,191 @@ -# What’s in This Template Project? +# OceanArray Project Structure -> 🐍 This project is designed for a **Python-based code repository**. It includes features to help you manage, test, document, and share your code. - -Below is an overview of the files and folders you’ll find in the `template-project`, along with what they do and why they’re useful. If you're new to GitHub or Python packaging, this is your orientation. +This document provides an overview of the oceanarray codebase structure and organization. --- ## 🔍 Project Structure Overview -📷 *This is what the template looks like when you clone or fork it:* -# 📁 `template-project` File Structure - -A minimal, modular Python project structure for collaborative research and reproducible workflows. - ``` -template-project/ -├── template_project # [core] Main Python package with scientific code -│ ├── __init__.py # [core] Makes this a Python package -│ ├── plotters.py # [core] Functions to plot data -│ ├── readers.py # [core] Functions to read raw data into xarray datasets -│ ├── read_rapid.py # [core] Example for a separate module for a specific dataset -│ ├── writers.py # [core] Functions to write data (e.g., to NetCDF) -│ ├── tools.py # [core] Utilities for unit conversion, calculations, etc. -│ ├── logger.py # [core] Structured logging configuration for reproducible runs -│ ├── template_project.mplstyle # [core] Default plotting parameters -│ └── utilities.py # [core] Helper functions (e.g., file download or parsing) +oceanarray/ +├── oceanarray/ # [core] Main Python package for oceanographic processing +│ ├── __init__.py # [core] Makes this a Python package +│ ├── stage1.py # [core] Stage1: Raw data conversion to NetCDF (modern workflow) +│ ├── stage2.py # [core] Stage2: Clock corrections and trimming (modern workflow) +│ ├── time_gridding.py # [core] Time gridding and mooring-level processing (modern workflow) +│ ├── clock_offset.py # [core] Clock offset detection and correction analysis +│ ├── find_deployment.py # [core] Deployment detection from temperature profiles +│ ├── readers.py # [core] Functions to read various oceanographic data formats +│ ├── writers.py # [core] Functions to write processed data to NetCDF +│ ├── rodb.py # [core] RODB format reader for legacy RAPID data +│ ├── process_rodb.py # [legacy] Legacy RODB instrument processing functions +│ ├── mooring_rodb.py # [legacy] Legacy RODB mooring-level processing functions +│ ├── tools.py # [core] Core utilities (lag correlation, QC functions) +│ ├── convertOS.py # [format] OceanSites format conversion utilities +│ ├── plotters.py # [viz] Data visualization and plotting functions +│ ├── rapid_interp.py # [interp] Physics-based vertical interpolation +│ ├── transports.py # [analysis] Transport calculations (work in progress) +│ ├── logger.py # [core] Structured logging configuration +│ ├── utilities.py # [core] General helper functions +│ └── config/ # [config] Configuration files for processing +│ ├── OS1_var_names.yaml # [config] OceanSites variable name mappings +│ ├── OS1_vocab_attrs.yaml # [config] OceanSites vocabulary attributes +│ ├── OS1_sensor_attrs.yaml # [config] OceanSites sensor attributes +│ └── project_RAPID.yaml # [config] RAPID project configuration │ -├── tests/ # [test] Unit tests using pytest -│ ├── test_readers.py # [test] Test functions in readers.py -│ ├── test_tools.py # [test] Test functions in tools.py -│ ├── test_utilities.py # [test] Test functions in utilities.py +├── tests/ # [test] Unit tests using pytest +│ ├── test_stage1.py # [test] Test Stage1 processing +│ ├── test_stage2.py # [test] Test Stage2 processing +│ ├── test_rodb.py # [test] Test RODB data reading +│ ├── test_process_rodb.py # [test] Test legacy RODB processing functions +│ ├── test_mooring_rodb.py # [test] Test legacy RODB mooring functions +│ ├── test_tools.py # [test] Test core utility functions +│ ├── test_convertOS.py # [test] Test OceanSites conversion │ └── ... │ -├── docs/ # [docs] -│ ├── source/ # [docs] Sphinx documentation source files -│ │ ├── conf.py # [docs] Setup for documentation -│ │ ├── index.rst # [docs] Main page with menus in *.rst -│ │ ├── setup.md # [docs] One of the documentation pages in *.md -│ │ ├── template_project.rst # [docs] The file to create the API based on docstrings -│ │ ├── ... # [docs] More *.md or *.rst linked in index.rst -│ │ └── _static # [docs] Figures -│ │ ├── css/custom.css # [docs, style] Custom style sheet for docs -│ │ └── logo.png # [docs] logo for top left of docs/ -│ └── Makefile # [docs] Build the docs -│ -├── notebooks/ # [demo] Example notebooks -│ ├── demo.ipynb # [demo] Also run in docs.yml to appear in docs -│ └── ... +├── notebooks/ # [demo] Processing demonstration notebooks +│ ├── demo_stage1.ipynb # [demo] Stage1 processing demo +│ ├── demo_stage2.ipynb # [demo] Stage2 processing demo +│ ├── demo_step1.ipynb # [demo] Time gridding (mooring-level) demo +│ ├── demo_instrument.ipynb # [demo] Compact instrument processing workflow +│ ├── demo_clock_offset.ipynb # [demo] Clock offset analysis (refactored) +│ ├── demo_check_clock.ipynb # [demo] Clock offset analysis (original) +│ ├── demo_instrument_rdb.ipynb # [demo] Legacy RODB instrument processing +│ ├── demo_mooring_rdb.ipynb # [demo] Legacy RODB mooring processing +│ ├── demo_batch_instrument.ipynb # [demo] Batch processing and QC analysis +│ └── demo_climatology.ipynb # [demo] Climatological processing │ -├── data/ # [data] -│ └── moc_transports.nc # [data] Example data file used for the template. +├── docs/ # [docs] Sphinx documentation +│ ├── source/ # [docs] Documentation source files +│ │ ├── conf.py # [docs] Sphinx configuration +│ │ ├── index.rst # [docs] Main documentation page +│ │ ├── processing_framework.rst # [docs] Processing workflow documentation +│ │ ├── roadmap.rst # [docs] Development roadmap +│ │ ├── methods/ # [docs] Method documentation +│ │ │ ├── standardisation.rst # [docs] Stage1 standardization +│ │ │ ├── trimming.rst # [docs] Stage2 trimming +│ │ │ ├── time_gridding.rst # [docs] Time gridding methods +│ │ │ ├── clock_offset.rst # [docs] Clock offset analysis +│ │ │ └── ... +│ │ └── _static/ # [docs] Static files (images, CSS) +│ └── Makefile # [docs] Build documentation │ -├── logs/ # [core] Log output from structured logging -│ └── amocarray_*.log # [core] +├── data/ # [data] Sample and test data +│ ├── moor/ # [data] Mooring data directory structure +│ │ ├── proc/ # [data] Processed data +│ │ └── raw/ # [data] Raw instrument files +│ └── climatology/ # [data] Climatological reference data │ -├── .github/ # [ci] GitHub-specific workflows (e.g., Actions) +├── .github/ # [ci] GitHub-specific workflows │ ├── workflows/ -│ │ ├── docs.yml # [ci] Test build documents on *pull-request* -│ │ ├── docs_deploy.yml # [ci] Build and deploy documents on "merge" -│ │ ├── pypi.yml # [ci] Package and release on GitHub.com "release" -│ │ └── test.yml # [ci] Run pytest on tests/test_.py on *pull-request* -│ ├── ISSUE_TEMPLATE.md # [ci, meta] Template for issues on Github -│ └── PULL_REQUEST_TEMPLATE.md # [ci, meta] Template for pull requests on Github +│ │ ├── tests.yml # [ci] Run pytest on pull requests +│ │ └── docs.yml # [ci] Build documentation +│ └── ... │ -├── .gitignore # [meta] Exclude build files, logs, data, etc. -├── requirements.txt # [meta] Pip requirements -├── requirements-dev.txt # [meta] Pip requirements for development (docs, tests, linting) -├── .pre-commit-config.yaml # [style] Instructions for pre-commits to run (linting) -├── pyproject.toml # [ci, meta, style] Build system and config linters -├── CITATION.cff # [meta] So Github can populate the "cite" button -├── README.md # [meta] Project overview and getting started -└── LICENSE # [meta] Open source license (e.g., MIT as default) +├── CLAUDE.md # [meta] Claude Code guidance file +├── .gitignore # [meta] Git ignore patterns +├── requirements.txt # [meta] Core dependencies +├── requirements-dev.txt # [meta] Development dependencies +├── .pre-commit-config.yaml # [style] Pre-commit hooks configuration +├── pyproject.toml # [meta] Build system and project metadata +├── README.md # [meta] Project overview +└── LICENSE # [meta] MIT License ``` -The tags above give an indication of what parts of this template project are used for what purposes, where: -- `# [core]` – Scientific core logic or core functions used across the project. - -- `# [docs]` – Documentation sources, configs, and assets for building project docs. -- `# [test]` – Automated tests for validating functionality. -- `# [demo]` – Notebooks and minimal working examples for demos or tutorials. -- `# [data]` – Sample or test data files. -- `# [ci]` – Continuous integration setup (GitHub Actions). -- `# [style]` – Configuration for code style, linting, and formatting. -- `# [meta]` – Project metadata (e.g., citation info, license, README). - -**Note:** There are also files that you may end up generating but which don't necessarily appear in the project on GitHub.com (due to being ignored by your `.gitignore`). These may include your environment (`venv/`, if you use pip and virtual environments), distribution files `dist/` for building packages to deploy on http://pypi.org, `htmlcov/` for coverage reports for tests, `template_project_efw.egg-info` for editable installs (e.g., `pip install -e .`). - -## 🔍 Notes - -- **Modularity**: Code is split by function (reading, writing, tools). -- **Logging**: All major functions support structured logging to `logs/`. -- **Tests**: Pytest-compatible tests are in `tests/`, with one file per module. -- **Docs**: Sphinx documentation is in `docs/`. - +## 🔍 Architecture Overview + +### Modern Processing Workflow +The current recommended workflow uses: +1. **Stage1** (`stage1.py`) - Format conversion from raw instrument files to CF-NetCDF +2. **Stage2** (`stage2.py`) - Clock corrections and deployment period trimming +3. **Time Gridding** (`time_gridding.py`) - Multi-instrument coordination and filtering +4. **Clock Offset Analysis** (`clock_offset.py`) - Inter-instrument timing validation + +### Legacy RODB Workflow +For RAPID/RODB format compatibility: +- **`process_rodb.py`** - Individual instrument processing functions +- **`mooring_rodb.py`** - Mooring-level stacking and filtering functions +- **`rodb.py`** - RODB format data reader + +### Key Design Principles +- **CF-Compliant**: Uses CF conventions for metadata and variable naming +- **xarray-Based**: Primary data structure throughout the pipeline +- **Modular**: Independent processing stages that can be run separately +- **Configurable**: YAML-driven configuration for processing parameters +- **Reproducible**: Comprehensive logging and processing history tracking + +### File Organization Tags +- `[core]` - Essential processing functionality and utilities +- `[legacy]` - RODB/RAPID legacy format compatibility functions +- `[demo]` - Example notebooks demonstrating workflows +- `[test]` - Automated tests for functionality validation +- `[docs]` - Documentation sources and configuration +- `[config]` - Processing configuration and parameter files +- `[data]` - Sample data and directory structure examples +- `[ci]` - Continuous integration and automation +- `[meta]` - Project metadata and development configuration --- -## 🔰 The Basics (Always Included) +## 🔧 Processing Stages -- **`README.md`** – The first thing people see when they visit your GitHub repo. Use this to explain what your project is, how to install it, and how to get started. -- **`LICENSE`** – Explains what others are allowed to do with your code. This template uses the **MIT License**: - - ✅ Very permissive — allows commercial and private use, modification, and distribution. - - 🔗 More license info: [choosealicense.com](https://choosealicense.com/) -- **`.gitignore`** – Tells Git which files/folders to ignore (e.g., system files, data outputs). -- **`requirements.txt`** – Lists the Python packages your project needs to run. +### Stage 1: Standardization +- **Purpose**: Convert raw instrument files to standardized NetCDF format +- **Input**: Raw files (`.cnv`, `.rsk`, `.dat`, `.mat`) +- **Output**: CF-compliant NetCDF files (`*_raw.nc`) +- **Module**: `stage1.py` ---- +### Stage 2: Temporal Corrections +- **Purpose**: Apply clock corrections and trim to deployment periods +- **Input**: Stage1 files + YAML with clock offsets +- **Output**: Time-corrected files (`*_use.nc`) +- **Module**: `stage2.py` -## 🧰 Python Packaging and Development +### Time Gridding: Mooring Coordination +- **Purpose**: Combine instruments onto common time grids with optional filtering +- **Input**: Stage2 files from multiple instruments +- **Output**: Mooring-level combined datasets +- **Module**: `time_gridding.py` -- **`pyproject.toml`** – A modern configuration file for building, installing, and describing your package (e.g. name, author, dependencies). -- **`requirements-dev.txt`** – Additional tools for developers (testing, linting, formatting, etc.). -- **`template_project/`** – Your main code lives here. Python will treat this as an importable module. -- **`pip install -e .`** – Lets you install your project locally in a way that updates as you edit files. +### Clock Offset Analysis +- **Purpose**: Detect timing errors between instruments on same mooring +- **Input**: Stage1 files from multiple instruments +- **Output**: Recommended clock offset corrections for YAML +- **Module**: `clock_offset.py` --- -## 🧪 Testing and Continuous Integration +## 📊 Data Flow + +``` +Raw Files → Stage1 → Stage2 → Time Gridding → Array Analysis + ↓ ↓ ↓ ↓ ↓ + Various *_raw.nc *_use.nc Combined Transports + Formats Mooring & Products + Datasets +``` -- **`tests/`** – Folder for test files. Use these to make sure your code works as expected. -- **`.github/workflows/`** – GitHub Actions automation: - - `tests.yml` – Runs your tests automatically when you push changes. - - `docs.yml` – Builds your documentation to check for errors. - - `docs_deploy.yml` – Publishes documentation to GitHub Pages. - - `pypi.yml` – Builds and uploads a release to PyPI when you tag a new version. +**Clock Offset Loop**: Stage1 → Clock Analysis → Update YAML → Stage2 --- -## 📝 Documentation +## 🧪 Testing Structure -- **`docs/`** – Contains Sphinx and Markdown files to build your documentation site. - - Run `make html` or use GitHub Actions to generate a website. -- **`.vscode/`** – Optional settings for Visual Studio Code (e.g., interpreter paths). -- **`notebooks/`** – A place to keep example Jupyter notebooks. +Tests are organized by module with comprehensive coverage: +- **Core workflow tests**: `test_stage*.py` +- **Legacy format tests**: `test_*_rodb.py` +- **Utility tests**: `test_tools.py`, `test_convertOS.py` +- **Integration tests**: Via demo notebooks in CI --- -## 🧾 Metadata and Community +## 📚 Documentation Structure -- **`CITATION.cff`** – Machine-readable citation info. Lets GitHub generate a "Cite this repository" button. -- **`CONTRIBUTING.md`** – Guidelines for contributing to the project. Useful if you welcome outside help. -- **`.pre-commit-config.yaml`** – Configuration for running automated checks (e.g., code formatting) before each commit. +- **Methods documentation**: Detailed processing methodology +- **API documentation**: Auto-generated from docstrings +- **Demo notebooks**: Interactive examples and tutorials +- **Development guides**: Roadmap and contribution guidelines --- -## ✅ Summary - -This template is a starting point for research or open-source Python projects. It supports: -- Clean project structure -- Reproducible environments -- Easy testing -- Auto-publishing documentation -- Optional packaging for PyPI - -> 💡 Use what you need. Delete what you don’t. This is your scaffold for doing good, shareable science/code. +This structure supports both modern CF-compliant processing workflows and legacy RAPID/RODB format compatibility, providing a flexible framework for oceanographic mooring data processing. \ No newline at end of file diff --git a/docs/source/roadmap.rst b/docs/source/roadmap.rst index 19ab123..0482ec8 100644 --- a/docs/source/roadmap.rst +++ b/docs/source/roadmap.rst @@ -26,7 +26,7 @@ The OceanArray framework currently provides a solid foundation for oceanographic 🟡 **Partially Implemented** - Stage 3: Auto QC - basic QARTOD functions exist (``tools.py``) - - Stage 4: Calibration - microcat calibration exists (``instrument.py``) + - Stage 4: Calibration - microcat calibration exists (``process_rodb.py``) - Step 2: Vertical Gridding - physics-based interpolation exists (``rapid_interp.py``) ❌ **Documented but Not Implemented** @@ -235,7 +235,7 @@ Priority 3: Enhanced Calibration System **Documentation**: ``docs/source/methods/calibration.rst`` -**Current State**: Basic microcat calibration exists in ``instrument.py``. +**Current State**: Basic microcat calibration exists in ``process_rodb.py``. **Missing Implementation**: - Multi-instrument calibration support (not just microcat) @@ -247,7 +247,7 @@ Priority 3: Enhanced Calibration System **Estimated Effort**: 2-3 weeks **Implementation Plan**: - 1. Expand ``instrument.py`` calibration functions + 1. Expand ``process_rodb.py`` calibration functions 2. Create calibration configuration system 3. Add uncertainty propagation 4. Design calibration workflow automation diff --git a/notebooks/demo_batch_instrument.ipynb b/notebooks/demo_batch_instrument.ipynb index 80c7c7c..67640f8 100644 --- a/notebooks/demo_batch_instrument.ipynb +++ b/notebooks/demo_batch_instrument.ipynb @@ -16,18 +16,7 @@ "id": "6a1920f3", "metadata": {}, "outputs": [], - "source": [ - "from pathlib import Path\n", - "import numpy as np\n", - "import xarray as xr\n", - "import numpy as np\n", - "from oceanarray import readers, mooring, plotters, tools\n", - "from oceanarray import writers, convertOS\n", - "from ioos_qc import qartod\n", - "from ioos_qc.config import Config\n", - "import numpy as np\n", - "import gsw\n" - ] + "source": "from pathlib import Path\nimport numpy as np\nimport xarray as xr\nimport numpy as np\nfrom oceanarray import readers, mooring_rodb, plotters, tools, process_rodb\nimport pandas as pd" }, { "cell_type": "markdown", @@ -84,20 +73,7 @@ "id": "c782730e", "metadata": {}, "outputs": [], - "source": [ - "import importlib\n", - "importlib.reload(mooring)\n", - "# Flag bad data to convert from P to D\n", - "data_dir = Path(\"..\", \"data\")\n", - "files = list(data_dir.glob(\"OS_wb2_9_201114_*_P.nc\"))\n", - "\n", - "ds_list_OS1 = readers.load_dataset(files)\n", - "\n", - "ds_stack = mooring.combine_mooring_OS(ds_list_OS)\n", - "ds_stack = tools.calc_psal(ds_stack)\n", - "\n", - "ds_stack\n" - ] + "source": "import importlib\nimportlib.reload(mooring_rodb)\n# Flag bad data to convert from P to D\ndata_dir = Path(\"..\", \"data\")\nfiles = list(data_dir.glob(\"OS_wb2_9_201114_*_P.nc\"))\n\nds_list_OS1 = readers.load_dataset(files)\n\nds_stack = mooring_rodb.combine_mooring_OS(ds_list_OS)\nds_stack = tools.calc_psal(ds_stack)\n\nds_stack" }, { "cell_type": "code", @@ -529,33 +505,7 @@ "id": "b16cbb1d", "metadata": {}, "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "for i, (idx_leq, *rest) in enumerate(depth_indices):\n", - " plt.figure(figsize=(10, 4))\n", - " # Main index (black)\n", - " main_data = ds_stack.CNDC[:, i].values\n", - " plt.plot(ds_stack.TIME, tools.normalize_by_middle_percent(main_data, percent=95), color='k', label=f'DEPTH={depths[i]}m (main)')\n", - "\n", - " # Next shallower (red)\n", - " if idx_leq is not None:\n", - " shallower_data = ds_stack.CNDC[:, idx_leq].values\n", - " plt.plot(ds_stack.TIME, tools.normalize_by_middle_percent(shallower_data, percent=95), color='r', label=f'DEPTH={depths[idx_leq]}m (shallower)')\n", - "\n", - " # Next deeper (blue)\n", - " if rest and rest[0] is not None:\n", - " idx_gt = rest[0]\n", - " deeper_data = ds_stack.CNDC[:, idx_gt].values\n", - " plt.plot(ds_stack.TIME, tools.normalize_by_middle_percent(deeper_data, percent=95), color='b', label=f'DEPTH={depths[idx_gt]}m (deeper)')\n", - "\n", - " plt.title(f'CNDC at DEPTH={depths[i]}m and neighbors (normalized)')\n", - " plt.xlabel('Time')\n", - " plt.ylabel('Normalized CNDC')\n", - " plt.legend()\n", - " plt.tight_layout()\n", - " plt.show()\n" - ] + "source": "import matplotlib.pyplot as plt\n\nfor i, (idx_leq, *rest) in enumerate(depth_indices):\n plt.figure(figsize=(10, 4))\n # Main index (black)\n main_data = ds_stack.CNDC[:, i].values\n plt.plot(ds_stack.TIME, process_rodb.normalize_by_middle_percent(main_data, percent=95), color='k', label=f'DEPTH={depths[i]}m (main)')\n\n # Next shallower (red)\n if idx_leq is not None:\n shallower_data = ds_stack.CNDC[:, idx_leq].values\n plt.plot(ds_stack.TIME, process_rodb.normalize_by_middle_percent(shallower_data, percent=95), color='r', label=f'DEPTH={depths[idx_leq]}m (shallower)')\n\n # Next deeper (blue)\n if rest and rest[0] is not None:\n idx_gt = rest[0]\n deeper_data = ds_stack.CNDC[:, idx_gt].values\n plt.plot(ds_stack.TIME, process_rodb.normalize_by_middle_percent(deeper_data, percent=95), color='b', label=f'DEPTH={depths[idx_gt]}m (deeper)')\n\n plt.title(f'CNDC at DEPTH={depths[i]}m and neighbors (normalized)')\n plt.xlabel('Time')\n plt.ylabel('Normalized CNDC')\n plt.legend()\n plt.tight_layout()\n plt.show()" }, { "cell_type": "code", @@ -587,4 +537,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/notebooks/demo_check_clock.ipynb b/notebooks/demo_check_clock.ipynb index dccb1ad..7fa11a4 100644 --- a/notebooks/demo_check_clock.ipynb +++ b/notebooks/demo_check_clock.ipynb @@ -4,35 +4,7 @@ "cell_type": "markdown", "id": "71edb016", "metadata": {}, - "source": [ - "## Demo: Clock check - for offsets in instrument clocks\n", - "\n", - "This is an intermediate step between stage1 and stage 2. We are trying to determine whether the timestamps for any of the instruments on the same mooring are incorrect. This is slightly faulty because they could *all* be wrong, unless we are comparing against UTC or have more exact timing knowledge. For more exact timing knowledge, the deployment time and recovery time (anchor release, either dropping from the ship or release from the seabed) have been added to the yaml file in UTC. This can be compared against the times estimated through lag correlations.\n", - "\n", - "### This notebook \n", - "\n", - "**It does not change anything in the data files.** You run this notebook in order to update the field `clock_offset` (in seconds) in the YAML file for each instrument on a mooring. This is normally due to the instruments being set up incorrectly (i.e., with a clock time that did not match UTC).\n", - "\n", - "After determining the appropriate clock offset, then run the stage2 processing to apply the clock offset to the netCDF files for each instrument.\n", - "\n", - "Then, running this notebook again using the stage2 files (`*_use.nc`) should predict no additional clock offsets.\n", - "\n", - "Clock offset is in integer seconds ADDED to the original instrument time. I.e., shifts the record later.\n", - "\n", - "### Main check\n", - "\n", - "- We look at when--according to the instrument clocks--the `temperature` values are cold. This assumes that in the middle of the record, the temperatures are colder than the near-surface temperatures (may fail for polar deployments). Cold is within the mean +- 3 * std of the deep values.\n", - "\n", - "- Then check when the instrument first reads a temperature within those bounds: `start_time`\n", - "- And check when the instrument last reads a temperature within those bounds: `end_time`\n", - "\n", - "Check whether the first timestamp within the cold water for that instrument is similar in time to the first timestamp for another instrument. This should be reasonably good at getting large offsets in clocks.\n", - "\n", - "### Secondary check\n", - "\n", - "- We interpolate data onto a common time grid (rough and ready)\n", - "- Check for lag correlation between instruments, and use this to estimate an offset" - ] + "source": "## Demo: Clock check - for offsets in instrument clocks\n\n**Note: This is the original file by the user. See `demo_clock_offset.ipynb` for a refactored version by Claude.**\n\nThis is an intermediate step between stage1 and stage 2. We are trying to determine whether the timestamps for any of the instruments on the same mooring are incorrect. This is slightly faulty because they could *all* be wrong, unless we are comparing against UTC or have more exact timing knowledge. For more exact timing knowledge, the deployment time and recovery time (anchor release, either dropping from the ship or release from the seabed) have been added to the yaml file in UTC. This can be compared against the times estimated through lag correlations.\n\n### This notebook \n\n**It does not change anything in the data files.** You run this notebook in order to update the field `clock_offset` (in seconds) in the YAML file for each instrument on a mooring. This is normally due to the instruments being set up incorrectly (i.e., with a clock time that did not match UTC).\n\nAfter determining the appropriate clock offset, then run the stage2 processing to apply the clock offset to the netCDF files for each instrument.\n\nThen, running this notebook again using the stage2 files (`*_use.nc`) should predict no additional clock offsets.\n\nClock offset is in integer seconds ADDED to the original instrument time. I.e., shifts the record later.\n\n### Main check\n\n- We look at when--according to the instrument clocks--the `temperature` values are cold. This assumes that in the middle of the record, the temperatures are colder than the near-surface temperatures (may fail for polar deployments). Cold is within the mean +- 3 * std of the deep values.\n\n- Then check when the instrument first reads a temperature within those bounds: `start_time`\n- And check when the instrument last reads a temperature within those bounds: `end_time`\n\nCheck whether the first timestamp within the cold water for that instrument is similar in time to the first timestamp for another instrument. This should be reasonably good at getting large offsets in clocks.\n\n### Secondary check\n\n- We interpolate data onto a common time grid (rough and ready)\n- Check for lag correlation between instruments, and use this to estimate an offset" }, { "cell_type": "code", @@ -654,4 +626,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/notebooks/demo_climatology.ipynb b/notebooks/demo_climatology.ipynb index af15e56..e08efe4 100644 --- a/notebooks/demo_climatology.ipynb +++ b/notebooks/demo_climatology.ipynb @@ -16,14 +16,7 @@ "id": "6a1920f3", "metadata": {}, "outputs": [], - "source": [ - "from pathlib import Path\n", - "import numpy as np\n", - "import xarray as xr\n", - "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "from oceanarray import readers, plotters, tools, convertOS, writers, mooring, rapid_interp\n" - ] + "source": "from pathlib import Path\nimport numpy as np\nimport xarray as xr\nimport numpy as np\nfrom oceanarray import readers, plotters, tools, convertOS, writers, mooring_rodb, rapid_interp\nimport pandas as pd" }, { "cell_type": "markdown", @@ -222,4 +215,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/notebooks/demo_clock_offset.ipynb b/notebooks/demo_clock_offset.ipynb index 861d2f4..6fdd693 100644 --- a/notebooks/demo_clock_offset.ipynb +++ b/notebooks/demo_clock_offset.ipynb @@ -4,21 +4,7 @@ "cell_type": "markdown", "id": "streamlined-demo", "metadata": {}, - "source": [ - "# Demo: Streamlined Clock Offset Analysis\n", - "\n", - "This notebook provides a streamlined version of clock offset analysis for oceanographic instruments.\n", - "It uses the new `oceanarray.clock_offset` module for cleaner, more maintainable code.\n", - "\n", - "## Purpose\n", - "\n", - "This notebook helps determine whether instrument timestamps are incorrect by:\n", - "1. Analyzing deployment timing based on temperature profiles\n", - "2. Performing lag correlation analysis between instruments\n", - "3. Calculating recommended clock offset corrections\n", - "\n", - "**Note:** This notebook does not modify data files. It only analyzes and suggests clock_offset values for the YAML configuration.\n" - ] + "source": "# Demo: Streamlined Clock Offset Analysis\n\n**Note: This is a refactored version by Claude. See `demo_check_clock.ipynb` for the original user file.**\n\nThis notebook provides a streamlined version of clock offset analysis for oceanographic instruments.\nIt uses the new `oceanarray.clock_offset` module for cleaner, more maintainable code.\n\n## Purpose\n\nThis notebook helps determine whether instrument timestamps are incorrect by:\n1. Analyzing deployment timing based on temperature profiles\n2. Performing lag correlation analysis between instruments\n3. Calculating recommended clock offset corrections\n\n**Note:** This notebook does not modify data files. It only analyzes and suggests clock_offset values for the YAML configuration." }, { "cell_type": "code", @@ -356,4 +342,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/notebooks/demo_instrument.ipynb b/notebooks/demo_instrument.ipynb index bf66bd2..86707cf 100644 --- a/notebooks/demo_instrument.ipynb +++ b/notebooks/demo_instrument.ipynb @@ -4,11 +4,7 @@ "cell_type": "markdown", "id": "c6a29764-f39c-431c-8e77-fbc6bfe20f01", "metadata": {}, - "source": [ - "# Demo: instrument-level processing (Stage 1 and Stage 2)\n", - "\n", - "This notebook walks through the instrument-level processing in the oceanarray code.\n" - ] + "source": "# Demo: Instrument-Level Processing (Compact Workflow)\n\nThis notebook walks through the complete instrument-level processing pipeline in the oceanarray codebase, from raw files to science-ready datasets. It demonstrates the same processing steps as `demo_stage1.ipynb` and `demo_stage2.ipynb` but in a more compact, streamlined format.\n\n## Processing Overview\n\n### Stage 1: Format Conversion (`*_raw.nc`)\n- **Purpose**: Convert raw instrument files to standardized NetCDF format\n- **Input**: Raw instrument files (`.cnv`, `.rsk`, `.dat`, `.mat`) \n- **Output**: Standardized NetCDF files (`*_raw.nc`)\n- **Processing**: Uses `oceanarray.stage1.MooringProcessor` - same as `demo_stage1.ipynb`\n\n### Stage 2: Temporal Corrections & Trimming (`*_use.nc`)\n- **Purpose**: Apply clock corrections and trim to deployment periods\n- **Input**: Stage1 files (`*_raw.nc`) + updated YAML with clock offsets\n- **Output**: Time-corrected files (`*_use.nc`)\n- **Processing**: Uses `oceanarray.stage2.process_multiple_moorings_stage2` - same as `demo_stage2.ipynb`\n\n### Stage 3: Calibrations & Corrections (Optional)\n- **Purpose**: Apply sensor-specific calibrations and corrections\n- **Status**: Commented out sections showing how to apply additional calibrations\n\n### Stage 4: Format Conversion (Optional)\n- **Purpose**: Convert to OceanSites or other standardized formats\n- **Status**: Commented out sections for format conversion\n\n## Key Features\n\n- **Compact Format**: Covers the same ground as separate stage notebooks in one place\n- **Instrument-Level Processing**: Each instrument processed independently before mooring-level coordination\n- **Multiple Instrument Types**: Handles various instrument types with analysis functions\n- **Visualization**: Includes plotting and analysis of processed results\n- **Metadata Management**: YAML configuration files drive processing parameters\n\n## Comparison with Other Notebooks\n\n- **vs demo_stage1.ipynb**: Same Stage1 processing but more concise\n- **vs demo_stage2.ipynb**: Same Stage2 processing but integrated workflow \n- **vs demo_step1.ipynb**: Focuses on individual instruments rather than mooring-level time gridding\n\nChoose this notebook if you want a complete instrument processing workflow in one place, or use the separate stage notebooks for more detailed exploration of each processing step.\n\nVersion: 1.0 \nDate: 2025-01-15" }, { "cell_type": "code", @@ -227,10 +223,7 @@ "id": "60fab40c", "metadata": {}, "outputs": [], - "source": [ - "#ds_cal = instrument.apply_microcat_calibration_from_txt(data_dir / 'wb1_12_2015_005.microcat.txt', data_dir / 'wb1_12_2015_6123.use')\n", - "#ds_cal\n" - ] + "source": "#ds_cal = process_rodb.apply_microcat_calibration_from_txt(data_dir / 'wb1_12_2015_005.microcat.txt', data_dir / 'wb1_12_2015_6123.use')\n#ds_cal" }, { "cell_type": "code", @@ -315,4 +308,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/notebooks/demo_instrument_rdb.ipynb b/notebooks/demo_instrument_rdb.ipynb index 3a68f30..3c72366 100644 --- a/notebooks/demo_instrument_rdb.ipynb +++ b/notebooks/demo_instrument_rdb.ipynb @@ -7,7 +7,7 @@ "source": [ "# Demo: instrument-level processing\n", "\n", - "This notebook walks through the instrument-level processing in the oceanarray code.\n" + "This notebook walks through the instrument-level processing in the oceanarray code **based on RAPID data formats**. This is a test to verify the initial steps. The actual code instead uses demo_stage1.ipynb (load data), demo_stage2.ipynb (raw to use).\n" ] }, { @@ -16,14 +16,7 @@ "id": "6a1920f3", "metadata": {}, "outputs": [], - "source": [ - "from pathlib import Path\n", - "import numpy as np\n", - "import xarray as xr\n", - "import numpy as np\n", - "from oceanarray import readers, rodb, instrument, plotters, tools, writers, convertOS\n", - "import pandas as pd\n" - ] + "source": "from pathlib import Path\nimport numpy as np\nimport xarray as xr\nimport numpy as np\nfrom oceanarray import readers, rodb, process_rodb, plotters, tools, writers, convertOS\nimport pandas as pd" }, { "cell_type": "markdown", @@ -65,16 +58,7 @@ "id": "ed615f11", "metadata": {}, "outputs": [], - "source": [ - "\n", - "ds2, dstart, dend = instrument.stage2_trim(ds)\n", - "\n", - "print(\"Deployment start:\", dstart)\n", - "print(\"Deployment end:\", dend)\n", - "\n", - "fig = plotters.plot_microcat(ds2)\n", - "\n" - ] + "source": "ds2, dstart, dend = process_rodb.stage2_trim(ds)\n\nprint(\"Deployment start:\", dstart)\nprint(\"Deployment end:\", dend)\n\nfig = plotters.plot_microcat(ds2)\n" }, { "cell_type": "code", @@ -82,10 +66,7 @@ "id": "6b1ecfad", "metadata": {}, "outputs": [], - "source": [ - "dstart, dend = instrument.trim_suggestion(ds)\n", - "fig, ax = plotters.plot_trim_windows(ds, dstart, dend)\n" - ] + "source": "dstart, dend = process_rodb.trim_suggestion(ds)\nfig, ax = plotters.plot_trim_windows(ds, dstart, dend)" }, { "cell_type": "code", @@ -93,17 +74,7 @@ "id": "660769c4", "metadata": {}, "outputs": [], - "source": [ - "dstart = np.datetime64('2015-11-30T19:00:00')\n", - "dend = np.datetime64('2017-03-28T14:00:00')\n", - "\n", - "ds2, dstart, dend = instrument.stage2_trim(ds, deployment_start=dstart, deployment_end=dend)\n", - "\n", - "print(\"Deployment start:\", dstart)\n", - "print(\"Deployment end:\", dend)\n", - "\n", - "fig = plotters.plot_microcat(ds2)" - ] + "source": "dstart = np.datetime64('2015-11-30T19:00:00')\ndend = np.datetime64('2017-03-28T14:00:00')\n\nds2, dstart, dend = process_rodb.stage2_trim(ds, deployment_start=dstart, deployment_end=dend)\n\nprint(\"Deployment start:\", dstart)\nprint(\"Deployment end:\", dend)\n\nfig = plotters.plot_microcat(ds2)" }, { "cell_type": "markdown", @@ -119,10 +90,7 @@ "id": "60fab40c", "metadata": {}, "outputs": [], - "source": [ - "ds_cal = instrument.apply_microcat_calibration_from_txt(data_dir / 'wb1_12_2015_005.microcat.txt', data_dir / 'wb1_12_2015_6123.use')\n", - "ds_cal\n" - ] + "source": "ds_cal = process_rodb.apply_microcat_calibration_from_txt(data_dir / 'wb1_12_2015_005.microcat.txt', data_dir / 'wb1_12_2015_6123.use')\nds_cal" }, { "cell_type": "code", @@ -218,4 +186,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/notebooks/demo_mooring_rdb.ipynb b/notebooks/demo_mooring_rdb.ipynb index 5f5eb5e..c1bb75c 100644 --- a/notebooks/demo_mooring_rdb.ipynb +++ b/notebooks/demo_mooring_rdb.ipynb @@ -16,16 +16,7 @@ "id": "6a1920f3", "metadata": {}, "outputs": [], - "source": [ - "from pathlib import Path\n", - "import numpy as np\n", - "import gsw\n", - "import xarray as xr\n", - "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "from oceanarray import readers, plotters, tools, convertOS, writers, mooring\n", - "from oceanarray import rapid_interp\n" - ] + "source": "from pathlib import Path\nimport numpy as np\nimport xarray as xr\nimport numpy as np\nfrom oceanarray import readers, plotters, tools, convertOS, writers, mooring_rodb\nimport pandas as pd" }, { "cell_type": "markdown", @@ -45,17 +36,7 @@ "id": "ce860d75", "metadata": {}, "outputs": [], - "source": [ - "data_dir = Path(\"..\", \"data\")\n", - "files = list(data_dir.glob(\"OS_wb2_9_201114_P.nc\"))\n", - "print(files)\n", - "ds_stack = xr.open_dataset(files[0])\n", - "\n", - "#ds_list_OS = readers.load_dataset(files)\n", - "\n", - "#ds_stack = mooring.combine_mooring_OS(ds_list_OS)\n", - "#ds_stack" - ] + "source": "data_dir = Path(\"..\", \"data\")\nfiles = list(data_dir.glob(\"OS_wb2_9_201114_P.nc\"))\nprint(files)\nds_stack = xr.open_dataset(files[0])\n\n#ds_list_OS = readers.load_dataset(files)\n\n#ds_stack = mooring_rodb.combine_mooring_OS(ds_list_OS)\n#ds_stack" }, { "cell_type": "markdown", @@ -77,11 +58,7 @@ "id": "b824bf8c", "metadata": {}, "outputs": [], - "source": [ - "ds_filt = mooring.filter_all_time_vars(ds_stack)\n", - "ds_12h = mooring.interp_to_12hour_grid(ds_filt)\n", - "ds_12h" - ] + "source": "ds_filt = mooring_rodb.filter_all_time_vars(ds_stack)\nds_12h = mooring_rodb.interp_to_12hour_grid(ds_filt)\nds_12h" }, { "cell_type": "code", @@ -230,4 +207,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/notebooks/demo_stage1.ipynb b/notebooks/demo_stage1.ipynb index ee2288c..aef023b 100644 --- a/notebooks/demo_stage1.ipynb +++ b/notebooks/demo_stage1.ipynb @@ -4,13 +4,7 @@ "cell_type": "markdown", "id": "71edb016", "metadata": {}, - "source": [ - "## Demo: Stage1 processing for mooring data\n", - "\n", - "Read the original raw files and convert to netCDF. None to minimal additional processing.\n", - "\n", - "This notebook demonstrates the usage of the refactored `oceanarray.stage1_mooring` module." - ] + "source": "## Demo: Stage1 processing for mooring data\n\n**Stage 1 Overview**: This is the first processing stage that converts raw instrument files into standardized CF-compliant NetCDF format. It handles multiple instrument types using the `ctd_tools` library for reading and the oceanarray framework for metadata management.\n\n### What Stage1 Does:\n- **File Conversion**: Reads raw instrument files (SeaBird, RBR, Nortek, etc.) and converts to NetCDF\n- **Standardization**: Applies CF conventions for variable names, units, and metadata\n- **Format Preservation**: Preserves original data values without modification or filtering\n- **Metadata Enrichment**: Adds deployment information from YAML configuration files\n- **Organization**: Outputs files organized by instrument type in the processed directory\n\n### Input Files:\n- Raw instrument data files (various formats: `.cnv`, `.rsk`, `.dat`, `.mat`)\n- YAML configuration files specifying mooring and instrument metadata\n\n### Output Files:\n- Standardized NetCDF files: `{mooring}_{serial}_raw.nc`\n- Processing log files for debugging and quality assurance\n\n### Processing Flow:\n1. Raw files → ctd_tools readers → xarray.Dataset\n2. Apply CF conventions and metadata from YAML\n3. Preserve all original data values unchanged\n4. Save as NetCDF with standardized naming\n\n**Note**: Stage1 focuses purely on format conversion with no data modification. All processing (filtering, clock corrections, quality control, trimming) happens in Stage2 and subsequent stages.\n\nThis notebook demonstrates the usage of the refactored `oceanarray.stage1` module." }, { "cell_type": "code", @@ -394,4 +388,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/notebooks/demo_stage2.ipynb b/notebooks/demo_stage2.ipynb index afb8836..b853356 100644 --- a/notebooks/demo_stage2.ipynb +++ b/notebooks/demo_stage2.ipynb @@ -4,12 +4,7 @@ "cell_type": "markdown", "id": "71edb016", "metadata": {}, - "source": [ - "## Demo: Stage2 processing for mooring data\n", - "\n", - "- Apply clock offsets\n", - "- Trim to deployment period" - ] + "source": "## Demo: Stage2 processing for mooring data\n\n**Stage 2 Overview**: This is the second processing stage that applies temporal corrections and deployment trimming to Stage1 NetCDF files. It focuses solely on time coordinate modifications and data trimming without changing the actual data values.\n\n### What Stage2 Does:\n- **Clock Offset Corrections**: Applies time corrections specified in YAML configuration files to fix instrument clock errors\n- **Deployment Trimming**: Crops data to the actual deployment period (removes pre/post deployment data) \n- **Time Coordinate Only**: Only modifies the time coordinate - all data values remain unchanged\n- **Metadata Updates**: Updates processing history and adds Stage2-specific attributes\n- **File Renaming**: Converts `*_raw.nc` files to `*_use.nc` files indicating they're ready for analysis\n\n### Input Files:\n- Stage1 NetCDF files: `{mooring}_{serial}_raw.nc`\n- Updated YAML configuration files with `clock_offset` values (determined from clock offset analysis)\n- Deployment timing information in YAML files\n\n### Output Files:\n- Time-corrected NetCDF files: `{mooring}_{serial}_use.nc`\n- Processing logs documenting applied corrections\n\n### Processing Flow:\n1. Load Stage1 (`*_raw.nc`) files\n2. Read clock offset values from YAML configuration\n3. Apply temporal corrections to time coordinate only\n4. Trim data to deployment period (between deployment and recovery times)\n5. Update metadata with processing history\n6. Save as Stage2 (`*_use.nc`) files\n\n### Clock Offset Workflow:\n1. **Analyze**: Use `demo_clock_offset.ipynb` (which uses `oceanarray.clock_offset.py`) to determine timing corrections needed\n2. **Update YAML**: Manually add `clock_offset` values (in seconds) to instrument configurations \n3. **Process Stage2**: Apply corrections and create `*_use.nc` files\n4. **Verify**: Re-run clock offset analysis on `*_use.nc` files to confirm corrections\n\n**Note**: Stage2 only modifies time coordinates and trims data - no quality control or data value modifications are performed. The `*_use.nc` files represent individual instruments with corrected timing and trimmed to deployment periods.\n\nThis notebook demonstrates the usage of the `oceanarray.stage2` module." }, { "cell_type": "code", @@ -52,6 +47,47 @@ "basedir = '/Users/eddifying/Dropbox/data/ifmro_mixsed/ds_data_eleanor/'\n", "results = process_multiple_moorings_stage2(moorlist, basedir)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5bc08fb9", + "metadata": {}, + "outputs": [], + "source": [ + "dir1 = '/Users/eddifying/Dropbox/data/ifmro_mixsed/ds_data_eleanor/moor/proc/dsE_1_2018/microcat'\n", + "fname = 'dsE_1_2018_7518_use.nc'\n", + "from pathlib import Path\n", + "data1 = xr.open_dataset(Path(dir1) / fname)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61f8cb88", + "metadata": {}, + "outputs": [], + "source": [ + "data1" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0c024c1c", + "metadata": {}, + "outputs": [], + "source": [ + "data1.serial_number.values" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3ca0c0bc", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { @@ -75,4 +111,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/notebooks/demo_step1.ipynb b/notebooks/demo_step1.ipynb index 0344576..5fe2863 100644 --- a/notebooks/demo_step1.ipynb +++ b/notebooks/demo_step1.ipynb @@ -58,19 +58,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# Set your data paths here\n", - "basedir = '/Users/eddifying/Dropbox/data/ifmro_mixsed/ds_data_eleanor/'\n", - "mooring_name = 'dsE_1_2018'\n", - "\n", - "# Construct paths\n", - "proc_dir = Path(basedir) / 'moor' / 'proc' / mooring_name\n", - "config_file = proc_dir / f\"{mooring_name}.mooring.yaml\"\n", - "\n", - "print(f\"Processing directory: {proc_dir}\")\n", - "print(f\"Configuration file: {config_file}\")\n", - "print(f\"Config exists: {config_file.exists()}\")" - ] + "source": "# Set your data paths here\nbasedir = '/Users/eddifying/Dropbox/data/ifmro_mixsed/ds_data_eleanor/'\nmooring_name = 'dsE_1_2018'\n\n# Set file suffix for processing\nfile_suffix = '_use' # Change from '_raw' to '_use'\n\n# Construct paths\nproc_dir = Path(basedir) / 'moor' / 'proc' / mooring_name\nconfig_file = proc_dir / f\"{mooring_name}.mooring.yaml\"\n\nprint(f\"Processing directory: {proc_dir}\")\nprint(f\"Configuration file: {config_file}\")\nprint(f\"File suffix: {file_suffix}\")\nprint(f\"Config exists: {config_file.exists()}\")" }, { "cell_type": "code", @@ -111,76 +99,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# Find and examine individual instrument files\n", - "file_suffix = \"_use\"\n", - "instrument_files = []\n", - "instrument_datasets = []\n", - "rows = []\n", - "\n", - "if config_file.exists():\n", - " for inst_config in config.get(\"instruments\", []):\n", - " instrument_type = inst_config.get(\"instrument\", \"unknown\")\n", - " serial = inst_config.get(\"serial\", 0)\n", - " depth = inst_config.get(\"depth\", 0)\n", - "\n", - " # Look for the file\n", - " filename = f\"{mooring_name}_{serial}{file_suffix}.nc\"\n", - " filepath = proc_dir / instrument_type / filename\n", - "\n", - " if filepath.exists():\n", - " ds = xr.open_dataset(filepath)\n", - " instrument_files.append(filepath)\n", - " instrument_datasets.append(ds)\n", - "\n", - " # Time coverage\n", - " t0, t1 = ds.time.values[0], ds.time.values[-1]\n", - " npoints = len(ds.time)\n", - "\n", - " # Median sampling interval\n", - " time_diff = np.diff(ds.time.values) / np.timedelta64(1, \"m\") # in minutes\n", - " median_interval = np.nanmedian(time_diff)\n", - " if median_interval > 1:\n", - " sampling = f\"{median_interval:.1f} min\"\n", - " else:\n", - " sampling = f\"{median_interval*60:.1f} sec\"\n", - "\n", - " # Collect a row for the table\n", - " rows.append(\n", - " {\n", - " \"Instrument\": instrument_type,\n", - " \"Serial\": serial,\n", - " \"Depth [m]\": depth,\n", - " \"File\": filepath.name,\n", - " \"Start\": str(t0)[:19],\n", - " \"End\": str(t1)[:19],\n", - " \"Points\": npoints,\n", - " \"Sampling\": sampling,\n", - " \"Variables\": \", \".join(list(ds.data_vars)),\n", - " }\n", - " )\n", - " else:\n", - " rows.append(\n", - " {\n", - " \"Instrument\": instrument_type,\n", - " \"Serial\": serial,\n", - " \"Depth [m]\": depth,\n", - " \"File\": \"MISSING\",\n", - " \"Start\": \"\",\n", - " \"End\": \"\",\n", - " \"Points\": 0,\n", - " \"Sampling\": \"\",\n", - " \"Variables\": \"\",\n", - " }\n", - " )\n", - "\n", - " # Make a DataFrame summary\n", - " summary = pd.DataFrame(rows)\n", - " pd.set_option(\"display.max_colwidth\", 80) # allow long var lists\n", - " print(summary.to_markdown(index=False))\n", - "\n", - " print(f\"\\nFound {len(instrument_datasets)} instrument datasets\")\n" - ] + "source": "# Find and examine individual instrument files\ninstrument_files = []\ninstrument_datasets = []\nrows = []\n\nif config_file.exists():\n for inst_config in config.get(\"instruments\", []):\n instrument_type = inst_config.get(\"instrument\", \"unknown\")\n serial = inst_config.get(\"serial\", 0)\n depth = inst_config.get(\"depth\", 0)\n\n # Look for the file\n filename = f\"{mooring_name}_{serial}{file_suffix}.nc\"\n filepath = proc_dir / instrument_type / filename\n\n if filepath.exists():\n ds = xr.open_dataset(filepath)\n instrument_files.append(filepath)\n instrument_datasets.append(ds)\n\n # Time coverage\n t0, t1 = ds.time.values[0], ds.time.values[-1]\n npoints = len(ds.time)\n\n # Median sampling interval\n time_diff = np.diff(ds.time.values) / np.timedelta64(1, \"m\") # in minutes\n median_interval = np.nanmedian(time_diff)\n if median_interval > 1:\n sampling = f\"{median_interval:.1f} min\"\n else:\n sampling = f\"{median_interval*60:.1f} sec\"\n\n # Collect a row for the table\n rows.append(\n {\n \"Instrument\": instrument_type,\n \"Serial\": serial,\n \"Depth [m]\": depth,\n \"File\": filepath.name,\n \"Start\": str(t0)[:19],\n \"End\": str(t1)[:19],\n \"Points\": npoints,\n \"Sampling\": sampling,\n \"Variables\": \", \".join(list(ds.data_vars)),\n }\n )\n else:\n rows.append(\n {\n \"Instrument\": instrument_type,\n \"Serial\": serial,\n \"Depth [m]\": depth,\n \"File\": \"MISSING\",\n \"Start\": \"\",\n \"End\": \"\",\n \"Points\": 0,\n \"Sampling\": \"\",\n \"Variables\": \"\",\n }\n )\n\n # Make a DataFrame summary\n summary = pd.DataFrame(rows)\n pd.set_option(\"display.max_colwidth\", 80) # allow long var lists\n print(summary.to_markdown(index=False))\n\n print(f\"\\nFound {len(instrument_datasets)} instrument datasets\")" }, { "cell_type": "markdown", @@ -196,33 +115,14 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# Process without filtering\n", - "print(\"Processing mooring with time gridding only (no filtering)...\")\n", - "print(\"=\"*60)\n", - "\n", - "result = time_gridding_mooring(mooring_name, basedir, file_suffix='_use')\n", - "\n", - "print(f\"\\nProcessing result: {'SUCCESS' if result else 'FAILED'}\")" - ] + "source": "# Process without filtering\nprint(\"Processing mooring with time gridding only (no filtering)...\")\nprint(\"=\"*60)\n\nresult = time_gridding_mooring(mooring_name, basedir, file_suffix=file_suffix)\n\nprint(f\"\\nProcessing result: {'SUCCESS' if result else 'FAILED'}\")" }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# Load and examine the combined dataset\n", - "output_file = proc_dir / f\"{mooring_name}_mooring_use.nc\"\n", - "\n", - "if output_file.exists():\n", - " print(f\"Output file exists: {output_file}\")\n", - "\n", - " # Load the combined dataset\n", - " combined_ds = xr.open_dataset(output_file)\n", - "else:\n", - " print(\"Output file not found - processing may have failed\")" - ] + "source": "# Load and examine the combined dataset\noutput_file = proc_dir / f\"{mooring_name}_mooring{file_suffix}.nc\"\n\nif output_file.exists():\n print(f\"Output file exists: {output_file}\")\n\n # Load the combined dataset\n combined_ds = xr.open_dataset(output_file)\nelse:\n print(\"Output file not found - processing may have failed\")" }, { "cell_type": "markdown", @@ -360,55 +260,14 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# Process with RAPID-style low-pass filtering\n", - "print(\"Processing mooring with 2-day low-pass filtering (RAPID-style)...\")\n", - "print(\"=\"*60)\n", - "print(\"IMPORTANT: Filtering is applied to each instrument on its native time grid\")\n", - "print(\"BEFORE interpolation to preserve data integrity.\")\n", - "print()\n", - "\n", - "filter_params = {\n", - " 'cutoff_days': 2.0, # 2-day cutoff\n", - " 'order': 6 # 6th order Butterworth\n", - "}\n", - "\n", - "result_filtered = time_gridding_mooring(\n", - " mooring_name, basedir,\n", - " file_suffix='_use',\n", - " filter_type='lowpass',\n", - " filter_params=filter_params\n", - ")\n", - "\n", - "print(f\"\\nFiltered processing result: {'SUCCESS' if result_filtered else 'FAILED'}\")" - ] + "source": "# Process with RAPID-style low-pass filtering\nprint(\"Processing mooring with 2-day low-pass filtering (RAPID-style)...\")\nprint(\"=\"*60)\nprint(\"IMPORTANT: Filtering is applied to each instrument on its native time grid\")\nprint(\"BEFORE interpolation to preserve data integrity.\")\nprint()\n\nfilter_params = {\n 'cutoff_days': 2.0, # 2-day cutoff\n 'order': 6 # 6th order Butterworth\n}\n\nresult_filtered = time_gridding_mooring(\n mooring_name, basedir,\n file_suffix=file_suffix,\n filter_type='lowpass',\n filter_params=filter_params\n)\n\nprint(f\"\\nFiltered processing result: {'SUCCESS' if result_filtered else 'FAILED'}\")" }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# Load the filtered dataset\n", - "filtered_output_file = proc_dir / f\"{mooring_name}_mooring_use_lowpass.nc\"\n", - "\n", - "if filtered_output_file.exists():\n", - " print(f\"Filtered output file created: {filtered_output_file}\")\n", - "\n", - " # Load the filtered dataset\n", - " filtered_ds = xr.open_dataset(filtered_output_file)\n", - "\n", - " print(\"\\nFiltered Dataset Attributes:\")\n", - " filter_attrs = {k: v for k, v in filtered_ds.attrs.items()\n", - " if 'filter' in k.lower()}\n", - " for key, value in filter_attrs.items():\n", - " print(f\" {key}: {value}\")\n", - "\n", - " print(f\"\\nDataset shape: {dict(filtered_ds.dims)}\")\n", - "else:\n", - " print(\"Filtered output file not found\")\n", - " filtered_ds = None" - ] + "source": "# Load the filtered dataset\nfiltered_output_file = proc_dir / f\"{mooring_name}_mooring{file_suffix}_lowpass.nc\"\n\nif filtered_output_file.exists():\n print(f\"Filtered output file created: {filtered_output_file}\")\n\n # Load the filtered dataset\n filtered_ds = xr.open_dataset(filtered_output_file)\n\n print(\"\\nFiltered Dataset Attributes:\")\n filter_attrs = {k: v for k, v in filtered_ds.attrs.items()\n if 'filter' in k.lower()}\n for key, value in filter_attrs.items():\n print(f\" {key}: {value}\")\n\n print(f\"\\nDataset shape: {dict(filtered_ds.dims)}\")\nelse:\n print(\"Filtered output file not found\")\n filtered_ds = None" }, { "cell_type": "markdown", @@ -627,4 +486,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/notebooks/dsE_1_2018_microcat_7518_temperature.png b/notebooks/dsE_1_2018_microcat_7518_temperature.png new file mode 100644 index 0000000000000000000000000000000000000000..b6fcd1f8073c8d8eff61ab2e017da9ec090b96e1 GIT binary patch literal 51365 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 literal 0 HcmV?d00001 diff --git a/oceanarray/convertOS.py b/oceanarray/convertOS.py index 6ee983c..1d02d45 100644 --- a/oceanarray/convertOS.py +++ b/oceanarray/convertOS.py @@ -7,8 +7,9 @@ import yaml from oceanarray import utilities # for any shared helpers like date parsing -from oceanarray.utilities import \ - iso8601_duration_from_seconds # or wherever you store it +from oceanarray.utilities import ( + iso8601_duration_from_seconds, +) # or wherever you store it def convert_rodb_to_oceansites( diff --git a/oceanarray/mooring.py b/oceanarray/mooring_rodb.py similarity index 92% rename from oceanarray/mooring.py rename to oceanarray/mooring_rodb.py index 880a100..af2d066 100644 --- a/oceanarray/mooring.py +++ b/oceanarray/mooring_rodb.py @@ -2,6 +2,7 @@ import pandas as pd import xarray as xr from scipy.interpolate import interp1d +from scipy.signal import butter, filtfilt from oceanarray import tools, utilities @@ -226,6 +227,40 @@ def combine_mooring_OS(ds_list): return ds_combined +def auto_filt(y, sr, co, typ="low", fo=6): + """ + Apply a Butterworth digital filter to a data array. + + Parameters + ---------- + y : array_like + Input data array (1D). + sr : float + Sampling rate (Hz or 1/time units of your data). + co : float or tuple of float + Cutoff frequency/frequencies. A scalar for 'low' or 'high', a 2-tuple for 'bandstop'. + typ : str, optional + Filter type: 'low', 'high', or 'bandstop'. Default is 'low'. + fo : int, optional + Filter order. Default is 6. + + Returns + ------- + yf : ndarray + Filtered data array. + """ + # Normalize cutoff frequency to the Nyquist rate + nyquist = 0.5 * sr + if isinstance(co, (list, tuple, np.ndarray)): + wh = [c / nyquist for c in co] + else: + wh = co / nyquist + + b, a = butter(fo, wh, btype=typ) + yf = filtfilt(b, a, y) + return yf + + def filter_all_time_vars(ds, cutoff_days=2, fo=6): """ Apply a lowpass Butterworth filter to all data variables that depend on TIME. @@ -270,7 +305,7 @@ def filter_all_time_vars(ds, cutoff_days=2, fo=6): y_filt[:, i] = np.nan else: try: - y_filt[:, i] = tools.auto_filt(y1d, sr, co, typ="low", fo=fo) + y_filt[:, i] = auto_filt(y1d, sr, co, typ="low", fo=fo) except ValueError: y_filt[:, i] = np.nan # fallback for rare failures diff --git a/oceanarray/instrument.py b/oceanarray/process_rodb.py similarity index 62% rename from oceanarray/instrument.py rename to oceanarray/process_rodb.py index c0c56c4..5556ea2 100644 --- a/oceanarray/instrument.py +++ b/oceanarray/process_rodb.py @@ -11,6 +11,154 @@ DUMMY_VALUE = -9.99e-29 # adjust if needed +def middle_percent(values, percent=95): + """ + Return the lower and upper bounds for the central `percent` of the data. + + Parameters + ---------- + values : array-like + Input data (1D array). NaNs will be ignored. + percent : float + Desired central percentage (e.g., 95 for middle 95%). + + Returns + ------- + tuple + (lower_bound, upper_bound) + """ + values = np.asarray(values) + values = values[~np.isnan(values)] + + if not 0 < percent < 100: + raise ValueError("percent must be between 0 and 100 (exclusive)") + + tail = (100 - percent) / 2 + lower = np.nanpercentile(values, tail) + upper = np.nanpercentile(values, 100 - tail) + return lower, upper + + +def mean_of_middle_percent(values, percent=95): + """ + Compute the mean of values within the central `percent` of the data. + + Parameters + ---------- + values : array-like + Input data (1D array). NaNs will be ignored. + percent : float + Desired central percentage (e.g., 95 for middle 95%). + + Returns + ------- + float + Mean of values within the specified middle percentage. + """ + values = np.asarray(values) + values = values[~np.isnan(values)] + lower, upper = middle_percent(values, percent) + filtered = values[(values >= lower) & (values <= upper)] + return np.mean(filtered) + + +def std_of_middle_percent(values, percent=95): + """ + Compute the standard deviation of values within the central `percent` of the data. + + Parameters + ---------- + values : array-like + Input data (1D array). NaNs will be ignored. + percent : float + Desired central percentage (e.g., 95 for middle 95%). + + Returns + ------- + float + Standard deviation of values within the specified middle percentage. + """ + values = np.asarray(values) + values = values[~np.isnan(values)] + lower, upper = middle_percent(values, percent) + filtered = values[(values >= lower) & (values <= upper)] + return np.std(filtered) + + +def normalize_by_middle_percent(values, percent=95): + """ + Normalize a data series by the mean and standard deviation + of its central `percent` range. + + Parameters + ---------- + values : array-like + Input data (1D array). NaNs are ignored. + percent : float + Central percentage to define the 'middle' of the distribution (e.g., 95). + + Returns + ------- + array + Normalized array with the same shape as input. + """ + values = np.asarray(values) + mask = ~np.isnan(values) + valid_values = values[mask] + + if valid_values.size == 0: + return values # return original if all NaNs + + lower, upper = middle_percent(valid_values, percent) + middle_vals = valid_values[(valid_values >= lower) & (valid_values <= upper)] + + mean_mid = np.mean(middle_vals) + std_mid = np.std(middle_vals) + + if std_mid == 0: + raise ValueError( + "Standard deviation of middle percent is zero — normalization not possible." + ) + + normalized = (values - mean_mid) / std_mid + return normalized + + +def normalize_dataset_by_middle_percent(ds, percent=95): + """ + Normalize all 1D data variables in an xarray Dataset that match the length of TIME, + using the mean and std over the central `percent` of each variable. + + Parameters + ---------- + ds : xarray.Dataset + Input dataset with a 'TIME' coordinate. + percent : float + Percentage of central values to define the middle (e.g., 95 for middle 95%). + + Returns + ------- + xarray.Dataset + New dataset with normalized data variables. + """ + ds_norm = xr.Dataset(attrs=ds.attrs) + time_shape = ds["TIME"].shape + + for var in ds.data_vars: + if ds[var].shape == time_shape: + norm_values = normalize_by_middle_percent(ds[var].values, percent) + ds_norm[var] = xr.DataArray( + norm_values, + coords=ds[var].coords, + dims=ds[var].dims, + attrs=ds[var].attrs, + ) + + # Retain TIME coordinate + ds_norm = ds_norm.assign_coords({"TIME": ds["TIME"]}) + return ds_norm + + def trim_suggestion(ds, percent=95, threshold=6, vars_to_check=["T", "C", "P"]): """ Normalize dataset variables using the middle percentile and determine suggested @@ -34,7 +182,7 @@ def trim_suggestion(ds, percent=95, threshold=6, vars_to_check=["T", "C", "P"]): end_time : np.datetime64 or None Suggested deployment end time. """ - ds_norm = tools.normalize_dataset_by_middle_percent(ds, percent=percent) + ds_norm = normalize_dataset_by_middle_percent(ds, percent=percent) start_candidates = [] end_candidates = [] diff --git a/oceanarray/read_rapid.py b/oceanarray/read_rapid.py deleted file mode 100644 index 1c7cc59..0000000 --- a/oceanarray/read_rapid.py +++ /dev/null @@ -1,133 +0,0 @@ -from pathlib import Path -from typing import Union - -import xarray as xr - -# Import the modules used -from oceanarray import logger, utilities -from oceanarray.logger import log_error, log_info, log_warning -from oceanarray.utilities import apply_defaults - -log = logger.log # Use the global logger - -# Default list of RAPID data files -RAPID_DEFAULT_SOURCE = "https://rapid.ac.uk/sites/default/files/rapid_data/" -RAPID_TRANSPORT_FILES = ["moc_transports.nc"] -RAPID_DEFAULT_FILES = ["moc_transports.nc"] - -# Inline metadata dictionary -RAPID_METADATA = { - "description": "RAPID 26N transport estimates dataset", - "project": "RAPID-AMOC 26°N array", - "web_link": "https://rapid.ac.uk/rapidmoc", - "note": "Dataset accessed and processed via xarray", -} - -# File-specific metadata placeholder -RAPID_FILE_METADATA = { - "moc_transports.nc": { - "data_product": "RAPID layer transport time series", - }, -} - - -@apply_defaults(RAPID_DEFAULT_SOURCE, RAPID_DEFAULT_FILES) -def read_rapid( - source: Union[str, Path, None], - file_list: Union[str, list[str]], - transport_only: bool = True, - data_dir: Union[str, Path, None] = None, - redownload: bool = False, -) -> list[xr.Dataset]: - """Load the RAPID transport dataset from a URL or local file path into an xarray.Dataset. - - Parameters - ---------- - source : str, optional - URL or local path to the NetCDF file(s). - Defaults to the RAPID data repository URL. - file_list : str or list of str, optional - Filename or list of filenames to process. - If None, will attempt to list files in the source directory. - transport_only : bool, optional - If True, restrict to transport files only. - data_dir : str, Path or None, optional - Optional local data directory. - redownload : bool, optional - If True, force redownload of the data. - - Returns - ------- - xr.Dataset - The loaded xarray dataset with basic inline metadata. - - Raises - ------ - ValueError - If the source is neither a valid URL nor a directory path. - FileNotFoundError - If no valid NetCDF files are found in the provided file list. - - """ - log_info("Starting to read RAPID dataset") - - if file_list is None: - file_list = RAPID_DEFAULT_FILES - if transport_only: - file_list = RAPID_TRANSPORT_FILES - if isinstance(file_list, str): - file_list = [file_list] - - local_data_dir = Path(data_dir) if data_dir else utilities.get_default_data_dir() - local_data_dir.mkdir(parents=True, exist_ok=True) - - datasets = [] - - for file in file_list: - if not file.lower().endswith(".nc"): - log_warning("Skipping non-NetCDF file: %s", file) - continue - - download_url = ( - f"{source.rstrip('/')}/{file}" if utilities._is_valid_url(source) else None - ) - - file_path = utilities.resolve_file_path( - file_name=file, - source=source, - download_url=download_url, - local_data_dir=local_data_dir, - redownload=redownload, - ) - - try: - log_info("Opening RAPID dataset: %s", file_path) - ds = xr.open_dataset(file_path) - except Exception as e: - log_error("Failed to open NetCDF file: %s: %s", file_path, e) - raise FileNotFoundError(f"Failed to open NetCDF file: {file_path}: {e}") - - file_metadata = RAPID_FILE_METADATA.get(file, {}) - log_info("Attaching metadata to RAPID dataset from file: %s", file) - utilities.safe_update_attrs( - ds, - { - "source_file": file, - "source_path": str(file_path), - **RAPID_METADATA, - **file_metadata, - }, - ) - if "time" in ds.dims or "time" in ds.coords: - log_info("Renaming 'time' dimension/coordinate to 'TIME'") - ds = ds.rename({"time": "TIME"}) - - datasets.append(ds) - - if not datasets: - log_error("No valid RAPID NetCDF files found in %s", file_list) - raise FileNotFoundError(f"No valid RAPID NetCDF files found in {file_list}") - - log_info("Successfully loaded %d RAPID dataset(s)", len(datasets)) - - return datasets diff --git a/oceanarray/readers.py b/oceanarray/readers.py index f6a32b0..c0884ea 100644 --- a/oceanarray/readers.py +++ b/oceanarray/readers.py @@ -4,7 +4,6 @@ import xarray as xr from oceanarray.logger import log_info -from oceanarray.read_rapid import read_rapid DUMMY_VALUES = [1e32, -9.0, -9.9] @@ -125,31 +124,3 @@ def rodbload_old(filepath: Path, variables: list[str]) -> xr.Dataset: return ds -def _get_reader(array_name: str): - """Return the reader function for the given array name. - - Parameters - ---------- - array_name : str - The name of the observing array. - - Returns - ------- - function - Reader function corresponding to the given array name. - - Raises - ------ - ValueError - If an unknown array name is provided. - - """ - readers = { - "rapid": read_rapid, - } - try: - return readers[array_name.lower()] - except KeyError: - raise ValueError( - f"Unknown array name: {array_name}. Valid options are: {list(readers.keys())}", - ) diff --git a/oceanarray/stage1.py b/oceanarray/stage1.py index 67536e0..50fcef5 100644 --- a/oceanarray/stage1.py +++ b/oceanarray/stage1.py @@ -6,8 +6,13 @@ from typing import Any, Dict, List, Optional, Tuple import yaml -from ctd_tools.readers import (NortekAsciiReader, RbrAsciiReader, - RbrRskAutoReader, SbeAsciiReader, SbeCnvReader) +from ctd_tools.readers import ( + NortekAsciiReader, + RbrAsciiReader, + RbrRskAutoReader, + SbeAsciiReader, + SbeCnvReader, +) from ctd_tools.writers import NetCdfWriter diff --git a/oceanarray/stage2.py b/oceanarray/stage2.py index 3757b1d..20bf4fd 100644 --- a/oceanarray/stage2.py +++ b/oceanarray/stage2.py @@ -102,21 +102,116 @@ def _trim_to_deployment_window( return dataset + def _extract_metadata_from_filepath( + self, filepath: Path, mooring_name: str + ) -> Dict[str, Any]: + """Extract metadata from filepath when not available in YAML or dataset. + + Expected pattern: {instrument_type}/{mooring_name}_{serial}_raw.nc + """ + fallback_metadata = {} + + # Extract instrument type from parent directory + instrument_type = filepath.parent.name + fallback_metadata["instrument"] = instrument_type + + # Extract serial number from filename + filename = filepath.stem # Remove .nc extension + if filename.endswith("_raw"): + filename = filename[:-4] # Remove _raw suffix + + # Pattern: mooring_name_serial + if filename.startswith(f"{mooring_name}_"): + serial_str = filename[len(f"{mooring_name}_") :] + try: + serial = int(serial_str) + fallback_metadata["serial"] = serial + self._log_print( + f"Extracted from filename - instrument: {instrument_type}, serial: {serial}" + ) + except ValueError: + self._log_print( + f"WARNING: Could not parse serial number from filename: {filename}" + ) + + return fallback_metadata + + def _get_figure_naming_info( + self, dataset: xr.Dataset, mooring_name: str + ) -> Dict[str, str]: + """Get information needed for figure naming convention. + + Returns dict with mooring_name, instrument, serial for creating + figure names like: dsE_1_2018_microcat_7518_ctd.png + """ + instrument = str(dataset.get("instrument", "unknown").values) + serial = str(int(dataset.get("serial_number", 0).values)) + + return { + "mooring_name": mooring_name, + "instrument": instrument, + "serial": serial, + } + def _add_missing_metadata( - self, dataset: xr.Dataset, instrument_config: Dict[str, Any] + self, + dataset: xr.Dataset, + instrument_config: Dict[str, Any], + filepath: Path, + mooring_name: str, ) -> xr.Dataset: - """Add any missing metadata variables to dataset.""" - # Add instrument depth if missing - if "InstrDepth" not in dataset.variables and "depth" in instrument_config: - dataset["InstrDepth"] = instrument_config["depth"] + """Add any missing metadata variables to dataset with fallback extraction.""" + + # Get metadata from YAML config (highest priority) + yaml_instrument = instrument_config.get("instrument") + yaml_serial = instrument_config.get("serial") + yaml_depth = instrument_config.get("depth", 0) + + # Check if we need fallback for any missing fields + needs_instrument_fallback = yaml_instrument is None + needs_serial_fallback = yaml_serial is None + + fallback_used = False + final_instrument = yaml_instrument + final_serial = yaml_serial + + if needs_instrument_fallback or needs_serial_fallback: + self._log_print( + "Some metadata missing from YAML, attempting extraction from filepath..." + ) + fallback_metadata = self._extract_metadata_from_filepath( + filepath, mooring_name + ) + + # Use fallback only for the missing fields + if needs_instrument_fallback and "instrument" in fallback_metadata: + final_instrument = fallback_metadata["instrument"] + self._log_print(f"Using fallback instrument type: {final_instrument}") + fallback_used = True + + if needs_serial_fallback and "serial" in fallback_metadata: + final_serial = fallback_metadata["serial"] + self._log_print(f"Using fallback serial number: {final_serial}") + fallback_used = True - # Add instrument type if missing - if "instrument" not in dataset.variables and "instrument" in instrument_config: - dataset["instrument"] = instrument_config["instrument"] + # Add metadata to dataset if missing + if "InstrDepth" not in dataset.variables: + dataset["InstrDepth"] = yaml_depth - # Add serial number if missing - if "serial_number" not in dataset.variables and "serial" in instrument_config: - dataset["serial_number"] = instrument_config["serial"] + if "instrument" not in dataset.variables and final_instrument is not None: + dataset["instrument"] = final_instrument + + if "serial_number" not in dataset.variables and final_serial is not None: + dataset["serial_number"] = final_serial + + # Add history note if fallback was used + if fallback_used: + history_note = "non-standard enrichment of metadata from filename patterns" + if "history" in dataset.attrs: + dataset.attrs["history"] += f"; {history_note}" + else: + dataset.attrs["history"] = history_note + self._log_print(f"Added history note: {history_note}") return dataset @@ -193,8 +288,10 @@ def _process_instrument( # Create a copy to modify dataset = ds.load() - # Add missing metadata - dataset = self._add_missing_metadata(dataset, instrument_config) + # Add missing metadata with fallback extraction + dataset = self._add_missing_metadata( + dataset, instrument_config, raw_filepath, mooring_name + ) # Clean unnecessary variables dataset = self._clean_unnecessary_variables(dataset) diff --git a/oceanarray/tools.py b/oceanarray/tools.py index f5e10fc..4ede4e2 100644 --- a/oceanarray/tools.py +++ b/oceanarray/tools.py @@ -4,15 +4,13 @@ import numpy as np import pandas as pd import xarray as xr -from scipy.signal import butter, filtfilt +from scipy.signal import find_peaks from oceanarray import utilities # Initialize logging _log = logging.getLogger(__name__) -from scipy.signal import find_peaks - def lag_correlation(x, y, max_lag, min_overlap=10): """Pearson correlation at integer lags in [-max_lag, max_lag].""" @@ -116,89 +114,6 @@ def find_cold_entry_exit( return time[s0], time[eL], thr -def find_deployment(ds, var_name="temperature"): - pre_deploy_before = [] - start_deployment = [] - end_deployment = [] - mooring_rising = [] - split_vals = [] - split_vals2 = [] - N_LEVELS = ds["N_LEVELS"] - for i in range(0, len(N_LEVELS)): - if var_name in ds and ds[var_name].dims == ("time", "N_LEVELS"): - data1 = ds[var_name][:, i] - - splitter = split_value(data1) - x, y, split2 = find_cold_entry_exit(ds["time"], data1) - - # Assume the deployment data are below the threshold - idx_less_than = np.where(data1 < splitter) - idx_more_than = np.where(data1 > splitter) - - # Find out whether idx_less_than or idx_more_than contains the first non-Nan value - if idx_less_than[0].size > 0 and ( - idx_more_than[0].size == 0 or idx_less_than[0][0] < idx_more_than[0][0] - ): - # idx_less_than starts sooner (i.e. contains the pre-deployment) - idx = idx_more_than - condition = ">" - else: - idx = idx_less_than - condition = "<" - - first_deep_time = ds["time"][idx].values[0] if idx[0].size > 0 else None - time_before = ds["time"][idx[0][0] - 1].values if idx[0].size > 0 else None - # End of deployment + one after - last_deep_time = ds["time"][idx].values[-1] if idx[0].size > 0 else None - time_after = ds["time"][idx[0][-1] + 1].values if idx[0].size > 0 else None - - pre_deploy_before.append(time_before) - start_deployment.append(first_deep_time) - end_deployment.append(last_deep_time) - mooring_rising.append(time_after) - split_vals.append(splitter) - split_vals2.append(split2) - - # Initialise new variable in dataset ds, called start_time with same size as ds[var_name] - if "start_time" not in ds: - # Use proper datetime64 unit specification - ds["start_time"] = ( - ("N_LEVELS"), - np.full(ds["N_LEVELS"].shape, np.datetime64("NaT", "ns")), - ) - ds["end_time"] = ( - ("N_LEVELS"), - np.full(ds["N_LEVELS"].shape, np.datetime64("NaT", "ns")), - ) - if "split_value" not in ds: - ds["split_value"] = ( - ("N_LEVELS"), - np.full(ds["N_LEVELS"].shape, np.nan), - ) - if "split_value2" not in ds: - ds["split_value2"] = ( - ("N_LEVELS"), - np.full(ds["N_LEVELS"].shape, np.nan), - ) - ds["start_time"][i] = first_deep_time - ds["end_time"][i] = last_deep_time - ds["split_value"][i] = splitter - ds["split_value2"][i] = split2 - - print( - f"{i}/{data1['serial_number'].values}:{data1['instrument'].values}: Split at {splitter:1.2f}. Start after {first_deep_time}. End with {last_deep_time}." - ) - - else: - pre_deploy_before.append(np.datetime64("NaT", "ns")) - start_deployment.append(np.datetime64("NaT", "ns")) - end_deployment.append(np.datetime64("NaT", "ns")) - mooring_rising.append(np.datetime64("NaT", "ns")) - split_vals.append(np.nan) - split_vals2.append(np.nan) - - return ds - def calc_psal(ds): if "PSAL" not in ds: @@ -434,186 +349,7 @@ def process_dataset( return ds_standard -def auto_filt(y, sr, co, typ="low", fo=6): - """ - Apply a Butterworth digital filter to a data array. - - Parameters - ---------- - y : array_like - Input data array (1D). - sr : float - Sampling rate (Hz or 1/time units of your data). - co : float or tuple of float - Cutoff frequency/frequencies. A scalar for 'low' or 'high', a 2-tuple for 'bandstop'. - typ : str, optional - Filter type: 'low', 'high', or 'bandstop'. Default is 'low'. - fo : int, optional - Filter order. Default is 6. - - Returns - ------- - yf : ndarray - Filtered data array. - """ - # Normalize cutoff frequency to the Nyquist rate - nyquist = 0.5 * sr - if isinstance(co, (list, tuple, np.ndarray)): - wh = [c / nyquist for c in co] - else: - wh = co / nyquist - - b, a = butter(fo, wh, btype=typ) - yf = filtfilt(b, a, y) - return yf - - -def normalize_dataset_by_middle_percent(ds, percent=95): - """ - Normalize all 1D data variables in an xarray Dataset that match the length of TIME, - using the mean and std over the central `percent` of each variable. - - Parameters - ---------- - ds : xarray.Dataset - Input dataset with a 'TIME' coordinate. - percent : float - Percentage of central values to define the middle (e.g., 95 for middle 95%). - - Returns - ------- - xarray.Dataset - New dataset with normalized data variables. - """ - ds_norm = xr.Dataset(attrs=ds.attrs) - time_shape = ds["TIME"].shape - - for var in ds.data_vars: - if ds[var].shape == time_shape: - norm_values = normalize_by_middle_percent(ds[var].values, percent) - ds_norm[var] = xr.DataArray( - norm_values, - coords=ds[var].coords, - dims=ds[var].dims, - attrs=ds[var].attrs, - ) - - # Retain TIME coordinate - ds_norm = ds_norm.assign_coords({"TIME": ds["TIME"]}) - return ds_norm - - -def normalize_by_middle_percent(values, percent=95): - """ - Normalize a data series by the mean and standard deviation - of its central `percent` range. - - Parameters - ---------- - values : array-like - Input data (1D array). NaNs are ignored. - percent : float - Central percentage to define the 'middle' of the distribution (e.g., 95). - - Returns - ------- - array - Normalized array with the same shape as input. - """ - values = np.asarray(values) - mask = ~np.isnan(values) - valid_values = values[mask] - - if valid_values.size == 0: - return values # return original if all NaNs - - lower, upper = middle_percent(valid_values, percent) - middle_vals = valid_values[(valid_values >= lower) & (valid_values <= upper)] - - mean_mid = np.mean(middle_vals) - std_mid = np.std(middle_vals) - - if std_mid == 0: - raise ValueError( - "Standard deviation of middle percent is zero — normalization not possible." - ) - - normalized = (values - mean_mid) / std_mid - return normalized - - -def std_of_middle_percent(values, percent=95): - """ - Compute the standard deviation of values within the central `percent` of the data. - - Parameters - ---------- - values : array-like - Input data (1D array). NaNs will be ignored. - percent : float - Desired central percentage (e.g., 95 for middle 95%). - - Returns - ------- - float - Standard deviation of values within the specified middle percentage. - """ - values = np.asarray(values) - values = values[~np.isnan(values)] - lower, upper = middle_percent(values, percent) - filtered = values[(values >= lower) & (values <= upper)] - return np.std(filtered) - - -def mean_of_middle_percent(values, percent=95): - """ - Compute the mean of values within the central `percent` of the data. - - Parameters - ---------- - values : array-like - Input data (1D array). NaNs will be ignored. - percent : float - Desired central percentage (e.g., 95 for middle 95%). - - Returns - ------- - float - Mean of values within the specified middle percentage. - """ - values = np.asarray(values) - values = values[~np.isnan(values)] - lower, upper = middle_percent(values, percent) - filtered = values[(values >= lower) & (values <= upper)] - return np.mean(filtered) - - -def middle_percent(values, percent=95): - """ - Return the lower and upper bounds for the central `percent` of the data. - - Parameters - ---------- - values : array-like - Input data (1D array). NaNs will be ignored. - percent : float - Desired central percentage (e.g., 95 for middle 95%). - - Returns - ------- - tuple - (lower_bound, upper_bound) - """ - values = np.asarray(values) - values = values[~np.isnan(values)] - - if not 0 < percent < 100: - raise ValueError("percent must be between 0 and 100 (exclusive)") - tail = (100 - percent) / 2 - lower = np.nanpercentile(values, tail) - upper = np.nanpercentile(values, 100 - tail) - return lower, upper def calc_ds_difference(ds1, ds2): diff --git a/tests/test_convertOS.py b/tests/test_convertOS.py index ab17c4d..aec16fd 100644 --- a/tests/test_convertOS.py +++ b/tests/test_convertOS.py @@ -4,10 +4,13 @@ import yaml from oceanarray import convertOS -from oceanarray.convertOS import (add_fixed_coordinates, - add_variable_attributes, - convert_rodb_to_oceansites, - format_time_variable, parse_rodb_metadata) +from oceanarray.convertOS import ( + add_fixed_coordinates, + add_variable_attributes, + convert_rodb_to_oceansites, + format_time_variable, + parse_rodb_metadata, +) @pytest.fixture diff --git a/tests/test_mooring.py b/tests/test_mooring_rodb.py similarity index 96% rename from tests/test_mooring.py rename to tests/test_mooring_rodb.py index 9da6ecb..af7a48b 100644 --- a/tests/test_mooring.py +++ b/tests/test_mooring_rodb.py @@ -3,10 +3,15 @@ import pytest import xarray as xr -from oceanarray import mooring -from oceanarray.mooring import ( # Adjust import as needed - filter_all_time_vars, find_common_attributes, find_time_vars, - get_12hourly_time_grid, interp_to_12hour_grid, stack_instruments) +from oceanarray import mooring_rodb +from oceanarray.mooring_rodb import ( # Adjust import as needed + filter_all_time_vars, + find_common_attributes, + find_time_vars, + get_12hourly_time_grid, + interp_to_12hour_grid, + stack_instruments, +) def create_mock_os_dataset(depth, serial_number, source_file): @@ -39,7 +44,7 @@ def create_mock_os_dataset(depth, serial_number, source_file): def test_combine_mooring(): ds1 = create_mock_os_dataset(100.0, "1234", "file1.nc") ds2 = create_mock_os_dataset(200.0, "5678", "file2.nc") - combined = mooring.combine_mooring_OS([ds1, ds2]) + combined = mooring_rodb.combine_mooring_OS([ds1, ds2]) assert "TEMP" in combined assert "serial_number" not in combined.attrs diff --git a/tests/test_instrument.py b/tests/test_process_rodb.py similarity index 95% rename from tests/test_instrument.py rename to tests/test_process_rodb.py index 2cd6e5e..82a08a5 100644 --- a/tests/test_instrument.py +++ b/tests/test_process_rodb.py @@ -5,8 +5,11 @@ import pandas as pd import xarray as xr -from oceanarray.instrument import (apply_microcat_calibration_from_txt, - stage2_trim, trim_suggestion) +from oceanarray.process_rodb import ( + apply_microcat_calibration_from_txt, + stage2_trim, + trim_suggestion, +) from oceanarray.rodb import rodbload @@ -65,9 +68,9 @@ def test_apply_microcat_with_flags(tmp_path): ds.to_netcdf(use_path) # write as .nc, simulate reading in `rodbload` # Patch rodbload to return this dataset - from oceanarray import instrument + from oceanarray import process_rodb - instrument.rodb.rodbload = lambda _: ds + process_rodb.rodb.rodbload = lambda _: ds ds_cal = apply_microcat_calibration_from_txt(txt, use_path) assert "T" in ds_cal and np.allclose( diff --git a/tests/test_rodb.py b/tests/test_rodb.py index 630d464..c09e2e2 100644 --- a/tests/test_rodb.py +++ b/tests/test_rodb.py @@ -5,7 +5,11 @@ import xarray as xr from oceanarray.rodb import ( # Replace with actual function names - format_latlon, parse_rodb_keys_file, rodbload, rodbsave) + format_latlon, + parse_rodb_keys_file, + rodbload, + rodbsave, +) def test_parse_rodb_keys_file(tmp_path): diff --git a/tests/test_stage1.py b/tests/test_stage1.py index 19a230c..c3c5547 100644 --- a/tests/test_stage1.py +++ b/tests/test_stage1.py @@ -12,8 +12,11 @@ import xarray as xr import yaml -from oceanarray.stage1 import (MooringProcessor, process_multiple_moorings, - stage1_mooring) +from oceanarray.stage1 import ( + MooringProcessor, + process_multiple_moorings, + stage1_mooring, +) class TestMooringProcessor: @@ -145,6 +148,140 @@ def test_get_netcdf_writer_params(self, processor): assert params["chunk_time"] == 3600 assert params["complevel"] == 5 + def test_clean_dataset_variables_sbe_cnv(self, processor): + """Test cleaning dataset variables for SBE CNV files.""" + # Create a mock dataset with variables that should be removed + import numpy as np + + ds = xr.Dataset({ + "temperature": (["time"], [20.0, 21.0, 22.0]), + "pressure": (["time"], [100.0, 101.0, 102.0]), + "potential_temperature": (["time"], [19.8, 20.8, 21.8]), # Should be removed + "density": (["time"], [1025.0, 1026.0, 1027.0]), # Should be removed + "julian_days_offset": (["time"], [1, 2, 3]), # Should be removed + "salinity": (["time"], [35.0, 35.1, 35.2]), # Should be kept + }, coords={ + "time": (["time"], np.arange(3)), + "depth": (["time"], [100.0, 100.0, 100.0]), # Should be removed + "latitude": 60.0, # Should be removed + "longitude": -30.0, # Should be removed + }) + + # Clean the dataset + cleaned_ds = processor._clean_dataset_variables(ds, "sbe-cnv") + + # Check that unwanted variables were removed + assert "potential_temperature" not in cleaned_ds.variables + assert "density" not in cleaned_ds.variables + assert "julian_days_offset" not in cleaned_ds.variables + + # Check that wanted variables were kept + assert "temperature" in cleaned_ds.variables + assert "pressure" in cleaned_ds.variables + assert "salinity" in cleaned_ds.variables + + # Check that unwanted coordinates were removed + assert "depth" not in cleaned_ds.coords + assert "latitude" not in cleaned_ds.coords + assert "longitude" not in cleaned_ds.coords + + # Check that time coordinate was kept + assert "time" in cleaned_ds.coords + + def test_clean_dataset_variables_unknown_type(self, processor): + """Test cleaning dataset variables for unknown file type.""" + import numpy as np + + ds = xr.Dataset({ + "temperature": (["time"], [20.0, 21.0, 22.0]), + "unwanted_var": (["time"], [1.0, 2.0, 3.0]), + }, coords={ + "time": (["time"], np.arange(3)), + "unwanted_coord": (["time"], [100.0, 100.0, 100.0]), + }) + + # Clean with unknown file type (should not remove anything) + cleaned_ds = processor._clean_dataset_variables(ds, "unknown-type") + + # Check that all variables and coordinates are preserved + assert "temperature" in cleaned_ds.variables + assert "unwanted_var" in cleaned_ds.variables + assert "time" in cleaned_ds.coords + assert "unwanted_coord" in cleaned_ds.coords + + def test_add_global_attributes_complete(self, processor): + """Test adding global attributes with complete YAML data.""" + import numpy as np + + # Create a simple dataset + ds = xr.Dataset({ + "temperature": (["time"], [20.0, 21.0, 22.0]) + }, coords={ + "time": (["time"], np.arange(3)) + }) + + yaml_data = { + "name": "test_mooring", + "waterdepth": 1000, + "longitude": -30.0, + "latitude": 60.0, + "deployment_latitude": "60 00.000 N", + "deployment_longitude": "030 00.000 W", + "deployment_time": "2018-08-12T08:00:00", + "seabed_latitude": "59 59.500 N", + "seabed_longitude": "030 00.500 W", + "recovery_time": "2018-08-26T20:47:24", + } + + # Add global attributes + updated_ds = processor._add_global_attributes(ds, yaml_data) + + # Check that all attributes were added correctly + assert updated_ds.attrs["mooring_name"] == "test_mooring" + assert updated_ds.attrs["waterdepth"] == 1000 + assert updated_ds.attrs["longitude"] == -30.0 + assert updated_ds.attrs["latitude"] == 60.0 + assert updated_ds.attrs["deployment_latitude"] == "60 00.000 N" + assert updated_ds.attrs["deployment_longitude"] == "030 00.000 W" + assert updated_ds.attrs["deployment_time"] == "2018-08-12T08:00:00" + assert updated_ds.attrs["seabed_latitude"] == "59 59.500 N" + assert updated_ds.attrs["seabed_longitude"] == "030 00.500 W" + assert updated_ds.attrs["recovery_time"] == "2018-08-26T20:47:24" + + def test_add_global_attributes_minimal(self, processor): + """Test adding global attributes with minimal YAML data.""" + import numpy as np + + # Create a simple dataset + ds = xr.Dataset({ + "temperature": (["time"], [20.0, 21.0, 22.0]) + }, coords={ + "time": (["time"], np.arange(3)) + }) + + # Minimal YAML data (only required fields) + yaml_data = { + "name": "minimal_mooring", + "waterdepth": 500, + } + + # Add global attributes + updated_ds = processor._add_global_attributes(ds, yaml_data) + + # Check that required attributes were added + assert updated_ds.attrs["mooring_name"] == "minimal_mooring" + assert updated_ds.attrs["waterdepth"] == 500 + + # Check that default values were used for missing fields + assert updated_ds.attrs["longitude"] == 0.0 + assert updated_ds.attrs["latitude"] == 0.0 + assert updated_ds.attrs["deployment_latitude"] == "00 00.000 N" + assert updated_ds.attrs["deployment_longitude"] == "000 00.000 W" + assert updated_ds.attrs["deployment_time"] == "YYYY-mm-ddTHH:MM:ss" + assert updated_ds.attrs["seabed_latitude"] == "00 00.000 N" + assert updated_ds.attrs["seabed_longitude"] == "000 00.000 W" + assert updated_ds.attrs["recovery_time"] == "YYYY-mm-ddTHH:MM:ss" + class TestRealDataProcessing: """Integration tests using real data files.""" diff --git a/tests/test_stage2.py b/tests/test_stage2.py index 20ee482..b968c35 100644 --- a/tests/test_stage2.py +++ b/tests/test_stage2.py @@ -21,9 +21,11 @@ import xarray as xr import yaml -from oceanarray.stage2 import (Stage2Processor, - process_multiple_moorings_stage2, - stage2_mooring) +from oceanarray.stage2 import ( + Stage2Processor, + process_multiple_moorings_stage2, + stage2_mooring, +) class TestStage2Processor: @@ -246,7 +248,7 @@ def test_trim_to_deployment_window_empty_result( # Should result in empty dataset assert len(result.time) == 0 - def test_add_missing_metadata(self, processor, sample_raw_dataset): + def test_add_missing_metadata(self, processor, sample_raw_dataset, tmp_path): """Test adding missing metadata variables.""" # Remove some metadata to test adding it back ds = sample_raw_dataset.copy() @@ -254,13 +256,22 @@ def test_add_missing_metadata(self, processor, sample_raw_dataset): instrument_config = {"depth": 150, "instrument": "new_microcat", "serial": 9999} - result = processor._add_missing_metadata(ds, instrument_config) + # Create a mock filepath for testing + mock_filepath = tmp_path / "microcat" / "test_mooring_7518_raw.nc" + mock_filepath.parent.mkdir(parents=True) + mock_filepath.touch() + + result = processor._add_missing_metadata( + ds, instrument_config, mock_filepath, "test_mooring" + ) assert result["InstrDepth"].values == 150 assert result["instrument"].values == "new_microcat" assert result["serial_number"].values == 9999 - def test_add_missing_metadata_no_overwrite(self, processor, sample_raw_dataset): + def test_add_missing_metadata_no_overwrite( + self, processor, sample_raw_dataset, tmp_path + ): """Test that existing metadata is not overwritten.""" instrument_config = { "depth": 999, @@ -268,13 +279,89 @@ def test_add_missing_metadata_no_overwrite(self, processor, sample_raw_dataset): "serial": 8888, } - result = processor._add_missing_metadata(sample_raw_dataset, instrument_config) + # Create a mock filepath for testing + mock_filepath = tmp_path / "microcat" / "test_mooring_7518_raw.nc" + mock_filepath.parent.mkdir(parents=True) + mock_filepath.touch() + + result = processor._add_missing_metadata( + sample_raw_dataset, instrument_config, mock_filepath, "test_mooring" + ) # Should keep original values assert result["InstrDepth"].values == 100 assert result["instrument"].values == "microcat" assert result["serial_number"].values == 7518 + def test_fallback_metadata_extraction(self, processor, tmp_path): + """Test fallback metadata extraction from filepath.""" + # Create dataset missing metadata + import numpy as np + import xarray as xr + + ds = xr.Dataset( + { + "temperature": (["time"], np.random.random(10)), + }, + coords={"time": pd.date_range("2018-01-01", periods=10, freq="h")}, + ) + + # YAML config is missing instrument and serial (None, not provided) + instrument_config = {"depth": 150} # Only depth provided + + # Create filepath that contains metadata + mock_filepath = tmp_path / "sbe56" / "dsE_1_2018_6363_raw.nc" + mock_filepath.parent.mkdir(parents=True) + mock_filepath.touch() + + result = processor._add_missing_metadata( + ds, instrument_config, mock_filepath, "dsE_1_2018" + ) + + # Should extract from filepath + assert result["instrument"].values == "sbe56" + assert result["serial_number"].values == 6363 + assert result["InstrDepth"].values == 150 # From YAML + + # Should add history note about non-standard enrichment + assert ( + "non-standard enrichment of metadata from filename patterns" + in result.attrs["history"] + ) + + def test_no_fallback_when_yaml_complete(self, processor, tmp_path): + """Test that fallback is not used when YAML provides complete metadata.""" + # Create dataset missing metadata + import numpy as np + import xarray as xr + + ds = xr.Dataset( + { + "temperature": (["time"], np.random.random(10)), + }, + coords={"time": pd.date_range("2018-01-01", periods=10, freq="h")}, + ) + + # YAML config has complete metadata + instrument_config = {"depth": 150, "instrument": "microcat", "serial": 7518} + + # Create filepath with different metadata + mock_filepath = tmp_path / "sbe56" / "dsE_1_2018_6363_raw.nc" + mock_filepath.parent.mkdir(parents=True) + mock_filepath.touch() + + result = processor._add_missing_metadata( + ds, instrument_config, mock_filepath, "dsE_1_2018" + ) + + # Should use YAML metadata, not filepath + assert result["instrument"].values == "microcat" # From YAML + assert result["serial_number"].values == 7518 # From YAML + assert result["InstrDepth"].values == 150 # From YAML + + # Should NOT add history note since no fallback was used + assert "history" not in result.attrs + def test_clean_unnecessary_variables(self, processor, sample_raw_dataset): """Test removal of unnecessary variables.""" result = processor._clean_unnecessary_variables(sample_raw_dataset) diff --git a/tests/test_tools.py b/tests/test_tools.py index 9950598..2ad09be 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -4,6 +4,14 @@ from oceanarray import tools from oceanarray.tools import calc_ds_difference +from oceanarray.process_rodb import ( + middle_percent, + mean_of_middle_percent, + std_of_middle_percent, + normalize_by_middle_percent, + normalize_dataset_by_middle_percent, +) +from oceanarray.mooring_rodb import auto_filt @pytest.fixture @@ -67,33 +75,33 @@ def test_downsample_to_sparse_shapes(): def test_middle_percent(): data = np.linspace(0, 100, 100) - lo, hi = tools.middle_percent(data, 80) + lo, hi = middle_percent(data, 80) assert lo > 0 and hi < 100 and hi > lo def test_middle_percent_bounds(): data = np.linspace(0, 100, 1000) - lower, upper = tools.middle_percent(data, 90) + lower, upper = middle_percent(data, 90) assert np.isclose(lower, 5) assert np.isclose(upper, 95) def test_mean_of_middle_percent(): data = np.concatenate([np.random.normal(10, 1, 1000), np.array([1000, -1000])]) - mean = tools.mean_of_middle_percent(data, 95) + mean = mean_of_middle_percent(data, 95) assert abs(mean - 10) < 0.2 def test_std_of_middle_percent(): data = np.concatenate([np.random.normal(5, 2, 1000), np.array([999, -999])]) - std = tools.std_of_middle_percent(data, 95) + std = std_of_middle_percent(data, 95) assert 1.5 < std < 2.5 def test_mean_std_middle_percent(): data = np.random.normal(0, 1, 1000) - mean = tools.mean_of_middle_percent(data, 90) - std = tools.std_of_middle_percent(data, 90) + mean = mean_of_middle_percent(data, 90) + std = std_of_middle_percent(data, 90) assert np.isfinite(mean) assert np.isfinite(std) assert std > 0 @@ -101,15 +109,15 @@ def test_mean_std_middle_percent(): def test_normalize_by_middle_percent(): data = np.random.normal(0, 1, 1000) - norm = tools.normalize_by_middle_percent(data, 90) - mid_std = tools.std_of_middle_percent(norm, 90) + norm = normalize_by_middle_percent(data, 90) + mid_std = std_of_middle_percent(norm, 90) assert 0.9 < mid_std < 1.1 def test_normalize_dataset_by_middle_percent(): time = np.arange(10) ds = xr.Dataset({"TEMP": ("TIME", np.random.rand(10) + 20)}, coords={"TIME": time}) - ds_norm = tools.normalize_dataset_by_middle_percent(ds) + ds_norm = normalize_dataset_by_middle_percent(ds) assert "TEMP" in ds_norm assert np.allclose(ds_norm.TIME, time) @@ -118,7 +126,7 @@ def test_auto_filt_low(): sr = 1.0 # Hz t = np.linspace(0, 10, 500) signal = np.sin(2 * np.pi * 0.1 * t) + 0.5 * np.sin(2 * np.pi * 2 * t) - filtered = tools.auto_filt(signal, sr, co=0.2, typ="low") + filtered = auto_filt(signal, sr, co=0.2, typ="low") assert len(filtered) == len(signal) assert np.std(filtered) < np.std(signal) From ffc4f8e38081bcabab90f88b9fdbbebf0e3297a6 Mon Sep 17 00:00:00 2001 From: Eleanor Frajka-Williams Date: Fri, 12 Sep 2025 22:20:36 +0200 Subject: [PATCH 2/2] Linting --- notebooks/demo_batch_instrument.ipynb | 2 +- notebooks/demo_check_clock.ipynb | 2 +- notebooks/demo_climatology.ipynb | 2 +- notebooks/demo_clock_offset.ipynb | 2 +- notebooks/demo_instrument.ipynb | 2 +- notebooks/demo_instrument_rdb.ipynb | 2 +- notebooks/demo_mooring_rdb.ipynb | 2 +- notebooks/demo_stage1.ipynb | 2 +- notebooks/demo_stage2.ipynb | 2 +- notebooks/demo_step1.ipynb | 2 +- oceanarray/convertOS.py | 5 +- oceanarray/mooring_rodb.py | 2 +- oceanarray/process_rodb.py | 2 +- oceanarray/readers.py | 2 - oceanarray/stage1.py | 9 +-- oceanarray/tools.py | 4 -- tests/test_convertOS.py | 11 ++-- tests/test_mooring_rodb.py | 9 +-- tests/test_process_rodb.py | 7 +-- tests/test_rodb.py | 6 +- tests/test_stage1.py | 84 ++++++++++++++------------- tests/test_stage2.py | 8 +-- tests/test_tools.py | 13 ++--- 23 files changed, 77 insertions(+), 105 deletions(-) diff --git a/notebooks/demo_batch_instrument.ipynb b/notebooks/demo_batch_instrument.ipynb index 67640f8..ebf172d 100644 --- a/notebooks/demo_batch_instrument.ipynb +++ b/notebooks/demo_batch_instrument.ipynb @@ -537,4 +537,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/notebooks/demo_check_clock.ipynb b/notebooks/demo_check_clock.ipynb index 7fa11a4..3cd243e 100644 --- a/notebooks/demo_check_clock.ipynb +++ b/notebooks/demo_check_clock.ipynb @@ -626,4 +626,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/notebooks/demo_climatology.ipynb b/notebooks/demo_climatology.ipynb index e08efe4..913f24a 100644 --- a/notebooks/demo_climatology.ipynb +++ b/notebooks/demo_climatology.ipynb @@ -215,4 +215,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/notebooks/demo_clock_offset.ipynb b/notebooks/demo_clock_offset.ipynb index 6fdd693..ca620b1 100644 --- a/notebooks/demo_clock_offset.ipynb +++ b/notebooks/demo_clock_offset.ipynb @@ -342,4 +342,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/notebooks/demo_instrument.ipynb b/notebooks/demo_instrument.ipynb index 86707cf..b8d3d7a 100644 --- a/notebooks/demo_instrument.ipynb +++ b/notebooks/demo_instrument.ipynb @@ -308,4 +308,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/notebooks/demo_instrument_rdb.ipynb b/notebooks/demo_instrument_rdb.ipynb index 3c72366..1337011 100644 --- a/notebooks/demo_instrument_rdb.ipynb +++ b/notebooks/demo_instrument_rdb.ipynb @@ -186,4 +186,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/notebooks/demo_mooring_rdb.ipynb b/notebooks/demo_mooring_rdb.ipynb index c1bb75c..e58957d 100644 --- a/notebooks/demo_mooring_rdb.ipynb +++ b/notebooks/demo_mooring_rdb.ipynb @@ -207,4 +207,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/notebooks/demo_stage1.ipynb b/notebooks/demo_stage1.ipynb index aef023b..6053357 100644 --- a/notebooks/demo_stage1.ipynb +++ b/notebooks/demo_stage1.ipynb @@ -388,4 +388,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/notebooks/demo_stage2.ipynb b/notebooks/demo_stage2.ipynb index b853356..39f771b 100644 --- a/notebooks/demo_stage2.ipynb +++ b/notebooks/demo_stage2.ipynb @@ -111,4 +111,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/notebooks/demo_step1.ipynb b/notebooks/demo_step1.ipynb index 5fe2863..928cc59 100644 --- a/notebooks/demo_step1.ipynb +++ b/notebooks/demo_step1.ipynb @@ -486,4 +486,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +} diff --git a/oceanarray/convertOS.py b/oceanarray/convertOS.py index 1d02d45..6ee983c 100644 --- a/oceanarray/convertOS.py +++ b/oceanarray/convertOS.py @@ -7,9 +7,8 @@ import yaml from oceanarray import utilities # for any shared helpers like date parsing -from oceanarray.utilities import ( - iso8601_duration_from_seconds, -) # or wherever you store it +from oceanarray.utilities import \ + iso8601_duration_from_seconds # or wherever you store it def convert_rodb_to_oceansites( diff --git a/oceanarray/mooring_rodb.py b/oceanarray/mooring_rodb.py index af2d066..9ee725b 100644 --- a/oceanarray/mooring_rodb.py +++ b/oceanarray/mooring_rodb.py @@ -4,7 +4,7 @@ from scipy.interpolate import interp1d from scipy.signal import butter, filtfilt -from oceanarray import tools, utilities +from oceanarray import utilities def find_time_vars(ds_list, time_key="TIME"): diff --git a/oceanarray/process_rodb.py b/oceanarray/process_rodb.py index 5556ea2..71d1f12 100644 --- a/oceanarray/process_rodb.py +++ b/oceanarray/process_rodb.py @@ -5,7 +5,7 @@ import numpy as np import xarray as xr -from oceanarray import rodb, tools +from oceanarray import rodb from oceanarray.logger import log_debug, log_info, log_warning DUMMY_VALUE = -9.99e-29 # adjust if needed diff --git a/oceanarray/readers.py b/oceanarray/readers.py index c0884ea..0137660 100644 --- a/oceanarray/readers.py +++ b/oceanarray/readers.py @@ -122,5 +122,3 @@ def rodbload_old(filepath: Path, variables: list[str]) -> xr.Dataset: ds = xr.Dataset(data_vars, coords=coords) ds.attrs["source_file"] = str(filepath) return ds - - diff --git a/oceanarray/stage1.py b/oceanarray/stage1.py index 50fcef5..67536e0 100644 --- a/oceanarray/stage1.py +++ b/oceanarray/stage1.py @@ -6,13 +6,8 @@ from typing import Any, Dict, List, Optional, Tuple import yaml -from ctd_tools.readers import ( - NortekAsciiReader, - RbrAsciiReader, - RbrRskAutoReader, - SbeAsciiReader, - SbeCnvReader, -) +from ctd_tools.readers import (NortekAsciiReader, RbrAsciiReader, + RbrRskAutoReader, SbeAsciiReader, SbeCnvReader) from ctd_tools.writers import NetCdfWriter diff --git a/oceanarray/tools.py b/oceanarray/tools.py index 4ede4e2..e8c0005 100644 --- a/oceanarray/tools.py +++ b/oceanarray/tools.py @@ -114,7 +114,6 @@ def find_cold_entry_exit( return time[s0], time[eL], thr - def calc_psal(ds): if "PSAL" not in ds: SP = gsw.SP_from_C(ds["CNDC"], ds["TEMP"], ds["PRES"]) @@ -349,9 +348,6 @@ def process_dataset( return ds_standard - - - def calc_ds_difference(ds1, ds2): # Check that the time grids are the same if not np.array_equal(ds1["TIME"].values, ds2["TIME"].values): diff --git a/tests/test_convertOS.py b/tests/test_convertOS.py index aec16fd..ab17c4d 100644 --- a/tests/test_convertOS.py +++ b/tests/test_convertOS.py @@ -4,13 +4,10 @@ import yaml from oceanarray import convertOS -from oceanarray.convertOS import ( - add_fixed_coordinates, - add_variable_attributes, - convert_rodb_to_oceansites, - format_time_variable, - parse_rodb_metadata, -) +from oceanarray.convertOS import (add_fixed_coordinates, + add_variable_attributes, + convert_rodb_to_oceansites, + format_time_variable, parse_rodb_metadata) @pytest.fixture diff --git a/tests/test_mooring_rodb.py b/tests/test_mooring_rodb.py index af7a48b..a2e0c9c 100644 --- a/tests/test_mooring_rodb.py +++ b/tests/test_mooring_rodb.py @@ -5,13 +5,8 @@ from oceanarray import mooring_rodb from oceanarray.mooring_rodb import ( # Adjust import as needed - filter_all_time_vars, - find_common_attributes, - find_time_vars, - get_12hourly_time_grid, - interp_to_12hour_grid, - stack_instruments, -) + filter_all_time_vars, find_common_attributes, find_time_vars, + get_12hourly_time_grid, interp_to_12hour_grid, stack_instruments) def create_mock_os_dataset(depth, serial_number, source_file): diff --git a/tests/test_process_rodb.py b/tests/test_process_rodb.py index 82a08a5..0d56832 100644 --- a/tests/test_process_rodb.py +++ b/tests/test_process_rodb.py @@ -5,11 +5,8 @@ import pandas as pd import xarray as xr -from oceanarray.process_rodb import ( - apply_microcat_calibration_from_txt, - stage2_trim, - trim_suggestion, -) +from oceanarray.process_rodb import (apply_microcat_calibration_from_txt, + stage2_trim, trim_suggestion) from oceanarray.rodb import rodbload diff --git a/tests/test_rodb.py b/tests/test_rodb.py index c09e2e2..630d464 100644 --- a/tests/test_rodb.py +++ b/tests/test_rodb.py @@ -5,11 +5,7 @@ import xarray as xr from oceanarray.rodb import ( # Replace with actual function names - format_latlon, - parse_rodb_keys_file, - rodbload, - rodbsave, -) + format_latlon, parse_rodb_keys_file, rodbload, rodbsave) def test_parse_rodb_keys_file(tmp_path): diff --git a/tests/test_stage1.py b/tests/test_stage1.py index c3c5547..e03e6ad 100644 --- a/tests/test_stage1.py +++ b/tests/test_stage1.py @@ -12,11 +12,8 @@ import xarray as xr import yaml -from oceanarray.stage1 import ( - MooringProcessor, - process_multiple_moorings, - stage1_mooring, -) +from oceanarray.stage1 import (MooringProcessor, process_multiple_moorings, + stage1_mooring) class TestMooringProcessor: @@ -152,20 +149,26 @@ def test_clean_dataset_variables_sbe_cnv(self, processor): """Test cleaning dataset variables for SBE CNV files.""" # Create a mock dataset with variables that should be removed import numpy as np - - ds = xr.Dataset({ - "temperature": (["time"], [20.0, 21.0, 22.0]), - "pressure": (["time"], [100.0, 101.0, 102.0]), - "potential_temperature": (["time"], [19.8, 20.8, 21.8]), # Should be removed - "density": (["time"], [1025.0, 1026.0, 1027.0]), # Should be removed - "julian_days_offset": (["time"], [1, 2, 3]), # Should be removed - "salinity": (["time"], [35.0, 35.1, 35.2]), # Should be kept - }, coords={ - "time": (["time"], np.arange(3)), - "depth": (["time"], [100.0, 100.0, 100.0]), # Should be removed - "latitude": 60.0, # Should be removed - "longitude": -30.0, # Should be removed - }) + + ds = xr.Dataset( + { + "temperature": (["time"], [20.0, 21.0, 22.0]), + "pressure": (["time"], [100.0, 101.0, 102.0]), + "potential_temperature": ( + ["time"], + [19.8, 20.8, 21.8], + ), # Should be removed + "density": (["time"], [1025.0, 1026.0, 1027.0]), # Should be removed + "julian_days_offset": (["time"], [1, 2, 3]), # Should be removed + "salinity": (["time"], [35.0, 35.1, 35.2]), # Should be kept + }, + coords={ + "time": (["time"], np.arange(3)), + "depth": (["time"], [100.0, 100.0, 100.0]), # Should be removed + "latitude": 60.0, # Should be removed + "longitude": -30.0, # Should be removed + }, + ) # Clean the dataset cleaned_ds = processor._clean_dataset_variables(ds, "sbe-cnv") @@ -191,14 +194,17 @@ def test_clean_dataset_variables_sbe_cnv(self, processor): def test_clean_dataset_variables_unknown_type(self, processor): """Test cleaning dataset variables for unknown file type.""" import numpy as np - - ds = xr.Dataset({ - "temperature": (["time"], [20.0, 21.0, 22.0]), - "unwanted_var": (["time"], [1.0, 2.0, 3.0]), - }, coords={ - "time": (["time"], np.arange(3)), - "unwanted_coord": (["time"], [100.0, 100.0, 100.0]), - }) + + ds = xr.Dataset( + { + "temperature": (["time"], [20.0, 21.0, 22.0]), + "unwanted_var": (["time"], [1.0, 2.0, 3.0]), + }, + coords={ + "time": (["time"], np.arange(3)), + "unwanted_coord": (["time"], [100.0, 100.0, 100.0]), + }, + ) # Clean with unknown file type (should not remove anything) cleaned_ds = processor._clean_dataset_variables(ds, "unknown-type") @@ -212,13 +218,12 @@ def test_clean_dataset_variables_unknown_type(self, processor): def test_add_global_attributes_complete(self, processor): """Test adding global attributes with complete YAML data.""" import numpy as np - + # Create a simple dataset - ds = xr.Dataset({ - "temperature": (["time"], [20.0, 21.0, 22.0]) - }, coords={ - "time": (["time"], np.arange(3)) - }) + ds = xr.Dataset( + {"temperature": (["time"], [20.0, 21.0, 22.0])}, + coords={"time": (["time"], np.arange(3))}, + ) yaml_data = { "name": "test_mooring", @@ -226,7 +231,7 @@ def test_add_global_attributes_complete(self, processor): "longitude": -30.0, "latitude": 60.0, "deployment_latitude": "60 00.000 N", - "deployment_longitude": "030 00.000 W", + "deployment_longitude": "030 00.000 W", "deployment_time": "2018-08-12T08:00:00", "seabed_latitude": "59 59.500 N", "seabed_longitude": "030 00.500 W", @@ -251,13 +256,12 @@ def test_add_global_attributes_complete(self, processor): def test_add_global_attributes_minimal(self, processor): """Test adding global attributes with minimal YAML data.""" import numpy as np - + # Create a simple dataset - ds = xr.Dataset({ - "temperature": (["time"], [20.0, 21.0, 22.0]) - }, coords={ - "time": (["time"], np.arange(3)) - }) + ds = xr.Dataset( + {"temperature": (["time"], [20.0, 21.0, 22.0])}, + coords={"time": (["time"], np.arange(3))}, + ) # Minimal YAML data (only required fields) yaml_data = { diff --git a/tests/test_stage2.py b/tests/test_stage2.py index b968c35..2b52395 100644 --- a/tests/test_stage2.py +++ b/tests/test_stage2.py @@ -21,11 +21,9 @@ import xarray as xr import yaml -from oceanarray.stage2 import ( - Stage2Processor, - process_multiple_moorings_stage2, - stage2_mooring, -) +from oceanarray.stage2 import (Stage2Processor, + process_multiple_moorings_stage2, + stage2_mooring) class TestStage2Processor: diff --git a/tests/test_tools.py b/tests/test_tools.py index 2ad09be..3d7a301 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -3,15 +3,12 @@ import xarray as xr from oceanarray import tools -from oceanarray.tools import calc_ds_difference -from oceanarray.process_rodb import ( - middle_percent, - mean_of_middle_percent, - std_of_middle_percent, - normalize_by_middle_percent, - normalize_dataset_by_middle_percent, -) from oceanarray.mooring_rodb import auto_filt +from oceanarray.process_rodb import (mean_of_middle_percent, middle_percent, + normalize_by_middle_percent, + normalize_dataset_by_middle_percent, + std_of_middle_percent) +from oceanarray.tools import calc_ds_difference @pytest.fixture