diff --git a/poetry.lock b/poetry.lock index 051a36e..aa7592e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -423,6 +423,24 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "loguru" +version = "0.7.3" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = "<4.0,>=3.5" +files = [ + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==8.1.3)", "build (==1.2.2)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.5.0)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.13.0)", "mypy (==v1.4.1)", "myst-parser (==4.0.0)", "pre-commit (==4.0.1)", "pytest (==6.1.2)", "pytest (==8.3.2)", "pytest-cov (==2.12.1)", "pytest-cov (==5.0.0)", "pytest-cov (==6.0.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.1.0)", "sphinx-rtd-theme (==3.0.2)", "tox (==3.27.1)", "tox (==4.23.2)", "twine (==6.0.1)"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -1362,6 +1380,20 @@ brotli = ["brotli (==1.0.9)", "brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotl secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +[[package]] +name = "win32-setctime" +version = "1.2.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +files = [ + {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, + {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, +] + +[package.extras] +dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] + [[package]] name = "zipp" version = "3.23.1" @@ -1384,4 +1416,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.9, <3.14" -content-hash = "7129c4182e82b734eb0555c04014ea13c5b14cfbe6210c98347b0a0cdc636571" +content-hash = "35f6c41bcd2f19d5c5086be42b9c8dc8f40d414e1678f01755628a92fc4dddd8" diff --git a/pyproject.toml b/pyproject.toml index 5805389..7b5b691 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ terminaltables = "^3.1.0" urllib3 = "^1.26.10" numpy = "^1.23.2" rich-click = ">=1.7.2" +loguru = "^0.7.3" [tool.poetry.group.dev.dependencies] pytest = "^6.2.5" diff --git a/quantaq_cli/console/__init__.py b/quantaq_cli/console/__init__.py index 8e98629..3106175 100644 --- a/quantaq_cli/console/__init__.py +++ b/quantaq_cli/console/__init__.py @@ -1,6 +1,6 @@ -import rich_click as click import pkg_resources -from ..variables import SUPPORTED_MODELS +import rich_click as click +from quantaq_cli.variables import SUPPORTED_MODELS CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) @@ -48,17 +48,20 @@ def merge(files, tscol, output, verbose, **kwargs): @click.command("resample", short_help="up/down sample data") @click.argument("file", nargs=1, type=click.Path()) -@click.argument("interval", nargs=1, type=str) -@click.option("-ts", "--tscol", default="timestamp", help="The column by which to join the files", type=str) -@click.option("-m", "--method", default="mean", help="One of [mean, median, sum, min, max]") +@click.argument("rule", nargs=1, type=str) @click.option("-o", "--output", default="output.csv", help="The filepath where you would like to save the file", type=str) +@click.option("--on", default="timestamp", help="Name of the datetime column to resample over.", type=str) +@click.option("--by", default=None, help="Optional column(s) to group by first") +@click.option("--wind", default=("wx_u", "wx_v", "wx_ws", "wx_wd"), help="``(u, v, speed, direction)`` column names") +@click.option("--numeric_how", default="mean", help="Aggregation for numeric columns.") +@click.option("--nonnumeric_how", default="first", help="Aggregation for non-numeric columns.") @click.option("-v", "--verbose", is_flag=True, help="Enable verbose mode (debugging)") -def resample(file, interval, tscol, method, output, verbose, **kwargs): +def resample(file, rule, output, verbose, **kwargs): """Resample FILE at INTERVAL and save to OUTPUT. """ from .commands.resample import resample_command - resample_command(file, interval, output, method=method, tscol=tscol, verbose=verbose, **kwargs) + resample_command(file, rule, output, verbose=verbose, **kwargs) @click.command("expunge", short_help="NaN flagged values") diff --git a/quantaq_cli/console/commands/resample.py b/quantaq_cli/console/commands/resample.py index 43f2e60..e15c565 100644 --- a/quantaq_cli/console/commands/resample.py +++ b/quantaq_cli/console/commands/resample.py @@ -1,16 +1,173 @@ +from __future__ import annotations from pathlib import Path -import pandas as pd -import numpy as np + import click +from loguru import logger +import numpy as np +import pandas as pd +from pandas.api.types import is_numeric_dtype + +from quantaq_cli.exceptions import InvalidFileExtension +from quantaq_cli.utilities import safe_load + + +# Default (u, v, speed, direction) column names for vector wind averaging. +WIND_COLUMNS = ("wx_u", "wx_v", "wx_ws", "wx_wd") + +def _vector_wind( + df: pd.DataFrame, u_col: str, v_col: str, ws_col: str, wd_col: str +) -> pd.DataFrame: + """Derive vector-averaged speed/direction from averaged u/v components. + + Wind speed and direction cannot be scalar-averaged (350 deg and 10 deg + average to 180 deg, not 0). Instead the Cartesian u/v components are + averaged and converted back to polar form. This mirrors the reference + C++ implementation:: + + ws = sqrt(mean_u**2 + mean_v**2) + wd = atan2(mean_u, mean_v) in degrees, wrapped to [0, 360) + + ``df`` already holds the per-bin averaged components, so ``ws`` is the + magnitude of the *mean* vector (the divide-by-count is baked in), not the + raw vector sum. + """ + u, v = df[u_col], df[v_col] + df[ws_col] = np.sqrt(u**2 + v**2) + df[wd_col] = np.degrees(np.arctan2(u, v)) % 360.0 + return df + +def _components_from_polar( + df: pd.DataFrame, u_col: str, v_col: str, ws_col: str, wd_col: str +) -> pd.DataFrame: + """Create u/v wind components from speed/direction. + + The exact inverse of ``_vector_wind``'s recovery, so the two round-trip:: -from ...exceptions import InvalidFileExtension -from ...utilities import safe_load + u = ws * sin(radians(wd)) + v = ws * cos(radians(wd)) + Used when the input has wind speed/direction but not the Cartesian + components, which must exist before wind can be vector-averaged. + """ + wd_rad = np.radians(df[wd_col]) + df[u_col] = df[ws_col] * np.sin(wd_rad) + df[v_col] = df[ws_col] * np.cos(wd_rad) + return df -def resample_command(file, interval, output, **kwargs): +def resample_dataframe( + df: pd.DataFrame, + rule: str, + *, + on: str = "timestamp", + by: str | list[str] | None = None, + wind: tuple[str, str, str, str] | None = WIND_COLUMNS, + numeric_how: str = "mean", + nonnumeric_how: str = "first", +) -> pd.DataFrame: + """Resample a time-indexed frame, handling mixed dtypes safely. + + A drop-in improvement over ``df.resample(rule).mean()``: numeric columns + are aggregated with ``numeric_how`` (default ``"mean"``) while non-numeric + columns (strings, categoricals) use ``nonnumeric_how`` (default + ``"first"``) instead of being silently dropped. + + Args: + df: Input frame containing a datetime column ``on``. + rule: Any pandas offset alias, e.g. ``"1min"``, ``"1h"``, ``"1D"``. + on: Name of the datetime column to resample over. + by: Optional column(s) to group by first (e.g. ``"sn"``), so each + device/location is resampled independently. + wind: ``(u, v, speed, direction)`` column names. When the u/v columns + are present, the speed/direction columns are NOT scalar-averaged; + they are derived from the averaged u/v components instead (see + ``_vector_wind``). If only speed/direction are present, the u/v + components are first created from them (see ``_components_from_polar``). + Pass ``None`` to skip. + numeric_how: Aggregation for numeric columns. + nonnumeric_how: Aggregation for non-numeric columns. + + Returns: + A new frame with ``on`` (and any ``by`` keys) as columns. + """ + if type(df[on]) != np.datetime64: + df[on] = df[on].map(pd.to_datetime) + + keys = [by] if isinstance(by, str) else list(by or []) + + # When vector-averaging wind, the speed/direction columns must never be + # scalar-aggregated -- drop them from the agg pass and derive them from the + # averaged u/v components afterwards. + do_wind = False + derived: set[str] = set() + if wind is not None: + u_col, v_col, ws_col, wd_col = wind + have_uv = {u_col, v_col}.issubset(df.columns) + if have_uv and (df[u_col].isna().all() or df[v_col].isna().all()): + logger.debug( + "All wind components contain NaNs ({}: {}, {}: {}); " + "Deriving them from the averaged u/v components", + u_col, int(df[u_col].isna().sum()), + v_col, int(df[v_col].isna().sum()), + ) + have_uv = False + elif have_uv and (df[u_col].isna().any() or df[v_col].isna().any()): + logger.debug( + "Some wind components contain NaNs ({}: {}, {}: {}); " + "affected bins might produce NaN wind", + u_col, int(df[u_col].isna().sum()), + v_col, int(df[v_col].isna().sum()), + ) + have_polar = {ws_col, wd_col}.issubset(df.columns) + if not have_uv and have_polar: + # Create the u/v components from speed/direction before resampling. + logger.debug( + "Deriving {}/{} from {}/{} before resampling", + u_col, v_col, ws_col, wd_col, + ) + df = _components_from_polar(df.copy(), u_col, v_col, ws_col, wd_col) + have_uv = True + do_wind = have_uv + if do_wind: + derived = {ws_col, wd_col} + + value_cols = [ + c for c in df.columns if c != on and c not in keys and c not in derived + ] + agg = { + c: (numeric_how if is_numeric_dtype(df[c]) else nonnumeric_how) + for c in value_cols + } + + indexed = df.set_index(on) + if keys: + out = indexed.groupby(keys).resample(rule).agg(agg).reset_index() + else: + out = indexed.resample(rule).agg(agg).reset_index() + + if do_wind: + out = _vector_wind(out, *wind) + elif wind is not None: + logger.trace("wind columns {} absent; skipping vector average", wind) + + # Preserve the input column order; append any derived columns that were not + # present in the input (e.g. speed/direction created from u/v alone). + ordered = [c for c in df.columns if c in out.columns] + extra = [c for c in out.columns if c not in df.columns] + out = out[ordered + extra] + + logger.debug( + "Resampled {} -> {} rows at '{}'{}", + len(df), len(out), rule, f" grouped by {keys}" if keys else "", + ) + return out + +def resample_command(file, rule, output, **kwargs): verbose = kwargs.pop("verbose", False) - tscol = kwargs.pop("tscol", "timestamp") - method = kwargs.pop("method", "mean") + on = kwargs.pop("on", "timestamp") + by = kwargs.pop("by", None) + wind = kwargs.pop("wind", WIND_COLUMNS) + numeric_how = kwargs.pop("numeric_how", "mean") + nonnumeric_how = kwargs.pop("nonnumeric_how", "first") # make sure the extension is either a csv or feather format output = Path(output) @@ -26,28 +183,20 @@ def resample_command(file, interval, output, **kwargs): # load the file df = safe_load(file) - # if tscol needs to be made a datetime obj, do so - if tscol not in df.columns: + # if column to resample over needs to be made a datetime obj, do so + if on not in df.columns: raise Exception("Invalid column name for the timestamp") # resample - if type(df[tscol]) != np.datetime64: - df[tscol] = df[tscol].map(pd.to_datetime) - - df = df.resample(interval, on=tscol) - - if method == "mean": - df = df.mean() - elif method == "median": - df = df.median() - elif method == "max": - df = df.max() - elif method == "min": - df = df.min() - elif method == "sum": - df = df.sum() - else: - raise Exception("Invalid argument for INTERVAL") + df = resample_dataframe( + df, + rule, + on=on, + by=by, + wind=wind, + numeric_how=numeric_how, + nonnumeric_how=nonnumeric_how, + ) # save the file if verbose: diff --git a/quantaq_cli/utilities.py b/quantaq_cli/utilities.py index 8b8e547..ee49395 100644 --- a/quantaq_cli/utilities.py +++ b/quantaq_cli/utilities.py @@ -1,7 +1,8 @@ import pandas as pd from pathlib import Path -from .exceptions import InvalidFileExtension +from quantaq_cli.exceptions import InvalidFileExtension + def safe_load(fpath, **kwargs): """Load and return a file @@ -25,7 +26,8 @@ def safe_load(fpath, **kwargs): tmp = pd.read_csv(fpath) if as_csv else pd.read_feather(fpath) # drop the extra column if it was added - if "Unnamed: 0" in tmp.columns: - del tmp["Unnamed: 0"] + unnamed = [c for c in tmp.columns if str(c).startswith("Unnamed:")] + if unnamed: + tmp.drop(columns=unnamed, inplace=True) return tmp \ No newline at end of file diff --git a/tests/test_merge.py b/tests/test_merge.py index ac85312..2e8d1c3 100644 --- a/tests/test_merge.py +++ b/tests/test_merge.py @@ -7,6 +7,7 @@ import pandas as pd from quantaq_cli.console import merge, concat +from quantaq_cli.utilities import safe_load class SetupTestCase(unittest.TestCase): @@ -44,9 +45,9 @@ def test_merge_files_modulair_db(self): self.assertEqual(p.suffix, ".csv") # are the number of lines correct? - df1 = pd.read_csv(os.path.join(self.test_files_dir, "modulair/MOD-00014-db-raw.csv"), index_col=0) - df2 = pd.read_csv(os.path.join(self.test_files_dir, "modulair/MOD-00014-db-final.csv"), index_col=0) - df3 = pd.read_csv(os.path.join(self.test_dir, "output.csv")) + df1 = safe_load(os.path.join(self.test_files_dir, "modulair/MOD-00014-db-raw.csv")) + df2 = safe_load(os.path.join(self.test_files_dir, "modulair/MOD-00014-db-final.csv")) + df3 = safe_load(os.path.join(self.test_dir, "output.csv")) self.assertEqual(df1.shape[1] + df2.shape[1] - 1, df3.shape[1]) @@ -59,7 +60,8 @@ def test_merge_files_feather(self): "-v", os.path.join(self.test_files_dir, "arisense/SN000-063-db-file1.csv"), os.path.join(self.test_files_dir, "arisense/ref/ref.csv"), - ] + ], + catch_exceptions=False ) # did it succeed? @@ -83,7 +85,8 @@ def test_concat_then_merge(self): os.path.join(self.test_dir, "concat1.csv"), os.path.join(self.test_files_dir, "modulair-pm/MOD-PM-00001-rawsd-file1.csv"), os.path.join(self.test_files_dir, "modulair-pm/MOD-PM-00001-rawsd-file2.csv"), - ] + ], + catch_exceptions=False ) self.assertEqual(res1.exit_code, 0) @@ -95,7 +98,8 @@ def test_concat_then_merge(self): "-l", os.path.join(self.test_files_dir, "modulair-pm/logs/000001.txt"), os.path.join(self.test_files_dir, "modulair-pm/logs/000002.txt"), - ] + ], + catch_exceptions=False ) self.assertEqual(res2.exit_code, 0) @@ -139,9 +143,9 @@ def test_merge_files_modulairx_rawsd(self): self.assertEqual(p.suffix, ".csv") # are the number of lines correct? - df1 = pd.read_csv(os.path.join(self.test_files_dir, "modulair-x/MOD-X-00891-rawsd-file1.csv"), skiprows=3) - df2 = pd.read_csv(os.path.join(self.test_files_dir, "modulair-x/MOD-X-00891-rawsd-file2.csv"), skiprows=3) - df3 = pd.read_csv(os.path.join(self.test_dir, "output.csv")) + df1 = safe_load(os.path.join(self.test_files_dir, "modulair-x/MOD-X-00891-rawsd-file1.csv")) + df2 = safe_load(os.path.join(self.test_files_dir, "modulair-x/MOD-X-00891-rawsd-file2.csv")) + df3 = safe_load(os.path.join(self.test_dir, "output.csv")) print(df1.shape[1], df2.shape[1], df3.shape[1], flush=True) self.assertEqual(df1.shape[1] + df2.shape[1] - 1, df3.shape[1]) @@ -173,9 +177,9 @@ def test_merge_files_modulairx_cloudapi(self): self.assertEqual(p.suffix, ".csv") # are the number of lines correct? - df1 = pd.read_csv(os.path.join(self.test_files_dir, "modulair-x/MOD-X-00993-cloudapi-file1.csv")) - df2 = pd.read_csv(os.path.join(self.test_files_dir, "modulair-x/MOD-X-00993-cloudapi-file2.csv")) - df3 = pd.read_csv(os.path.join(self.test_dir, "output.csv")) + df1 = safe_load(os.path.join(self.test_files_dir, "modulair-x/MOD-X-00993-cloudapi-file1.csv")) + df2 = safe_load(os.path.join(self.test_files_dir, "modulair-x/MOD-X-00993-cloudapi-file2.csv")) + df3 = safe_load(os.path.join(self.test_dir, "output.csv")) print(df1.shape[1], df2.shape[1], df3.shape[1], flush=True) self.assertEqual(df1.shape[1] + df2.shape[1] - 1, df3.shape[1]) diff --git a/tests/test_resample.py b/tests/test_resample.py index 498aed1..4c84c00 100644 --- a/tests/test_resample.py +++ b/tests/test_resample.py @@ -1,13 +1,18 @@ +import os +import shutil +import tempfile import unittest -from click.testing import CliRunner from os import path from pathlib import Path -import os -import shutil, tempfile -import pandas as pd + import numpy as np +import pandas as pd +from click.testing import CliRunner +from loguru import logger from quantaq_cli.console import resample +from quantaq_cli.console.commands.resample import resample_dataframe, WIND_COLUMNS +from quantaq_cli.utilities import safe_load class SetupTestCase(unittest.TestCase): @@ -27,7 +32,7 @@ def test_resample_files_csv(self): "-v", os.path.join(self.test_files_dir, "arisense/ref/ref.csv"), "10min", - ] + ], catch_exceptions=False ) # did it succeed? @@ -60,7 +65,7 @@ def test_resample_files_feather(self): "-v", os.path.join(self.test_files_dir, "arisense/ref/ref.csv"), "10min", - ] + ], catch_exceptions=False ) # did it succeed? @@ -81,4 +86,173 @@ def test_resample_files_feather(self): idx = df.timestamp.values - self.assertEqual((idx[1] - idx[0]) / np.timedelta64(1, 's'), 600.0) \ No newline at end of file + self.assertEqual((idx[1] - idx[0]) / np.timedelta64(1, 's'), 600.0) + + def test_resample_modulair_db(self): + runner = CliRunner() + result = runner.invoke(resample, + [ + "-o", + os.path.join(self.test_dir, "output.csv"), + "-v", + os.path.join(self.test_files_dir, "modulair/MOD-00014-db-raw.csv"), + "1h", + ], catch_exceptions=False + ) + + # did it succeed? + self.assertEqual(result.exit_code, 0) + + # did it output the correct text? + self.assertTrue("Saving file" in result.output) + + # make sure the file exists + p = Path(self.test_dir + "/output.csv") + self.assertTrue(p.exists()) + + # is it a csv? + self.assertEqual(p.suffix, ".csv") + + # are the number of lines correct? + df = pd.read_csv(os.path.join(self.test_dir, "output.csv")) + df['timestamp'] = df['timestamp'].map(pd.to_datetime) + + idx = df.timestamp.values + + self.assertEqual((idx[1] - idx[0]) / np.timedelta64(1, 's'), 3600.0) + + def test_resample_modulairpm_rawsd(self): + runner = CliRunner() + result = runner.invoke(resample, + [ + "-o", + os.path.join(self.test_dir, "output.csv"), + "-v", + os.path.join(self.test_files_dir, "modulair-pm/MOD-PM-00001-rawsd-file1.csv"), + "10min", + "--on", + "timestamp_iso" + ], catch_exceptions=False + ) + + # did it succeed? + self.assertEqual(result.exit_code, 0) + + # did it output the correct text? + self.assertTrue("Saving file" in result.output) + + # make sure the file exists + p = Path(self.test_dir + "/output.csv") + self.assertTrue(p.exists()) + + # is it a csv? + self.assertEqual(p.suffix, ".csv") + + # are the number of lines correct? + df = pd.read_csv(os.path.join(self.test_dir, "output.csv")) + df['timestamp_iso'] = df['timestamp_iso'].map(pd.to_datetime) + + idx = df.timestamp_iso.values + + self.assertEqual((idx[1] - idx[0]) / np.timedelta64(1, 's'), 600.0) + + def test_resample_modulairx_rawsd(self): + runner = CliRunner() + result = runner.invoke(resample, + [ + "-o", + os.path.join(self.test_dir, "output.csv"), + "-v", + os.path.join(self.test_files_dir, "modulair-x/MOD-X-00891-rawsd-file1.csv"), + "10min", + ], catch_exceptions=False + ) + + # did it succeed? + self.assertEqual(result.exit_code, 0) + + # did it output the correct text? + self.assertTrue("Saving file" in result.output) + + # make sure the file exists + p = Path(self.test_dir + "/output.csv") + self.assertTrue(p.exists()) + + # is it a csv? + self.assertEqual(p.suffix, ".csv") + + # are the number of lines correct? + df = pd.read_csv(os.path.join(self.test_dir, "output.csv")) + df['timestamp'] = df['timestamp'].map(pd.to_datetime) + + idx = df.timestamp.values + + self.assertEqual((idx[1] - idx[0]) / np.timedelta64(1, 's'), 600.0) + + def test_resample_modulairx_cloudapi(self): + runner = CliRunner() + result = runner.invoke(resample, + [ + "-o", + os.path.join(self.test_dir, "output.csv"), + "-v", + os.path.join(self.test_files_dir, "modulair-x/MOD-X-00993-cloudapi-file1.csv"), + "10min", + ], catch_exceptions=False + ) + + # did it succeed? + self.assertEqual(result.exit_code, 0) + + # did it output the correct text? + self.assertTrue("Saving file" in result.output) + + # make sure the file exists + p = Path(self.test_dir + "/output.csv") + self.assertTrue(p.exists()) + + # is it a csv? + self.assertEqual(p.suffix, ".csv") + + # are the number of lines correct? + df = pd.read_csv(os.path.join(self.test_dir, "output.csv")) + df['timestamp'] = df['timestamp'].map(pd.to_datetime) + + idx = df.timestamp.values + + self.assertEqual((idx[1] - idx[0]) / np.timedelta64(1, 's'), 600.0) + + def test_resample_dataframe_nan_winds(self): + """Test for edge case in case u/v cols exist but have nans. + """ + file = os.path.join( + self.test_files_dir, "modulair-x/MOD-X-00891-rawsd-file1.csv" + ) + + expected = pd.DataFrame( + {"wx_wd": [171.0000, 187.9838], + "wx_ws": [1.53998, 0.49199]}, + ) + atol = 1e-3 + + # case 1: all u/v rows are nan + df1 = safe_load(file) + df1["wx_u"] = np.nan + df1["wx_v"] = np.nan + + df1_resampled = resample_dataframe(df1, "10min") + np.testing.assert_allclose( + df1_resampled[["wx_wd", "wx_ws"]].to_numpy(), + expected.to_numpy(), + atol=atol, equal_nan=True, + ) + + # case 2: some u/v rows are nan + df2 = safe_load(file) # has 1 row where wx_u and wx_v are nan + + df2_resampled = resample_dataframe(df2, "10min") + np.testing.assert_allclose( + df2_resampled[["wx_wd", "wx_ws"]].to_numpy(), + expected.to_numpy(), + atol=atol, equal_nan=True, + )