diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..92c44a8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bcf3216 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + workflow_dispatch: + +jobs: + # pre-commit: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v6.0.2 + # - uses: actions/setup-python@v6 + # - uses: pre-commit/action@v3.0.1 + + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + max-parallel: 10 + matrix: + os: ["ubuntu-latest", "windows-latest", "macos-latest"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - name: Checkout source + uses: actions/checkout@v6 + + - name: Setup conda environment + uses: conda-incubator/setup-miniconda@v3 + with: + miniconda-version: "latest" + python-version: ${{ matrix.python-version }} + environment-file: ci/env.yml + activate-environment: pcnv-dev + auto-activate-base: false + + - name: Test build distribution + shell: bash -l {0} + run: | + git clean -xdf + pyproject-build + + - name: Install source + shell: bash -l {0} + run: python -m pip install . + + - name: List installed packages + shell: bash -l {0} + run: conda list + + - name: Run tests + shell: bash -l {0} + run: python -m pytest -s --cov=pyncv --cov-report=xml . + diff --git a/pycnv/CHANGELOG.rst b/CHANGELOG.rst similarity index 100% rename from pycnv/CHANGELOG.rst rename to CHANGELOG.rst diff --git a/README.rst b/README.rst index 8c21550..e527718 100644 --- a/README.rst +++ b/README.rst @@ -27,6 +27,10 @@ earlier versions, but its not supported. The newest depends also on python 3.5+, pycnv heavily depends on the gsw toolbox. It therefore strongly recommended to use python 3.5+. +___ + +CT Note: Python versions below 3.10 are now EOL'ed, so I'm gonna update to require 3.10+ if thats okay? + User ____ @@ -34,19 +38,19 @@ ____ Install as a user using pip .. code:: bash - + pip install pycnv Install as a user from the repository .. code:: bash - + python setup.py install --user Uninstall as a user - + .. code:: bash - + pip uninstall pycnv @@ -57,13 +61,15 @@ _________ Install as a developer .. code:: bash - - python setup.py develop --user + + conda env create -f ci/env.yml + conda activate pycnv-dev + pip install -e . Uninstall as a user - + .. code:: bash - + pip uninstall pycnv @@ -85,16 +91,16 @@ FEATURES is stored in a second field called computed data: cdata. E.g. cdata['SA00']. The code used to compute the properties are - + .. code:: python - + SP = gsw.SP_from_C(data['C' + isen], T, data['p']) SA = gsw.SA_from_SP(SP,data['p'],lon = lon, lat = lat) if(baltic == True): SA = gsw.SA_from_SP_Baltic(SA,lon = lon, lat = lat) - + PT = gsw.pt0_from_t(SA, T, data['p']) - CT = gsw.CT_from_t(SA, T, data['p']) + CT = gsw.CT_from_t(SA, T, data['p']) pot_rho = gsw.pot_rho_t_exact(SA, T, data['p'], p_ref) - The cnv object provides standard entries for pressure (cnv.p), @@ -127,15 +133,15 @@ The package installs the executables: - pycnv_sum_folder - -EXAMPLES + +EXAMPLES -------- Plot the absolute salinity and oxygen of a CTD cast: .. code:: python - + import pycnv - import pylab as pl + import pylab as pl fname = 'test.cnv' # Some CTD cast cnv = pycnv.pycnv(fname) @@ -155,7 +161,7 @@ Plot the absolute salinity and oxygen of a CTD cast: # Get unit of derived data datad0_unit = cnv.cunits[keyd0] - # Standard names are mapped to + # Standard names are mapped to # cnv.p,cnv.CT,cnv.T,cnv.SP,cnv.oxy # units are _unit, e.g. cnv.p_unit @@ -179,11 +185,11 @@ Plot the absolute salinity and oxygen of a CTD cast: pl.show() - + Lists all predefined stations (in terminal): .. code:: bash - + pycnv_sum_folder --list_stations @@ -192,14 +198,14 @@ station TF0271 with a radius of 5000 m, prints it to the terminal and saves it into the file TF271.txt (in terminal): .. code:: bash - + pycnv_sum_folder --data_folder cnv_data --station TF0271 5000 -p -f TF271.txt Show and plot conservative temperature, salinity and potential density of a cnv file into a pdf: .. code:: bash - + pycnv --plot show,save,CT00,SA00,pot_rho00 ctd_cast.cnv @@ -208,7 +214,7 @@ Interpolate all CTD casts on station TF0271 onto the same pressure axis and make see code pycnv/test/make_netcdf.py -Devices tested +Devices tested -------------- - SEACAT (SBE16) V4.0g @@ -220,8 +226,3 @@ Devices tested - SBE 11plus V 5.1g - Sea-Bird SBE 9 Software Version 4.206 - - - - - diff --git a/ci/env.yml b/ci/env.yml new file mode 100644 index 0000000..fffae91 --- /dev/null +++ b/ci/env.yml @@ -0,0 +1,21 @@ +name: pycnv-dev +channels: + - conda-forge + - defaults +dependencies: + - python>=3.10 + - setuptools + - wheel + - proj + - gsw + - pyproj + - pytest + - pytz + - pyaml + - matplotlib + - cartopy + - pip + - pip: + - pytest + - build + - pytest-cov diff --git a/test/bottle_example.py b/test/bottle_example.py deleted file mode 100644 index b74d9db..0000000 --- a/test/bottle_example.py +++ /dev/null @@ -1,276 +0,0 @@ - -import numpy -import gsw - -from pycnv import parse_time - -class bottle(object): - def __init__(self, filename): - self.filename = filename - self.header = None - self.start_date = None - self.nmea_date = None - self.lon = numpy.nan - self.lat = numpy.nan - self.data = None - - f = open(filename) - ll = f.readlines() - f.close() - - # separate header from data table (with column names still) - head_lines = [l.replace("\n","") for l in ll if l.startswith("*") or l.startswith("#")] - data_lines = ll[len(head_lines):] - - self.header = head_lines - self._parse_header() - - self._parse_data(data_lines) - - - def _parse_header(self): - """ - Parses the header of the btl file - """ - - # make educated guess for 'delimiter' of custom header items ('**') - # will try "=" or ":" - custom_delim = "=" - try: - custom_header_lines = [l for l in self.header if "**" in l and l != "**"] - ncust = len(custom_header_lines) - ncounted = "".join(custom_header_lines).count(custom_delim) - - if ncounted < ncust: - custom_delim = ":" - - custom_header_lines = [l for l in self.header if "**" in l and l != "**"] - ncust = len(custom_header_lines) - ncounted = "".join(custom_header_lines).count(custom_delim) - - if ncounted < ncust: - print("warning, could not find seabird metadata delimiter, will use '='") - - except: - print("warning, used '=' as delimiter for custom seabird meta data ('**')") - - - for l in self.header: - - if("* NMEA Latitude" in l) or ("* NMEA Longitude" in l): - pos_str = l.rsplit('=')[1] - pos_str = pos_str.replace("\n","").replace("\r","") - SIGN = numpy.nan - if("S" in pos_str): - SIGN = -1. - if("N" in pos_str): - SIGN = 1. - if("W" in pos_str): - SIGN = -1. - if("E" in pos_str): - SIGN = 1. - - pos_str = pos_str.replace(" "," ") - while(pos_str[0] == " "): - pos_str = pos_str[1:] - - pos_str_deg = pos_str.split(" ")[0] - pos_str_min = pos_str.split(" ")[1] - - pos = SIGN * (float(pos_str_deg) + float(pos_str_min)/60.) - if("* NMEA Latitude" in l): - self.lat = pos - if("* NMEA Longitude" in l): - self.lon = pos - - if "* NMEA UTC (Time) = " in l: - line = l.split(" = ") - line1 = line[1].split(" [") - datum = line1[0] - self.nmea_date = parse_time(datum) - - - if "# start_time = " in l: - line = l.split(" = ") - line1 = line[1].split(" [") - datum = line1[0] - self.start_date = parse_time(datum) - - - if "** " in l: # User defined additional information - - try: - self.seabird_meta - except: - self.seabird_meta = {} - - try: - splitted = l.split(custom_delim) - key = splitted[0][3:].strip() - if len(splitted)>2: - data = custom_delim.join(splitted[1:]).strip() - else: - data = splitted[1].lstrip() - self.seabird_meta[key] = data - except: - pass - - def _parse_data(self, data_lines): - """ - Parses the data lines of the btl file. It was tested with data from two - different platforms and likely needs adjustments, as some assumptions - on the generic data structure were implicitly made. - """ - - - # get column names - data_cols1 = data_lines[0].split() - data_cols2 = data_lines[1].split() # assuming here are only 2 items - - - # count occurences of "min", "max", "avg", "std" -> find number of data lines per bottle - nmin = "".join(data_lines).count("min") - nmax = "".join(data_lines).count("max") - navg = "".join(data_lines).count("avg") - nstv = "".join(data_lines).count("sdev") - - - nline_per_btl = 0 - line_keys = [] - - if navg > 0: - nline_per_btl = 1 - line_keys += ["avg"] - if navg == nstv: - nline_per_btl += 1 - line_keys += ["sdev"] - if navg == nmin: - nline_per_btl += 1 - line_keys += ["min"] - if navg == nmax: - nline_per_btl += 1 - line_keys += ["max"] - else: - print(".. unknown file formatting ('avg' not available)") - - - # test (assuming column 'header' consists of two lines) if number of lines is - # a multiple of the previously determined 'nline_per_btl' - try: - nline_per_btl_ = int((len(data_lines)-2) / navg) - if nline_per_btl!=nline_per_btl_: - print(".. unknown file formatting (wrt number of data lines per bottle)") - except: - print(".. unknown file formatting (wrt number of data lines per bottle or number of headerlines used for column names)") - - - - # parse date and time information (should be in first two lines) - b_date = [ " ".join(l.split()[1:4]) for l in data_lines[2::nline_per_btl]] - - # sanity check 1: try parse_time(b_date[0]+" 0:0:0") - try: - parse_time(b_date[0]+" 0:0:0") - except: - print(".. failed to parse b_date") - - # sanity check 2: check if second line of data contains a string with ":" in first or second position - time_is_first = ":" in data_lines[3].split()[0] - time_is_second = ":" in data_lines[3].split()[1] - - if time_is_first or time_is_second: - b_time = [ l.split()[int(time_is_second)] for l in data_lines[3::nline_per_btl]] - else: - print(".. unknown data formatting (in row containing time)") - - - try: - b_datetime = [parse_time(b_date[ii] +" "+ b_time[ii]) for ii in numpy.arange(len(b_date))] - except: - print("time parsing failed") - - - # step 3: parse data, finally - - # brute-force bug fix for non-separated 'FlECO-AFLTurbWETntu0' in IOW example files - items_fleco = [f for f in data_cols1 if "FlECO-AFL" in f] - if len(items_fleco)>0: - for item_fleco in items_fleco: - if len(item_fleco) > len("FlECO-AFL"): - data_cols1.remove(item_fleco) - - i_item = item_fleco.find("FlECO-AFL") - data_cols1+= [item_fleco[i_item:i_item+len("FlECO-AFL")] , item_fleco[i_item+len("FlECO-AFL"):]] - - - ndatacol = len(data_cols1)-2 # number of variables (excludingtime stamp and bottle numbers) - - data_lines = data_lines[2:] - - data_array = numpy.array([l.split()[-ndatacol-1:-1] for l in data_lines ]).astype(float) - type_list = [l.split()[-1].replace("(","").replace(")","") for l in data_lines][:nline_per_btl] - - # put values in dictionary like data["T090C"]["avg"] for now - data = {} - - for var_key in data_cols1[2:]: - data[var_key] = {} - i_col = data_cols1[2:].index(var_key) - - for line_key in line_keys: - line_key_ind = type_list.index(line_key) - - data[var_key][line_key] = data_array[line_key_ind::nline_per_btl, i_col] - - - # add variable in first position of second row (before 'time', 'Btl_ID' in IOW example, empty for AWI example) - if time_is_second: - data[data_cols2[0]] = [l.split()[0] for l in data_lines[1::nline_per_btl]] - - data[data_cols1[0]] = [l.split()[0] for l in data_lines[::nline_per_btl]] - - - # compute depth from pressure - try: - depth = -gsw.z_from_p(data["PrDM"], self.lat) - data["depth"] = depth - except: - pass - - data["time"] = b_datetime - # self.time = b_datetime - self.data = data - self.names = data.keys() - - - - - -#%% plot all temperature profiles from example files - -import matplotlib.pyplot as plt -import os - -fp_test = "bottlefile_test_data/" - - -for fn in [f for f in os.listdir(fp_test) if f.endswith("btl")]: -# if True: - filename = fp_test+fn - - btl = bottle(filename) - - T = btl.data["T090C"]["avg"] - p = btl.data["PrDM"]["avg"] - - plt.plot(T, p, marker=".") - - -plt.gca().invert_yaxis() - - -print(btl.names) - -print(btl.seabird_meta) - - diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..8f5ae4c --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,7 @@ +from pathlib import Path +import pytest + + +@pytest.fixture +def btl_dir(): + return Path(__file__).parent / "bottlefile_test_data" diff --git a/test/test_bottle.py b/test/test_bottle.py new file mode 100644 index 0000000..02a58d6 --- /dev/null +++ b/test/test_bottle.py @@ -0,0 +1,306 @@ +import math +import pytest +from pathlib import Path + +import numpy +import gsw + +from pycnv import parse_time + + +class Bottle: + def __init__(self, filename): + self.filename = filename + self.header = None + self.start_date = None + self.nmea_date = None + self.lon = numpy.nan + self.lat = numpy.nan + self.data = None + + f = open(filename) + ll = f.readlines() + f.close() + + # separate header from data table (with column names still) + head_lines = [ + l.replace("\n", "") for l in ll if l.startswith("*") or l.startswith("#") + ] + data_lines = ll[len(head_lines) :] + + self.header = head_lines + self._parse_header() + + self._parse_data(data_lines) + + def _parse_header(self): + """ + Parses the header of the btl file + """ + + # make educated guess for 'delimiter' of custom header items ('**') + # will try "=" or ":" + custom_delim = "=" + try: + custom_header_lines = [l for l in self.header if "**" in l and l != "**"] + ncust = len(custom_header_lines) + ncounted = "".join(custom_header_lines).count(custom_delim) + + if ncounted < ncust: + custom_delim = ":" + + custom_header_lines = [ + l for l in self.header if "**" in l and l != "**" + ] + ncust = len(custom_header_lines) + ncounted = "".join(custom_header_lines).count(custom_delim) + + if ncounted < ncust: + print( + "warning, could not find seabird metadata delimiter, will use '='" + ) + + except: + print("warning, used '=' as delimiter for custom seabird meta data ('**')") + + for l in self.header: + if ("* NMEA Latitude" in l) or ("* NMEA Longitude" in l): + pos_str = l.rsplit("=")[1] + pos_str = pos_str.replace("\n", "").replace("\r", "") + SIGN = numpy.nan + if "S" in pos_str: + SIGN = -1.0 + if "N" in pos_str: + SIGN = 1.0 + if "W" in pos_str: + SIGN = -1.0 + if "E" in pos_str: + SIGN = 1.0 + + pos_str = pos_str.replace(" ", " ") + while pos_str[0] == " ": + pos_str = pos_str[1:] + + pos_str_deg = pos_str.split(" ")[0] + pos_str_min = pos_str.split(" ")[1] + + pos = SIGN * (float(pos_str_deg) + float(pos_str_min) / 60.0) + if "* NMEA Latitude" in l: + self.lat = pos + if "* NMEA Longitude" in l: + self.lon = pos + + if "* NMEA UTC (Time) = " in l: + line = l.split(" = ") + line1 = line[1].split(" [") + datum = line1[0] + self.nmea_date = parse_time(datum) + + if "# start_time = " in l: + line = l.split(" = ") + line1 = line[1].split(" [") + datum = line1[0] + self.start_date = parse_time(datum) + + if "** " in l: # User defined additional information + try: + self.seabird_meta + except: + self.seabird_meta = {} + + try: + splitted = l.split(custom_delim) + key = splitted[0][3:].strip() + if len(splitted) > 2: + data = custom_delim.join(splitted[1:]).strip() + else: + data = splitted[1].lstrip() + self.seabird_meta[key] = data + except: + pass + + def _parse_data(self, data_lines): + """ + Parses the data lines of the btl file. It was tested with data from two + different platforms and likely needs adjustments, as some assumptions + on the generic data structure were implicitly made. + """ + + # get column names + data_cols1 = data_lines[0].split() + data_cols2 = data_lines[1].split() # assuming here are only 2 items + + # count occurences of "min", "max", "avg", "std" -> find number of data lines per bottle + nmin = "".join(data_lines).count("min") + nmax = "".join(data_lines).count("max") + navg = "".join(data_lines).count("avg") + nstv = "".join(data_lines).count("sdev") + + nline_per_btl = 0 + line_keys = [] + + if navg > 0: + nline_per_btl = 1 + line_keys += ["avg"] + if navg == nstv: + nline_per_btl += 1 + line_keys += ["sdev"] + if navg == nmin: + nline_per_btl += 1 + line_keys += ["min"] + if navg == nmax: + nline_per_btl += 1 + line_keys += ["max"] + else: + print(".. unknown file formatting ('avg' not available)") + + # test (assuming column 'header' consists of two lines) if number of lines is + # a multiple of the previously determined 'nline_per_btl' + try: + nline_per_btl_ = int((len(data_lines) - 2) / navg) + if nline_per_btl != nline_per_btl_: + print( + ".. unknown file formatting (wrt number of data lines per bottle)" + ) + except: + print( + ".. unknown file formatting (wrt number of data lines per bottle or number of headerlines used for column names)" + ) + + # parse date and time information (should be in first two lines) + b_date = [" ".join(l.split()[1:4]) for l in data_lines[2::nline_per_btl]] + + # sanity check 1: try parse_time(b_date[0]+" 0:0:0") + try: + parse_time(b_date[0] + " 0:0:0") + except: + print(".. failed to parse b_date") + + # sanity check 2: check if second line of data contains a string with ":" in first or second position + time_is_first = ":" in data_lines[3].split()[0] + time_is_second = ":" in data_lines[3].split()[1] + + if time_is_first or time_is_second: + b_time = [ + l.split()[int(time_is_second)] for l in data_lines[3::nline_per_btl] + ] + else: + print(".. unknown data formatting (in row containing time)") + + try: + b_datetime = [ + parse_time(b_date[ii] + " " + b_time[ii]) + for ii in numpy.arange(len(b_date)) + ] + except: + print("time parsing failed") + + # step 3: parse data, finally + + # brute-force bug fix for non-separated 'FlECO-AFLTurbWETntu0' in IOW example files + items_fleco = [f for f in data_cols1 if "FlECO-AFL" in f] + if len(items_fleco) > 0: + for item_fleco in items_fleco: + if len(item_fleco) > len("FlECO-AFL"): + data_cols1.remove(item_fleco) + + i_item = item_fleco.find("FlECO-AFL") + data_cols1 += [ + item_fleco[i_item : i_item + len("FlECO-AFL")], + item_fleco[i_item + len("FlECO-AFL") :], + ] + + ndatacol = ( + len(data_cols1) - 2 + ) # number of variables (excludingtime stamp and bottle numbers) + + data_lines = data_lines[2:] + + data_array = numpy.array( + [l.split()[-ndatacol - 1 : -1] for l in data_lines] + ).astype(float) + type_list = [ + l.split()[-1].replace("(", "").replace(")", "") for l in data_lines + ][:nline_per_btl] + + # put values in dictionary like data["T090C"]["avg"] for now + data = {} + + for var_key in data_cols1[2:]: + data[var_key] = {} + i_col = data_cols1[2:].index(var_key) + + for line_key in line_keys: + line_key_ind = type_list.index(line_key) + + data[var_key][line_key] = data_array[line_key_ind::nline_per_btl, i_col] + + # add variable in first position of second row (before 'time', 'Btl_ID' in IOW example, empty for AWI example) + if time_is_second: + data[data_cols2[0]] = [l.split()[0] for l in data_lines[1::nline_per_btl]] + + data[data_cols1[0]] = [l.split()[0] for l in data_lines[::nline_per_btl]] + + # compute depth from pressure + try: + depth = -gsw.z_from_p(data["PrDM"], self.lat) + data["depth"] = depth + except: + pass + + data["time"] = b_datetime + # self.time = b_datetime + self.data = data + self.names = data.keys() + + +BTL_DIR = Path(__file__).parent / "bottlefile_test_data" + +# Expected values extracted from static test data files (n_bottles, T[0], p[0], T[-1], p[-1]) +BTL_EXPECTED = { + "V0001F02.btl": (13, 3.4229, 10.2810, 6.9138, 234.4310), + "V0020F02.btl": (6, 2.3932, 10.3780, 5.6599, 58.5530), + "V0021F02.btl": (14, 2.5558, 10.3770, 6.1831, 92.6630), + "V0022F01.btl": (12, 2.5724, 10.4120, 6.2893, 117.4330), + "V0023F02.btl": (13, 2.7795, 10.3650, 6.6717, 140.5600), + "V0024F01.btl": (13, 2.9059, 10.3740, 6.4669, 106.8050), + "V0025F01.btl": (12, 2.4514, 10.3540, 6.3451, 116.8530), + "V0026F01.btl": (12, 2.5923, 10.3620, 6.1610, 113.9620), + "V0027F01.btl": (10, 2.4276, 10.3240, 5.9279, 92.6810), + "V0028F01.btl": (6, 2.3314, 10.3390, 5.2398, 58.0350), + "V0029F01.btl": (5, 2.0944, 10.3450, 2.9502, 51.0010), + "V0030F01.btl": (5, 6.4712, 106.4200, 6.4728, 106.4510), +} + +BTL_FILES = sorted(BTL_DIR.glob("*.btl")) + + +@pytest.mark.parametrize("btl_file", BTL_FILES, ids=[f.name for f in BTL_FILES]) +def test_bottle_parses(btl_file): + b = Bottle(str(btl_file)) + assert b.data is not None + assert math.isfinite(b.lat) + assert math.isfinite(b.lon) + assert "T090C" in b.data + assert "PrDM" in b.data + + T = b.data["T090C"]["avg"] + p = b.data["PrDM"]["avg"] + assert all(math.isfinite(v) for v in T) + assert all(math.isfinite(v) for v in p) + + n_bottles, T0, p0, Tn, pn = BTL_EXPECTED[btl_file.name] + assert len(T) == n_bottles + assert T[0] == pytest.approx(T0, abs=1e-3) + assert p[0] == pytest.approx(p0, abs=1e-3) + assert T[-1] == pytest.approx(Tn, abs=1e-3) + assert p[-1] == pytest.approx(pn, abs=1e-3) + + +def test_bottle_v0001f02_values(btl_dir): + b = Bottle(str(btl_dir / "V0001F02.btl")) + assert b.lat == pytest.approx(57.3195, abs=1e-3) + assert b.lon == pytest.approx(20.0495, abs=1e-3) + assert b.seabird_meta["ReiseNr"].strip() == "EMB-177" + assert b.start_date is not None + assert b.start_date.tzinfo is not None diff --git a/test/test_plots.py b/test/test_plots.py new file mode 100644 index 0000000..9e2b722 --- /dev/null +++ b/test/test_plots.py @@ -0,0 +1,55 @@ +import pytest +import pycnv + +import matplotlib.pyplot as pl +import cartopy.crs as ccrs + + +def test_plot_baltic_test_regions(): + BAL_LON = [6.0, 31.0] + BAL_LAT = [53.0, 66.5] + + fig = pl.figure() + ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], projection=ccrs.PlateCarree()) + ax.set_extent([BAL_LON[0], BAL_LON[1], BAL_LAT[0], BAL_LAT[1]]) + ax.coastlines("10m") + for region in pycnv.regions_baltic: + lons = region[0] + lats = region[1] + lonsb = [lons[0], lons[1], lons[1], lons[0], lons[0]] + latsb = [lats[0], lats[0], lats[1], lats[1], lats[0]] + ax.plot(lonsb, latsb, "-b") + pl.close(fig) + + +def test_plot_baltic_stations(): + BAL_LON = [6.0, 31.0] + BAL_LAT = [53.0, 66.5] + + stations = pycnv.get_stations() + assert len(stations) > 0 + + fig = pl.figure() + ax = fig.add_axes([0.1, 0.1, 0.8, 0.8], projection=ccrs.PlateCarree()) + ax.set_extent([BAL_LON[0], BAL_LON[1], BAL_LAT[0], BAL_LAT[1]]) + ax.coastlines("10m") + for s in stations: + ax.plot(s["longitude"], s["latitude"], ".k") + ax.text(s["longitude"], s["latitude"], s["name"]) + pl.close(fig) + + +def test_regions_baltic_exact(): + expected = [ + [[10.2, 13.0], [56.2, 57.5]], + [[9.4, 13.4], [53.9, 56.3]], + [[13.3, 17.0], [53.4, 56.3]], + [[15.9, 24.6], [54.2, 60.2]], + [[24.3, 30.4], [59.1, 60.8]], + [[16.8, 23.3], [60.1, 63.3]], + [[18.8, 25.6], [63.1, 66.2]], + ] + assert len(pycnv.regions_baltic) == len(expected) + for actual_region, expected_region in zip(pycnv.regions_baltic, expected): + assert actual_region[0] == pytest.approx(expected_region[0]) + assert actual_region[1] == pytest.approx(expected_region[1]) diff --git a/test/test_utils.py b/test/test_utils.py new file mode 100644 index 0000000..5875b5d --- /dev/null +++ b/test/test_utils.py @@ -0,0 +1,49 @@ +import datetime +import pycnv + + +def test_parse_time_basic(): + result = pycnv.parse_time("Feb 26 2018 08:31:55") + assert isinstance(result, datetime.datetime) + assert result.year == 2018 + assert result.month == 2 + assert result.day == 26 + assert result.hour == 8 + assert result.minute == 31 + assert result.second == 55 + assert result.tzinfo is not None + assert str(result.tzinfo) == "UTC" + + +def test_parse_time_invalid_returns_none(): + result = pycnv.parse_time("not a date") + assert result is None + + +def test_check_baltic_inside(): + # Central Baltic (Bornholm basin area) + assert pycnv.check_baltic(17.0, 55.0) is True + + +def test_check_baltic_outside(): + # North Sea + assert pycnv.check_baltic(3.0, 55.0) is False + # North Atlantic + assert pycnv.check_baltic(-10.0, 50.0) is False + + # Might want to add bounds here? IDK... + assert pycnv.check_baltic(-1000, -1000) is False + + +def test_get_stations_returns_list(): + stations = pycnv.get_stations() + assert isinstance(stations, list) + assert len(stations) > 0 + + +def test_get_stations_keys(): + stations = pycnv.get_stations() + for s in stations: + assert "name" in s + assert "longitude" in s + assert "latitude" in s