diff --git a/endaq/calc/utils.py b/endaq/calc/utils.py index 2ba7eb9..c3b16ce 100644 --- a/endaq/calc/utils.py +++ b/endaq/calc/utils.py @@ -3,7 +3,7 @@ from __future__ import annotations import typing -from typing import Optional, Union +from typing import Optional, Union, Literal import warnings import numpy as np @@ -161,9 +161,9 @@ def resample(df: pd.DataFrame, sample_rate: Optional[float] = None) -> pd.DataFr index=resampled_time.astype(df.index.dtype), columns=df.columns, ) - + resampled_df.index.name = df.index.name - + return resampled_df @@ -343,3 +343,119 @@ def convert_units( for i, c in enumerate(df.columns): converted_df[c] = vals[:, i] return converted_df + + +def to_altitude(df: pd.DataFrame, + base_press: Optional[float] = 101325, + base_temp: Optional[float] = 15, + temp_col_index: Optional[int] = None, + press_col_index: Optional[int] = None, + units: Literal['m', 'ft'] = 'm') -> pd.DataFrame: + """ + Converts pressure (Pascals) to altitude (feet or meters) up to 50km. + + :param df: pandas DataFrame of one of the temperature/pressure channels. + A pressure column should be present, if not, raise an error + :param base_press: P_b; reference pressure (pressure at sea level) in + Pascals (Pa). If set to None, use the first pressure measurement + included in the dataframe (df) + :param base_temp: T_b reference temperature (temperature at sea level) in + Celsius (C). If set to None and a Temperature column exists in the + dataframe (df), use the first listed temperature value. If set to None + and no temperature column exists, error out + :param temp_col_index: the index (starting at 0) in the dataframe (df) of + the temperature column to pull data from. + :param press_col_index: the index (starting at 0) in the dataframe (df) of + the pressure column to pull data from + :param units: determines if altitude is represented in meters ('m') or feet + ('ft') in both the input and output dataframes + :returns: a pandas DataFrame with the same Index values as the + input, and an added column of “Altitude (m)” or “Altitude (ft)” data + """ + # Column Name Placeholders + temp_col = None + press_col = None + + # Conversion Constants + C_K = 273.15 # Celsius to Kelvin Constant (C + 273.15 = K) + ft_m = 0.3048 # Feet to Meters Constant (ft * 0.3048 = m) & (m / 0.3048 = ft) + + # Constants + h_b = 0 # Reference Height of Sea Level (m) + h_sb = 11000 # Height at the Base of the Stratosphere (meters) + h_st = 50000 # Height at the Top of the Stratosphere (meters) + L_b = -0.0065 # Standard Temperature Lapse Rate [K/m] + g_0 = 9.80665 # Gravitational Acceleration Constant [m/s^2] + R = 8.31432 # Universal Gas Constant [N*m/mol*K] + M = 0.0289644 # Molar Mass of Earth's Air [kg/mol] + top_stratosphere_pressure = 100 # Air Pressure at Stratopause (1mb)=[100 Pa] + + # Finding pressure column + if press_col_index is None: + for col in df.columns: + if "press" in col.lower(): + press_col = col + press_col_index = df.columns.get_loc(press_col) + break + if press_col is None: + raise ValueError("Pressure column not found.") + else: + press_col = df.columns[press_col_index] + + # Checking for base temp and converting to K + if base_temp == None: + if temp_col_index is None: + for col in df.columns: + if "temp" in col.lower(): + temp_col = col + temp_col_index = df.columns.get_loc(temp_col) + break + if temp_col is None: + raise ValueError('Temperature column not found.') + T_b = df.iloc[0, temp_col_index] + C_K # Standard Temperature in Kelvin + T_bS = T_b -71.5 # Temperature at start of Stratosphere + else: + T_b = base_temp + C_K # Standard Temperature in Kelvin + T_bS = T_b -71.5 # Temperature at start of Stratosphere + + # Checking for base pressure + if base_press == None: + # Set to first pressure recording in the dataframe + P_b = df.iloc[0, press_col_index] # Static Pressure in Pascals + else: + P_b = base_press # Static Pressure in Pascals + + # Pressure at base of Stratosphere + base_stratosphere_pressure = (P_b * (1 + (L_b / T_b) * (h_sb - h_b)) ** + ((-g_0 * M) / (R * L_b))) + + # List of Altitudes to be added to Dataframe + altitude_column = [] + + # Calculate Altitude for the DataFrame + for index, row in df.iterrows(): + P = row[press_col] + if P > base_stratosphere_pressure: + h = h_b + (T_b / L_b) * (((P / P_b) ** ((-R * L_b) / (g_0 * M))) - 1) + altitude_column.append(h) + elif top_stratosphere_pressure < P < base_stratosphere_pressure: + h = h_sb + ((R * T_bS * np.log(P / base_stratosphere_pressure)) / + (-g_0 * M)) + altitude_column.append(h) + else: + raise ValueError("Altitudes above stratosphere not supported.") + + # Convert Altitude back to feet if specified + if units == 'ft': + altitude_column = [x / ft_m for x in altitude_column] + # Add "Altitude (ft)" Column to Copy of Original Dataframe + alt_df = df.copy() + alt_df["Altitude (ft)"] = altitude_column + + # Add "Altitude (m)" Column to Copy of Original Dataframe + if units == 'm': + alt_df = df.copy() + alt_df["Altitude (m)"] = altitude_column + + # Return DataFrame with New Altitude Column + return alt_df diff --git a/setup.py b/setup.py index 48bda63..bdf00d5 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ def get_version(rel_path): "ebmlite>=3.2.0", "idelib>=3.2.8", "jinja2", - "numpy>=1.19.5", + "numpy>1.19.5", "pandas>=1.3", "plotly>=5.3.1", "pynmeagps", diff --git a/tests/batch/test_core.py b/tests/batch/test_core.py index 7a1a136..f7561c3 100644 --- a/tests/batch/test_core.py +++ b/tests/batch/test_core.py @@ -292,7 +292,7 @@ def assert_output_is_valid(output: endaq.batch.core.OutputStruct): @pytest.mark.filterwarnings("ignore:no acceleration channel in:UserWarning") @pytest.mark.filterwarnings( "ignore" - ":nperseg .* is greater than input length .*, using nperseg .*" + ":.*nperseg.* is greater than (signal|input) length.*, using nperseg .*" ":UserWarning" ) def test_aggregate_data(getdata_builder): diff --git a/tests/calc/csv_to_df/beyond_stratosphere.csv b/tests/calc/csv_to_df/beyond_stratosphere.csv new file mode 100644 index 0000000..53731f0 --- /dev/null +++ b/tests/calc/csv_to_df/beyond_stratosphere.csv @@ -0,0 +1,2 @@ +Pressure (Pa),Temperature (C) +25,15 diff --git a/tests/calc/csv_to_df/default_sea_lvl.csv b/tests/calc/csv_to_df/default_sea_lvl.csv new file mode 100644 index 0000000..75cb987 --- /dev/null +++ b/tests/calc/csv_to_df/default_sea_lvl.csv @@ -0,0 +1,18 @@ +Pressure (Pa),Temperature (C) +101325,15 +100000, +95000, +90000, +85000, +80000, +75000, +70000, +65000, +60000, +55000, +50000, +45000, +40000, +35000, +30000, +25000, diff --git a/tests/calc/csv_to_df/keys/base_values_none.csv b/tests/calc/csv_to_df/keys/base_values_none.csv new file mode 100644 index 0000000..0bc8d1f --- /dev/null +++ b/tests/calc/csv_to_df/keys/base_values_none.csv @@ -0,0 +1,18 @@ +Key,base settings = None +0.00 +110.88 +540.34 +988.50 +1457.30 +1948.99 +2466.23 +3012.18 +3590.69 +4206.43 +4865.22 +5574.44 +6343.62 +7185.44 +8117.27 +9163.96 +10362.95 \ No newline at end of file diff --git a/tests/calc/csv_to_df/keys/default_settings.csv b/tests/calc/csv_to_df/keys/default_settings.csv new file mode 100644 index 0000000..74291a5 --- /dev/null +++ b/tests/calc/csv_to_df/keys/default_settings.csv @@ -0,0 +1,18 @@ +Key,settings are default +0.00 +110.88 +540.34 +988.50 +1457.30 +1948.99 +2466.23 +3012.18 +3590.69 +4206.43 +4865.22 +5574.44 +6343.62 +7185.44 +8117.27 +9163.96 +10362.95 \ No newline at end of file diff --git a/tests/calc/csv_to_df/keys/diff_base_pressure.csv b/tests/calc/csv_to_df/keys/diff_base_pressure.csv new file mode 100644 index 0000000..486f9f3 --- /dev/null +++ b/tests/calc/csv_to_df/keys/diff_base_pressure.csv @@ -0,0 +1,18 @@ +Key,the base pressure is not set to the default value +-111.16 +0 +430.53 +879.82 +1349.79 +1842.71 +2361.25 +2908.57 +3488.53 +4105.81 +4766.26 +5477.25 +6248.37 +7092.29 +8026.46, +9075.77 +10277.77 \ No newline at end of file diff --git a/tests/calc/csv_to_df/keys/diff_base_temp.csv b/tests/calc/csv_to_df/keys/diff_base_temp.csv new file mode 100644 index 0000000..7d580ee --- /dev/null +++ b/tests/calc/csv_to_df/keys/diff_base_temp.csv @@ -0,0 +1,18 @@ +Key,the base temperature is not set to the default value +0.00 +116.66 +568.47 +1039.96 +1533.16 +2050.45 +2594.61 +3168.99 +3777.61 +4425.40 +5118.48 +5864.62 +6673.85 +7559.48 +8539.82 +9641.00 +10902.40 \ No newline at end of file diff --git a/tests/calc/csv_to_df/keys/diff_temp_and_pressure.csv b/tests/calc/csv_to_df/keys/diff_temp_and_pressure.csv new file mode 100644 index 0000000..2e72d29 --- /dev/null +++ b/tests/calc/csv_to_df/keys/diff_temp_and_pressure.csv @@ -0,0 +1,18 @@ +Key,the base temperature and pressure are not set to default values +-116.95 +0.00 +452.94 +925.62 +1420.06 +1938.64 +2484.17 +3059.98 +3670.13 +4319.54 +5014.37 +5762.38 +6573.63 +7461.49 +8444.29 +9548.22 +10812.79 \ No newline at end of file diff --git a/tests/calc/csv_to_df/keys/stratosphere.csv b/tests/calc/csv_to_df/keys/stratosphere.csv new file mode 100644 index 0000000..41bbcdf --- /dev/null +++ b/tests/calc/csv_to_df/keys/stratosphere.csv @@ -0,0 +1,12 @@ +Key,altitude is in the stratosphere +11784.05 +12452.21 +13199.14 +14045.95 +15023.51 +16179.72 +17594.82 +19419.19 +21990.49 +26386.17 +35177.52 \ No newline at end of file diff --git a/tests/calc/csv_to_df/keys/units_feet.csv b/tests/calc/csv_to_df/keys/units_feet.csv new file mode 100644 index 0000000..a250c2a --- /dev/null +++ b/tests/calc/csv_to_df/keys/units_feet.csv @@ -0,0 +1,18 @@ +Key,units are set to feet ('ft') +0.00 +363.79 +1772.76 +3243.11 +4781.17 +6394.32 +8091.29 +9882.49 +11780.47 +13800.61 +15962.0 +18288.84 +20812.4, +23574.27 +26631.46 +30065.48 +33999.16 \ No newline at end of file diff --git a/tests/calc/csv_to_df/press_error.csv b/tests/calc/csv_to_df/press_error.csv new file mode 100644 index 0000000..6e4fcb5 --- /dev/null +++ b/tests/calc/csv_to_df/press_error.csv @@ -0,0 +1,2 @@ +Temperature (C) +15 \ No newline at end of file diff --git a/tests/calc/csv_to_df/stratosphere.csv b/tests/calc/csv_to_df/stratosphere.csv new file mode 100644 index 0000000..55600fb --- /dev/null +++ b/tests/calc/csv_to_df/stratosphere.csv @@ -0,0 +1,12 @@ +Pressure (Pa),Temperature (C) +20000,-55 +18000 +16000 +14000 +12000 +10000 +8000 +6000 +4000 +2000 +500 \ No newline at end of file diff --git a/tests/calc/csv_to_df/temp_error.csv b/tests/calc/csv_to_df/temp_error.csv new file mode 100644 index 0000000..1e3223a --- /dev/null +++ b/tests/calc/csv_to_df/temp_error.csv @@ -0,0 +1,18 @@ +Pressure (Pa), +101325, +100000, +95000, +90000, +85000, +80000, +75000, +70000, +65000, +60000, +55000, +50000, +45000, +40000, +35000, +30000, +25000, diff --git a/tests/calc/test_utils.py b/tests/calc/test_utils.py index b012c89..b8a5ea9 100644 --- a/tests/calc/test_utils.py +++ b/tests/calc/test_utils.py @@ -5,6 +5,10 @@ import numpy as np import pandas as pd +import sys +import os +sys.path.insert(0, os.path.realpath(os.path.join(__file__, '..', '..', '..'))) + from endaq.calc import utils @@ -107,3 +111,61 @@ def test_convert_units(): df = pd.DataFrame({'Val': [-40, 0, 10]}) np.testing.assert_allclose(utils.convert_units('degC', 'degF', df).Val[0], -40) np.testing.assert_allclose(utils.convert_units('degC', 'degF', df).Val[1], 32) + + +# Dataframes for the following altitude test +df_1 = pd.read_csv("tests/calc/csv_to_df/default_sea_lvl.csv") +df_strat = pd.read_csv("tests/calc/csv_to_df/stratosphere.csv") + +@pytest.mark.parametrize("kwargs, key", [ + ({"df": df_1}, "default_settings.csv"), + ({"df": df_1, "base_temp": 30}, "diff_base_temp.csv"), + ({"df": df_1, "base_press": 100000}, "diff_base_pressure.csv"), + ({"df": df_1, "base_temp": 30, "base_press": 100000}, "diff_temp_and_pressure.csv"), + ({"df": df_1, "units":'ft'}, "units_feet.csv"), + ({"df": df_1, "base_press": None, "base_temp": None}, "base_values_none.csv"), + ({"df": df_strat}, "stratosphere.csv"), + ]) +def test_to_altitude(kwargs, key): + """ + Tests the accuracy of the to_altitude function, which converts air pressure + to altitude. + """ + possible_altitude_cols = ['Altitude (m)', 'Altitude (ft)'] + altitude_col = None + + key_df = pd.read_csv("tests/calc/csv_to_df/keys/" + key) + key_list = key_df['Key'].tolist() + + to_alt_df = utils.to_altitude(**kwargs) + for col in possible_altitude_cols: + if col in to_alt_df.columns: + altitude_col = col + break + + to_alt_list = to_alt_df[altitude_col].tolist() + to_alt_list = [round(num, 2) for num in to_alt_list] + assert (to_alt_list == key_list + ), f"Equation is not accurate when {key_df.columns[1]}." + + +# Dataframes for the following altitude error test +df_err_temp = pd.read_csv("tests/calc/csv_to_df/temp_error.csv") +df_err_press = pd.read_csv("tests/calc/csv_to_df/press_error.csv") +df_err_strat = pd.read_csv("tests/calc/csv_to_df/beyond_stratosphere.csv") + +@pytest.mark.parametrize("kwargs, error_message", [ + ({"df": df_err_temp, "base_temp": None}, + "there's no temperature column and base_temp = None"), + ({"df": df_err_press, "base_press": None}, + "there's no pressure column and base_press = None"), + ({"df": df_err_strat}, + "altitude is above the stratosphere (50km)"), + ]) +def test_to_altitude_errors(kwargs, error_message): + """ + Tests that the to_altitude function raises errors when expected to. + """ + with pytest.raises(ValueError) as exc_info: + utils.to_altitude(**kwargs) + assert (exc_info.type == ValueError), f"Error not raised when {error_message}."