Initial commit: GasFlux project with core processing pipelines

This commit is contained in:
2026-01-05 09:33:27 +08:00
commit f085d7c5fe
50 changed files with 10634 additions and 0 deletions

205
tests/test_processing.py Normal file
View File

@ -0,0 +1,205 @@
import pandas as pd
from src.gasflux import processing
import yaml
from pathlib import Path
import numpy as np
from src.gasflux.processing import min_angular_displacement
import pytest
testdf = pd.read_csv(Path(__file__).parents[1] / "src" / "gasflux" / "testdata" / "testdata.csv")
testconfig = yaml.safe_load(open(Path(__file__).parents[1] / "src" / "gasflux" / "testdata" / "testconfig.yaml"))
def load_cols(cols):
return testdf[cols]
def test_min_angular_diff_def():
def test_min_angular_displacement():
assert min_angular_displacement(10, 350) == 20
assert min_angular_displacement(0, 180) == 180
x = np.array([10, 0])
y = np.array([350, 180])
expected = np.array([20, 180])
result = min_angular_displacement(x, y)
assert np.all(result == expected), "Vectorized function failed"
def test_circ_median():
x = np.array([0, 1, 2, 359, 4, 3])
median = processing.circ_median(x)
assert median == 1.5, "Circular median not calculated correctly"
@pytest.mark.parametrize(
"plane_angle,expected_winddir_rel,expected_windspeed_normal",
[
(
90,
[0, 90, 0, 90, 0],
[5, 0, 5, 0, 5],
),
(
30,
[60, 30, 60, 30, 60],
np.array([1 / 2, np.sqrt(3) / 2, 1 / 2, np.sqrt(3) / 2, 1 / 2]) * 5,
),
(
60,
[30, 60, 30, 60, 30],
np.array([np.sqrt(3) / 2, 1 / 2, np.sqrt(3) / 2, 1 / 2, np.sqrt(3) / 2]) * 5,
),
],
)
def test_wind_offset_correction_parametrized(plane_angle, expected_winddir_rel, expected_windspeed_normal):
data = {"winddir": [0, 90, 180, 270, 360], "windspeed": [5, 5, 5, 5, 5]}
df = pd.DataFrame(data)
corrected_df = processing.wind_offset_correction(df, plane_angle)
assert "winddir_rel" in corrected_df.columns, f"Relative wind direction column not added for angle {plane_angle}"
assert "windspeed" in corrected_df.columns, f"Normalised wind speed column not added for angle {plane_angle}"
assert np.allclose(corrected_df["winddir_rel"], expected_winddir_rel, rtol=1e-5, atol=1e-10), (
f"Relative wind directions not calculated correctly for angle {plane_angle}"
)
assert np.allclose(corrected_df["windspeed"], expected_windspeed_normal, rtol=1e-5, atol=1e-10), (
f"Normalised wind speeds not calculated correctly for angle {plane_angle}"
)
def test_bimodal_azimuth():
input_mode = testconfig["transect_azimuth"]
input_reciprocal_mode = (input_mode + 180) % 360
df = load_cols(["course_azimuth", "height_ato"])
mode1, mode2 = processing.bimodal_azimuth(df)
assert (
min_angular_displacement(mode1, input_mode) < 3 or min_angular_displacement(mode1, input_reciprocal_mode) < 3
), "Mode1 does not match expected azimuth or its reciprocal within 3 degrees"
if min_angular_displacement(mode1, input_mode) < 3:
assert min_angular_displacement(mode2, input_reciprocal_mode) < 3, (
"Mode2 does not match expected reciprocal azimuth within 3 degrees"
)
else:
assert min_angular_displacement(mode2, input_mode) < 3, "Mode2 does not match expected azimuth within 3 degrees"
def test_bimodal_elevation():
df = load_cols(["course_elevation", "height_ato"])
input_mode = 0
input_reciprocal_mode = 0 - input_mode
mode1, mode2 = processing.bimodal_elevation(df)
assert (
min_angular_displacement(mode1, input_mode) < 3 or min_angular_displacement(mode1, input_reciprocal_mode) < 3
), "Mode1 does not match expected elevation or its reciprocal within 3 degrees"
if min_angular_displacement(mode1, input_mode) < 3:
assert min_angular_displacement(mode2, input_reciprocal_mode) < 3, (
"Mode2 does not match expected reciprocal elevation within 3 degrees"
)
else:
assert min_angular_displacement(mode2, input_mode) < 3, (
"Mode2 does not match expected elevation within 3 degrees"
)
def test_height_transect_splitter():
df = load_cols(["height_ato"])
df, fig = processing.height_transect_splitter(df)
assert "transect_num" in df.columns, "Transect number column not added to dataframe"
assert df["transect_num"].nunique() == testconfig["number_of_transects"], (
"Dataframe was not split into the right number of transects"
)
def test_add_transect_azimuth_switches():
df = load_cols(["course_azimuth"])
df = processing.add_transect_azimuth_switches(df)
assert df["transect_num"].nunique() == testconfig["number_of_transects"], (
"Transect azimuth switches not added to dataframe"
)
def test_course_filter():
df = load_cols(["course_azimuth", "course_elevation", "height_ato"])
azimuth_filter = testconfig["filters"]["course_filter"]["azimuth_filter"]
azimuth_window = testconfig["filters"]["course_filter"]["azimuth_window"]
elevation_filter = testconfig["filters"]["course_filter"]["elevation_filter"]
df_filtered, df_unfiltered = processing.course_filter(
df, azimuth_filter=azimuth_filter, azimuth_window=azimuth_window, elevation_filter=elevation_filter
)
input_mode = testconfig["transect_azimuth"]
input_reciprocal_mode = (input_mode + 180) % 360
# assert that the filtered dataframe contains the expected azimuth or its reciprocal within the window
df_filtered["near_mode1"] = df_filtered["rolling_course_azimuth"].apply(
lambda x: min_angular_displacement(x, input_mode) < azimuth_window
)
df_filtered["near_mode2"] = df_filtered["rolling_course_azimuth"].apply(
lambda x: min_angular_displacement(x, input_reciprocal_mode) < azimuth_window
)
assert df_filtered["near_mode1"].any() or df_filtered["near_mode2"].any(), (
"Filtered dataframe does not contain expected azimuth or its reciprocal within the window"
)
def test_mCount_max():
data_dict = {1: -5.4, 2: 0.6, 3: 5.6, 4: 3.2, 5: 10.4, 6: 18.4, 7: 20.8, 8: 19.4}
start, end = processing.mCount_max(data_dict)
assert start == 4, "Start index of max count not calculated correctly"
assert end == 7, "End index of max count not calculated correctly"
def test_largest_monotonic_transect_series():
df = load_cols(
["timestamp", "height_ato", "course_azimuth", "longitude", "latitude", "utm_easting", "utm_northing"]
)
df, starttransect, endtransect = processing.largest_monotonic_transect_series(df)
starttransect = 1
endtransect = testconfig["number_of_transects"]
assert starttransect == starttransect, "Start index of largest monotonic transect not calculated correctly"
assert endtransect == endtransect, "End index of largest monotonic transect not calculated correctly"
def test_remove_non_transects():
df = load_cols(
["height_ato", "course_azimuth", "course_elevation", "longitude", "latitude", "utm_easting", "utm_northing"]
)
retained_df, removed_df = processing.remove_non_transects(df)
assert retained_df is not None, "Retained dataframe is None"
assert removed_df is not None, "Removed dataframe is None"
def test_flatten_linear_plane():
df = load_cols(["height_ato", "utm_easting", "utm_northing"])
df, plane_angle = processing.flatten_linear_plane(df)
input_plane_angle = testconfig["transect_azimuth"]
reciprocal_plane_angle = (input_plane_angle + 180) % 360
assert (
min_angular_displacement(plane_angle, input_plane_angle) < 3
or min_angular_displacement(plane_angle, reciprocal_plane_angle) < 3
), "Plane angle not calculated correctly"
def test_drone_anemo_to_point_wind():
data = {
"yaw": [0, 90, 0, -90, 180],
"anemo_u": [0, 0, 10, 10, 10],
"anemo_v": [0, 0, 0, 0, 0],
"easting": [0, 10, 0, 10, 0],
"northing": [0, 0, 0, 0, 10],
}
df_test = pd.DataFrame(data)
yaw_col = "yaw"
anemo_u_col = "anemo_u"
anemo_v_col = "anemo_v"
easting_col = "easting"
northing_col = "northing"
result_df = processing.drone_anemo_to_point_wind(
df_test, yaw_col, anemo_u_col, anemo_v_col, easting_col, northing_col
)
expected_windspeed = np.array([0, 10, 10, np.sqrt(200), np.sqrt(200)])
expected_winddir = np.array(
[180, 270, 270, 225, 135]
) # 180 not zero because of the way IEEE 754 handles floating point numbers
windspeed_diff = np.abs(result_df["windspeed"].values - expected_windspeed)
winddir_diff = processing.min_angular_displacement(result_df["winddir"].to_numpy(), expected_winddir)
assert np.all(windspeed_diff < 1e-10), "Wind speed not calculated correctly"
assert np.all(np.array(winddir_diff) < 3), "Wind direction not calculated correctly"