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

0
src/__init__.py Normal file
View File

29
src/gasflux/__init__.py Normal file
View File

@ -0,0 +1,29 @@
"""Init file for gasflux package."""
__version__ = "0.2.1" # managed by semantic versioning
from . import (
background,
cli,
gas,
interpolation,
ml,
plotting,
pre_processing,
processing,
processing_pipelines,
reporting,
)
__all__ = [
"background",
"cli",
"gas",
"interpolation",
"ml",
"plotting",
"pre_processing",
"processing",
"processing_pipelines",
"reporting",
]

64
src/gasflux/background.py Normal file
View File

@ -0,0 +1,64 @@
"""Baselining functions."""
import numpy as np
import pandas as pd
import pybaselines as pybs
from . import plotting
# 自定义阈值函数,避免依赖scikit-image
def custom_threshold(data):
"""
简单的三角阈值算法实现,避免依赖scikit-image
使用直方图的三角法来确定阈值
"""
if len(data) == 0:
return 0
# 计算直方图
hist, bin_edges = np.histogram(data, bins=256)
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
# 找到最大峰值
max_idx = np.argmax(hist)
# 如果最大峰在边缘,使用中位数作为阈值
if max_idx == 0 or max_idx == len(hist) - 1:
return np.median(data)
# 使用简单的阈值策略:最大峰右侧的第一个局部最小值
# 这里简化为使用均值作为阈值
return np.mean(data)
def algorithmic_baseline(
df: pd.DataFrame,
gas: str,
algorithmic_baseline_settings: dict,
):
df = df.copy()
algorithm = algorithmic_baseline_settings["algorithm"]
settings = algorithmic_baseline_settings.get(algorithm, {}).copy()
if settings.get("threshold") == "custom":
settings["threshold"] = custom_threshold
if len(df) < 20:
raise ValueError("Dataframe must contain at least 20 rows for background correction.")
index = np.arange(len(df))
baseline_fitter = pybs.Baseline(index, check_finite=False)
fit = getattr(baseline_fitter, algorithm)
bkg, params = fit(df[gas], **settings)
bkg_points = params["mask"]
df[f"{gas}_normalised"] = df[gas] - bkg
df[f"{gas}_fit"] = bkg
background = (df[gas] - bkg)[bkg_points]
signal = (df[gas] - bkg)[~bkg_points]
df[f"{gas}_signal"] = np.invert(bkg_points)
fig = plotting.background_plotting(df, gas)
output_text = (
f"Baseline algorithm: {algorithm}\n"
f"Positive and negative 95% percentile of baseline: {np.percentile(background, 2.5):.2f} ppm, \
{np.percentile(background, 97.5):.2f} ppm\n"
f"Mean of baseline: {np.mean(background):.2f} ppm\n"
f"Minimum and maximum of baseline: {np.min(background):.2f} ppm, {np.max(background):.2f} ppm\n"
f"Signal points: {len(signal)}; background points: {len(background)}\n"
)
return df, fig, output_text

279
src/gasflux/cli.py Normal file
View File

@ -0,0 +1,279 @@
import argparse
from pathlib import Path
import shutil
from colorama import init, Fore, Style
from src.gasflux.processing_pipelines import process_main
init()
def find_config_file(path: Path, recursive: bool = True):
if recursive:
config_files = [file for file in path.rglob("*.yaml") if "gasflux_config" in file.read_text()]
else:
config_files = [file for file in path.glob("*.yaml") if "gasflux_config" in file.read_text()]
if len(config_files) == 1:
return config_files[0]
elif len(config_files) > 1:
raise ValueError(
"Multiple candidate config files found: {}".format(", ".join(str(file) for file in config_files))
)
else:
raise FileNotFoundError("No config file found in the supplied path or its child folders")
def process_command(data_path: str, config_path: str, test: bool):
if test:
data_file = Path(__file__).parent / "testdata" / "testdata.csv"
config_file = Path(__file__).parent / "testdata" / "testconfig.yaml"
process_main(data_file, config_file)
return
dpath_obj = Path(data_path)
if dpath_obj.is_dir():
data_files = list(dpath_obj.rglob("*.csv"))
if not data_files:
raise FileNotFoundError(f"No CSV files found in directory: {data_path}")
elif dpath_obj.is_file():
data_files = [dpath_obj]
else:
raise FileNotFoundError(f"Invalid data path: {data_path}")
if config_path is None:
try:
config_file = find_config_file(dpath_obj if dpath_obj.is_dir() else dpath_obj.parent)
except FileNotFoundError:
response = (
input(
"No configuration file found. Generate config file? \n"
"You may have to modify it before processing. [y/n]: "
)
.strip()
.lower()
)
if response == "y":
config_file = (dpath_obj if dpath_obj.is_dir() else dpath_obj.parent) / "gasflux_config.yaml"
shutil.copy(Path(__file__).parent / "gasflux_config.yaml", config_file)
print(f"Config file copied to: {config_file}")
else:
print("No config file generated. Exiting.")
return
else:
config_file = Path(config_path)
for data_file in data_files:
process_main(data_file, config_file)
def generate_config_command(config_destination: str, recursive: bool = False, template_path: str | None = None):
if template_path:
template = Path(template_path)
print(f"Using custom template: {template}")
else:
template = Path(__file__).parent / "gasflux_config.yaml"
if not template.exists():
raise FileNotFoundError(f"Template file not found: {template}")
destination_path = Path(config_destination)
if not destination_path.is_dir():
raise NotADirectoryError(f"Destination path is not a directory: {config_destination}")
overwrite_all = False # Flag to determine if we should overwrite all files without prompting
if recursive:
for subdir in destination_path.rglob("*"):
if subdir.is_dir():
config_file = subdir / "gasflux_config.yaml"
if config_file.exists() and not overwrite_all:
response = (
input(f"Config file already exists at {config_file}. Overwrite? [y/n/a/c]: ").strip().lower()
)
if response == "a":
overwrite_all = True
elif response == "c":
print("Operation canceled.")
return
elif response == "n":
print(f"Skipping {config_file}")
continue
shutil.copy(template, config_file)
print(f"Config file copied to: {config_file}")
else:
config_file = destination_path / "gasflux_config.yaml"
if config_file.exists() and not overwrite_all:
response = input(f"Config file already exists at {config_file}. Overwrite? [y/n/a/c]: ").strip().lower()
if response == "a":
overwrite_all = True
elif response == "c":
print("Operation canceled.")
return
elif response == "n":
print(f"Skipping {config_file}")
return
shutil.copy(template, config_file)
print(f"Config file copied to: {config_file}")
def main_cli():
parser = argparse.ArgumentParser(
description="GasFlux Processing Pipeline",
formatter_class=argparse.RawTextHelpFormatter,
add_help=False, # Disable the default help to use the custom help screen
)
subparsers = parser.add_subparsers(dest="command")
process_parser = subparsers.add_parser(
"process",
help="Process CSV data files",
description=(
"Process CSV data files located in a specified directory or a single file.\n"
"The program will search for a configuration file (gasflux_config.yaml) in the same directory as the data file(s).\n" # noqa
),
formatter_class=argparse.RawTextHelpFormatter,
)
process_parser.add_argument(
"data_path",
nargs="?",
default=None,
help=(
"Path to the data file or directory containing CSV files.\n"
"If a directory is provided, all CSV files within the directory will be processed."
),
)
process_parser.add_argument(
"--config-path",
"-c",
default=None,
help=(
"Path to the configuration file (gasflux_config.yaml).\n"
"If not specified, the program will search for the configuration file in the same directory as the data file(s).\n" # noqa
"If no configuration file is found, the default configuration will be used."
),
)
process_parser.add_argument(
"--test",
action="store_true",
help="Use test data instead of the specified data file(s).",
)
config_parser = subparsers.add_parser(
"generate-config",
help="Generate a configuration file",
description=(
"Generate a configuration file (gasflux_config.yaml) in the specified directory.\n"
"If no directory is specified, the configuration file will be generated in the current directory."
"Use flag --recursive to generate config files in all subdirectories recursively. YOu might want to"
"do this if you want to have a separate config for each data file"
),
formatter_class=argparse.RawTextHelpFormatter,
)
config_parser.add_argument(
"--recursive",
action="store_true",
help="Generate config files in all subdirectories recursively.",
)
config_parser.add_argument(
"--template",
default=None,
help="Path to a custom template configuration file.",
)
config_parser.add_argument(
"config_destination",
nargs="?",
default=None,
help="Destination directory for the generated configuration file.",
)
# Custom help option
parser.add_argument(
"-h",
"--help",
action="store_true",
help="Show this help message and exit",
)
args = parser.parse_args()
if args.command == "process":
process_command(args.data_path, args.config_path, args.test)
elif args.command == "generate-config":
generate_config_command(args.config_destination, args.recursive, args.template)
elif args.help:
display_help()
else:
display_help()
def display_help():
help_text = f"""
{Fore.CYAN}GasFlux Processing Pipeline{Style.RESET_ALL}
{Fore.CYAN}==========================={Style.RESET_ALL}
{Fore.YELLOW}Description:{Style.RESET_ALL}
The GasFlux Processing Pipeline is a command-line tool for processing CSV data files and generating configuration files.
{Fore.YELLOW}Usage:{Style.RESET_ALL}
gasflux [OPTIONS] COMMAND [ARGS]...
{Fore.YELLOW}Commands:{Style.RESET_ALL}
{Fore.GREEN}process{Style.RESET_ALL} Process CSV data files
{Fore.GREEN}generate-config{Style.RESET_ALL} Generate a default configuration file
{Fore.YELLOW}Options:{Style.RESET_ALL}
{Fore.BLUE}-h, --help{Style.RESET_ALL} Show this help message and exit
{Fore.BLUE}-v, --version{Style.RESET_ALL} Show the version and exit
{Fore.YELLOW}Commands Help:{Style.RESET_ALL}
{Fore.GREEN}process{Style.RESET_ALL} Process CSV data files located in a specified directory or a single file.
The program will search for a singular configuration file (gasflux_config.yaml) in the same directory
- if a directory is supplied to [DATA_PATH] then child directories are searched too.
{Fore.YELLOW}Usage:{Style.RESET_ALL} gasflux process [OPTIONS] [DATA_PATH]
{Fore.YELLOW}Options:{Style.RESET_ALL}
{Fore.BLUE}-c, --config-path PATH{Style.RESET_ALL} Path to the configuration file (gasflux_config.yaml).
If not specified, the program will search for the configuration file
in the same directory as the data file(s).
If no configuration file is found, the default configuration will be used.
{Fore.BLUE}--test{Style.RESET_ALL} Use test data instead of the specified data file(s).
{Fore.BLUE}-h, --help{Style.RESET_ALL} Show this help message and exit
{Fore.GREEN}generate-config{Style.RESET_ALL} Generate a default configuration file (gasflux_config.yaml) in the specified directory.
If no directory is specified, the configuration file will be generated in the current directory.
{Fore.YELLOW}Usage:{Style.RESET_ALL} gasflux generate-config [OPTIONS] [CONFIG_DESTINATION]
{Fore.YELLOW}Options:{Style.RESET_ALL}
{Fore.BLUE}-h, --help{Style.RESET_ALL} Show this help message and exit
{Fore.YELLOW}Examples:{Style.RESET_ALL}
Process a single CSV file:
$ gasflux process {Fore.MAGENTA}"/path/to/data.csv"{Style.RESET_ALL}
Process all CSV files in a directory:
$ gasflux process {Fore.MAGENTA}"/path/to/data/directory"{Style.RESET_ALL}
Process a single CSV file with a specific configuration file:
$ gasflux process {Fore.MAGENTA}"/path/to/data.csv"{Style.RESET_ALL} --config-path {Fore.MAGENTA}/path/to/config.yaml{Style.RESET_ALL}
Process test data:
$ gasflux process {Fore.BLUE}--test{Style.RESET_ALL}
Generate a default configuration file in the current directory:
$ gasflux generate-config {Fore.MAGENTA}.{Style.RESET_ALL}
Generate a default configuration file in a specific directory recursively using a custom template:
$ gasflux generate-config {Fore.MAGENTA}"/path/to/directory"{Style.RESET_ALL} {Fore.BLUE}--recursive{Style.RESET_ALL} {Fore.BLUE}--template{Style.RESET_ALL} {Fore.MAGENTA}"/path/to/template.yaml"{Style.RESET_ALL}
""" # noqa
print(help_text)
if __name__ == "__main__":
main_cli()

View File

@ -0,0 +1,934 @@
#!/usr/bin/env python3
"""
无人机数据处理脚本
处理Excel格式的无人机测量数据,转换为GasFlux标准输入格式。
支持两种使用方式:
1. 命令行调用:python data_processor.py input.xlsx
2. 直接调用:from data_processor import process_file; df = process_file('input.xlsx')
处理步骤:
1. 读取Excel文件
2. 删除不需要的列
3. 根据文件名修正时间格式
4. 坐标转换(经纬度除以10^7)
5. 计算气压(使用qiya.py)
6. 高度调整(减去最小高度)
7. 时间戳融合
8. 字段重命名
作者:GasFlux开发团队
"""
import pandas as pd
import numpy as np
from pathlib import Path
import re
from datetime import datetime
import sys
import os
from collections import Counter
try:
from tqdm import tqdm
HAS_TQDM = True
except ImportError:
HAS_TQDM = False
print("⚠️ 未安装tqdm库,将不显示进度条。如需进度条,请运行: pip install tqdm")
def create_height_bins(heights, bin_size=2.0):
"""
将高度数据按指定间隔分档
Args:
heights: 高度数据Series
bin_size: 每个档位的间隔(米)
Returns:
list: [(bin_min, bin_max, bin_center, count), ...] 每个档位的信息
"""
if len(heights) == 0:
return []
height_min = heights.min()
height_max = heights.max()
# 计算需要的档位数量
range_size = height_max - height_min
if range_size == 0:
# 所有高度相同
return [(height_min, height_max, height_min, len(heights))]
num_bins = max(1, int(np.ceil(range_size / bin_size)))
bins = []
for i in range(num_bins):
bin_min = height_min + i * bin_size
bin_max = min(height_min + (i + 1) * bin_size, height_max)
bin_center = (bin_min + bin_max) / 2
# 统计这个档位有多少数据点
count = ((heights >= bin_min) & (heights < bin_max)).sum()
if i == num_bins - 1: # 最后一个档位包含上限
count = ((heights >= bin_min) & (heights <= bin_max)).sum()
if count > 0: # 只保留有数据的档位
bins.append((bin_min, bin_max, bin_center, count))
return bins
# 导入qiya模块
try:
from .qiya import get_pressure_at_location
print("✅ 成功导入qiya模块")
except ImportError as e:
print(f"❌ 导入qiya模块失败: {e}")
print("请确保GasFlux包结构完整")
sys.exit(1)
def load_excel_data(file_path):
"""
读取Excel文件并进行初步处理
Args:
file_path: Excel文件路径
Returns:
pd.DataFrame: 读取的数据
"""
try:
print(f"正在读取文件: {file_path}")
# 读取Excel文件
df = pd.read_excel(file_path)
print(f"✅ 成功读取数据:{len(df)} 行,{len(df.columns)} 列")
print(f"列名:{list(df.columns)}")
return df
except Exception as e:
print(f"❌ 读取文件失败: {e}")
sys.exit(1)
def remove_columns(df, columns_to_remove):
"""
删除指定的列
Args:
df: 输入DataFrame
columns_to_remove: 要删除的列名列表
Returns:
pd.DataFrame: 删除列后的DataFrame
"""
print(f"\n删除列: {columns_to_remove}")
# 检查要删除的列是否存在
existing_columns = [col for col in columns_to_remove if col in df.columns]
missing_columns = [col for col in columns_to_remove if col not in df.columns]
if missing_columns:
print(f"⚠️ 以下列不存在(跳过): {missing_columns}")
if existing_columns:
df = df.drop(columns=existing_columns)
print(f"✅ 已删除 {len(existing_columns)} 列")
return df
def extract_hour_from_filename(filename):
"""
从文件名中提取小时信息
例如: "08_34_01_间隔高度5m.xlsx" -> "08"
Args:
filename: 文件名
Returns:
str: 小时字符串(两位数)
"""
# 使用正则表达式匹配小时部分
match = re.match(r'(\d{2})_', filename)
if match:
return match.group(1)
else:
print(f"⚠️ 无法从文件名 '{filename}' 中提取小时信息,使用默认值 '00'")
return "00"
def fix_time_column(df, filename):
"""
根据文件名修正时间列
Args:
df: 输入DataFrame
filename: 文件名
Returns:
pd.DataFrame: 修正后的DataFrame
"""
print("修正时间格式...")
# 提取小时信息
hour_prefix = extract_hour_from_filename(filename)
print(f"从文件名提取的小时: {hour_prefix}")
# 检查时间列是否存在
if '时间' not in df.columns:
print("❌ 未找到 '时间' 列")
return df
# 修正时间格式
def fix_single_time(time_str):
if pd.isna(time_str):
return time_str
time_str = str(time_str).strip()
# 如果时间格式类似 "0:34:01" 或 "00:34:01"
if re.match(r'^\d{1,2}:\d{2}:\d{2}$', time_str):
parts = time_str.split(':')
original_hour = int(parts[0])
filename_hour = int(hour_prefix)
# 小时相加
new_hour = (filename_hour + original_hour) % 24 # 防止超过24小时
# 保持分钟和秒不变
new_time = f"{new_hour:02d}:{parts[1]}:{parts[2]}"
return new_time
else:
# 其他格式,使用默认时间
return f"{hour_prefix}:00:00"
# 应用时间修正
original_times = df['时间'].head(3).tolist()
df['时间'] = df['时间'].apply(fix_single_time)
corrected_times = df['时间'].head(3).tolist()
print(f"时间修正示例:")
for orig, corr in zip(original_times, corrected_times):
print(f" {orig} → {corr}")
return df
def convert_coordinates(df):
"""
转换经纬度坐标(除以10^7)
Args:
df: 输入DataFrame
Returns:
pd.DataFrame: 转换后的DataFrame
"""
print("转换经纬度坐标...")
if '经度' in df.columns:
original_lon = df['经度'].head(3).tolist()
df['经度'] = df['经度'] / 1e7
converted_lon = df['经度'].head(3).tolist()
print("经度转换示例:")
for orig, conv in zip(original_lon, converted_lon):
print(".6f")
if '纬度' in df.columns:
original_lat = df['纬度'].head(3).tolist()
df['纬度'] = df['纬度'] / 1e7
converted_lat = df['纬度'].head(3).tolist()
print("纬度转换示例:")
for orig, conv in zip(original_lat, converted_lat):
print(".6f")
return df
def calculate_pressure(df, max_samples=None, height_tolerance=10.0, height_bin_size=2.0):
"""
计算气压数据(高度分档优化版)
Args:
df: 输入DataFrame
max_samples: 最大采样数量(None表示计算所有行)
height_tolerance: 高度变化容差(米),如果所有高度都在此范围内,只计算一次
height_bin_size: 高度分档间隔(米),每个档位使用中间高度计算气压
Returns:
pd.DataFrame: 添加气压列的DataFrame
"""
print("计算气压数据...")
# 检查必要列是否存在
required_cols = ['日期', '时间', '经度', '纬度', '融合高程']
missing_cols = [col for col in required_cols if col not in df.columns]
if missing_cols:
print(f"❌ 缺少必要列: {missing_cols}")
return df
# 检查高度变化范围
height_min = df['融合高程'].min()
height_max = df['融合高程'].max()
height_range = height_max - height_min
print(f"🏔️ 高度范围: {height_min:.1f} - {height_max:.1f} 米 (变化: {height_range:.1f} 米)")
# 创建高度分档
height_bins = create_height_bins(df['融合高程'], height_bin_size)
print(f"📏 高度分档: {len(height_bins)} 个档位 (间隔: {height_bin_size:.1f} 米)")
for i, (bin_min, bin_max, bin_center, count) in enumerate(height_bins):
print(f" 档位{i+1}: {bin_min:.1f}-{bin_max:.1f}m (中心: {bin_center:.1f}m, 数据: {count}行)")
# 决定计算策略
if height_range <= height_tolerance:
# 高度变化小,只计算一次气压
print("🎯 高度变化小,将使用平均高度计算一次气压")
use_single_calculation = True
mean_height = df['融合高程'].mean()
print(f"📍 使用平均高度: {mean_height:.1f} 米")
elif len(height_bins) == 1:
# 只有一个高度档位,使用档位中心高度
print("📦 只有一个高度档位,使用档位中心高度")
use_single_calculation = True
mean_height = height_bins[0][2] # bin_center
print(f"📍 使用档位中心高度: {mean_height:.1f} 米")
else:
# 高度变化大,使用分档计算
print("🏗️ 使用高度分档策略,减少API调用")
use_single_calculation = False
# 确定要处理的行数
if max_samples is None or max_samples >= len(df):
# 计算所有行
sample_df = df.copy()
actual_samples = len(df)
if not use_single_calculation:
print(f"📊 将计算所有 {len(df)} 行的气压数据")
else:
# 限制采样数量
print(f"⚠️ 数据量较大 ({len(df)} 行),只对前 {max_samples} 行计算气压")
sample_df = df.head(max_samples).copy()
actual_samples = max_samples
pressures = []
if use_single_calculation:
# 只计算一次气压
try:
# 使用第一行的日期和时间作为代表
first_row = sample_df.iloc[0]
# 转换日期格式 - 只提取日期部分,移除任何时间信息
date_str = str(first_row['日期'])
if ' ' in date_str:
date_str = date_str.split(' ')[0]
elif 'T' in date_str:
date_str = date_str.split('T')[0]
if '/' in date_str:
date_str = date_str.replace('/', '-')
date_parts = date_str.split('-')
if len(date_parts) == 3 and len(date_parts[0]) == 4:
year, month, day = date_parts
formatted_date = f"{year}-{month.zfill(2)}-{day.zfill(2)}"
else:
raise ValueError(f"日期格式异常: {date_str}")
# 使用数据的代表性时间(整点小时,众数)
time_strings = []
for time_val in sample_df['时间']:
time_str = str(time_val).strip()
if ':' in time_str:
# 确保是有效的 HH:MM 格式,然后取整点小时
parts = time_str.split(':')
if len(parts) >= 2:
try:
hour = int(parts[0])
# 确保小时在有效范围内 (0-23)
if 0 <= hour <= 23:
time_strings.append(f"{hour:02d}:00")
except ValueError:
continue
if time_strings:
time_counts = Counter(time_strings)
formatted_time = time_counts.most_common(1)[0][0]
occurrence_count = time_counts.most_common(1)[0][1]
print(f"数据时间选择: {formatted_time} ({occurrence_count}/{len(time_strings)} 次,{occurrence_count/len(time_strings)*100:.1f}%)")
else:
formatted_time = "12:00"
print("无有效时间数据,使用默认中午12:00")
print("正在计算平均气压...")
pressure = get_pressure_at_location(
lat=sample_df['纬度'].mean(),
lon=sample_df['经度'].mean(),
altitude=mean_height,
date=formatted_date,
time=formatted_time
)
if pressure is not None:
pressures = [pressure] * len(sample_df)
print("✅ 平均气压计算成功,将应用到所有行")
else:
print("❌ 平均气压计算失败")
pressures = [None] * len(sample_df)
except Exception as e:
print(f"❌ 平均气压计算失败: {e}")
pressures = [None] * len(sample_df)
else:
# 使用高度分档策略
print("🏗️ 开始分档计算气压...")
# 为每个高度档位计算气压
bin_pressures = {} # bin_center -> pressure
# 设置进度条
iterator = height_bins
if HAS_TQDM:
iterator = tqdm(iterator, total=len(height_bins), desc="计算气压档位", unit="档")
for bin_min, bin_max, bin_center, count in iterator:
try:
# 使用第一行数据作为代表来获取日期和时间
# 找到这个档位中的一行数据
bin_rows = sample_df[(sample_df['融合高程'] >= bin_min) &
(sample_df['融合高程'] <= bin_max)]
if len(bin_rows) == 0:
continue
first_row = bin_rows.iloc[0]
# 转换日期格式 - 只提取日期部分,移除任何时间信息
date_str = str(first_row['日期'])
if ' ' in date_str:
date_str = date_str.split(' ')[0]
elif 'T' in date_str:
date_str = date_str.split('T')[0]
if '/' in date_str:
date_str = date_str.replace('/', '-')
date_parts = date_str.split('-')
if len(date_parts) == 3 and len(date_parts[0]) == 4:
year, month, day = date_parts
formatted_date = f"{year}-{month.zfill(2)}-{day.zfill(2)}"
else:
print(f"⚠️ 档位高度 {bin_center:.1f}m 日期格式异常: {date_str}")
bin_pressures[bin_center] = None
continue
# 使用该档位数据的代表性时间(整点小时)
time_strings = []
for time_val in bin_rows['时间']:
time_str = str(time_val).strip()
if ':' in time_str:
# 确保是有效的 HH:MM 格式,然后取整点小时
parts = time_str.split(':')
if len(parts) >= 2:
try:
hour = int(parts[0])
# 确保小时在有效范围内 (0-23)
if 0 <= hour <= 23:
time_strings.append(f"{hour:02d}:00")
except ValueError:
continue
if time_strings:
# 使用最常见的时间(众数)
time_counts = Counter(time_strings)
formatted_time = time_counts.most_common(1)[0][0]
occurrence_count = time_counts.most_common(1)[0][1]
print(f" 档位时间选择: {formatted_time} ({occurrence_count}/{len(time_strings)} 次,{occurrence_count/len(time_strings)*100:.1f}%)")
else:
# 如果没有有效时间,使用中午12点
formatted_time = "12:00"
print(" 无有效时间数据,使用默认中午12:00")
# 计算这个档位的气压(使用平均位置和档位中心高度)
avg_lat = bin_rows['纬度'].mean()
avg_lon = bin_rows['经度'].mean()
pressure = get_pressure_at_location(
lat=avg_lat,
lon=avg_lon,
altitude=bin_center,
date=formatted_date,
time=formatted_time
)
bin_pressures[bin_center] = pressure
# 更新进度条
if HAS_TQDM:
success_count = sum(1 for p in bin_pressures.values() if p is not None)
iterator.set_description(f"计算档位 (成功: {success_count}/{len(bin_pressures)})")
except Exception as e:
print(f"❌ 计算高度档位 {bin_center:.1f}m 气压失败: {e}")
bin_pressures[bin_center] = None
# 为每一行分配对应档位的气压
pressures = []
for idx, row in sample_df.iterrows():
# 找到这个高度对应的档位
height = row['融合高程']
assigned_pressure = None
for bin_min, bin_max, bin_center, count in height_bins:
if bin_min <= height <= bin_max:
assigned_pressure = bin_pressures.get(bin_center)
break
pressures.append(assigned_pressure)
print(f"✅ 完成分档气压计算,共 {len(bin_pressures)} 个档位,{len([p for p in bin_pressures.values() if p is not None])} 个成功")
# 添加气压列
df['pressure'] = None # 初始化
df.loc[sample_df.index, 'pressure'] = pressures
# 对于未计算的行,使用插值或平均值填充
if max_samples is not None and len(df) > max_samples:
# 只计算了部分行,用平均值填充其余行
valid_pressures_for_mean = [p for p in pressures if p is not None]
if valid_pressures_for_mean:
mean_pressure = sum(valid_pressures_for_mean) / len(valid_pressures_for_mean)
df['pressure'] = df['pressure'].fillna(mean_pressure)
print(f"使用平均气压填充其余 {len(df) - max_samples} 行: {mean_pressure:.1f} hPa")
# 统计信息
valid_pressures = [p for p in pressures if p is not None]
if valid_pressures:
avg_pressure = sum(valid_pressures) / len(valid_pressures)
print(f"成功计算 {len(valid_pressures)}/{actual_samples} 个气压值,平均值: {avg_pressure:.1f} hPa")
else:
print("⚠️ 未能计算出任何气压值")
return df
def adjust_altitude(df):
"""
调整融合高程(减去最小值)
Args:
df: 输入DataFrame
Returns:
pd.DataFrame: 调整后的DataFrame
"""
print("调整融合高程...")
if '融合高程' in df.columns:
min_altitude = df['融合高程'].min()
print(".2f")
original_alt = df['融合高程'].head(3).tolist()
df['融合高程'] = df['融合高程'] - min_altitude
adjusted_alt = df['融合高程'].head(3).tolist()
print("高度调整示例:")
for orig, adj in zip(original_alt, adjusted_alt):
print(".2f")
return df
def merge_timestamp(df):
"""
融合日期和时间列为时间戳
Args:
df: 输入DataFrame
Returns:
pd.DataFrame: 融合后的DataFrame
"""
print("融合日期和时间...")
if '日期' in df.columns and '时间' in df.columns:
timestamps = []
for idx, row in df.iterrows():
try:
date_str = str(row['日期'])
time_str = str(row['时间'])
# 清理日期字符串 - 移除任何时间部分
date_str = date_str.strip()
if ' ' in date_str:
date_str = date_str.split(' ')[0] # 只取日期部分
if 'T' in date_str:
date_str = date_str.split('T')[0] # 处理ISO格式
# 标准化日期格式
if '/' in date_str:
date_str = date_str.replace('/', '-')
# 确保日期格式正确
date_parts = date_str.split('-')
if len(date_parts) == 3:
year, month, day = date_parts
date_formatted = f"{year.zfill(4)}-{month.zfill(2)}-{day.zfill(2)}"
else:
print(f"⚠️ 日期格式异常: '{date_str}',使用当前日期")
date_formatted = datetime.now().strftime("%Y-%m-%d")
# 时间字符串已经是修正后的格式(如 "08:34:01"),直接使用
time_str = time_str.strip()
if ':' in time_str and len(time_str.split(':')) >= 2:
time_formatted = time_str
else:
print(f"⚠️ 时间格式异常: '{time_str}',使用默认时间")
time_formatted = "12:00:00"
# 组合时间戳 - 直接连接日期和时间
timestamp = f"{date_formatted} {time_formatted}"
timestamps.append(timestamp)
except Exception as e:
print(f"❌ 处理第 {idx+1} 行时间戳失败: {e}")
timestamps.append(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
df['timestamp'] = timestamps
print("时间戳融合示例:")
for i in range(min(3, len(timestamps))):
print(f" {df.loc[i, '日期']} + {df.loc[i, '时间']} → {timestamps[i]}")
return df
def rename_columns(df):
"""
重命名字段为GasFlux标准格式
Args:
df: 输入DataFrame
Returns:
pd.DataFrame: 重命名后的DataFrame
"""
print("重命名字段...")
# 定义字段映射
column_mapping = {
'timestamp': 'timestamp', # 时间戳(已创建)
'经度': 'longitude', # 经度 → latitude
'纬度': 'latitude', # 纬度 → longitude
'融合高程': 'height_ato', # 融合高程 → height_ato
'修正风向': 'winddir', # 修正风向 → winddir
'修正风速': 'windspeed', # 修正风速 → windspeed
'风温': 'temperature', # 风温 → temperature
'pressure': 'pressure', # 气压(已计算)
'CH4': 'ch4', # CH4保持不变
'pitch': 'course_elevation', # pitch → course_elevation
'yaw': 'course_azimuth' # yaw → course_azimuth
}
# 重命名存在的列
columns_to_rename = {}
for old_name, new_name in column_mapping.items():
if old_name in df.columns:
columns_to_rename[old_name] = new_name
if columns_to_rename:
df = df.rename(columns=columns_to_rename)
print("字段重命名:")
for old, new in columns_to_rename.items():
print(f" {old} → {new}")
# 只保留GasFlux需要的列
required_columns = ['timestamp', 'latitude', 'longitude', 'height_ato', 'windspeed', 'winddir', 'temperature', 'pressure', 'ch4', 'course_elevation', 'course_azimuth']
existing_required_columns = [col for col in required_columns if col in df.columns]
if len(existing_required_columns) != len(required_columns):
missing = [col for col in required_columns if col not in df.columns]
print(f"⚠️ 缺少必需列: {missing}")
# 移除不需要的列,只保留必需的列
df = df[existing_required_columns]
print(f"最终保留列: {existing_required_columns}")
return df
def process_excel_file(file_path):
"""
处理单个Excel文件的主函数
Args:
file_path: Excel文件路径
"""
print(f"=== 开始处理文件: {file_path} ===\n")
# 获取文件名(用于时间修正)
filename = Path(file_path).name
# 1. 读取数据
df = load_excel_data(file_path)
# 2. 删除不需要的列
columns_to_remove = [
'高程', '速度x', '速度y', '速度z',
'四元数_q0', '四元数_q1', '四元数_q2', '四元数_q3',
'roll', 'H2O', # 保留pitch和yaw,将重命名为course_elevation和course_azimuth
'原始风向', '原始风速'
]
df = remove_columns(df, columns_to_remove)
# 3. 修正时间格式
df = fix_time_column(df, filename)
# 4. 坐标转换
df = convert_coordinates(df)
# 5. 计算气压
df = calculate_pressure(df, max_samples=None, height_tolerance=10.0, height_bin_size=2.0) # 计算所有行,高度容差10米,分档2米
# 6. 高度调整
df = adjust_altitude(df)
# 7. 时间戳融合
df = merge_timestamp(df)
# 调试:检查当前列
print(f"时间戳融合后列名: {list(df.columns)}")
if 'timestamp' in df.columns:
print(f"timestamp列示例: {df['timestamp'].head(3).tolist()}")
# 8. 字段重命名
df = rename_columns(df)
# 保存处理结果
output_path = Path(file_path).with_suffix('.processed.csv')
df.to_csv(output_path, index=False)
print(f"\n✅ 处理完成!")
print(f"📁 输出文件: {output_path}")
print(f"📊 最终数据形状: {df.shape[0]} 行 × {df.shape[1]} 列")
print(f"📋 最终列名: {list(df.columns)}")
return df
def process_file(input_file, output_file=None):
"""
直接处理Excel文件的函数(不使用命令行参数)
Args:
input_file: 输入Excel文件路径(字符串或Path对象)
output_file: 输出CSV文件路径(可选,字符串或Path对象)
Returns:
pd.DataFrame: 处理后的DataFrame
"""
# 转换为Path对象
input_path = Path(input_file)
# 检查输入文件
if not input_path.exists():
raise FileNotFoundError(f"输入文件不存在: {input_path}")
if input_path.suffix.lower() not in ['.xlsx', '.xls']:
raise ValueError(f"输入文件必须是Excel格式 (.xlsx 或 .xls),当前文件: {input_path}")
# 处理文件
df = process_excel_file(str(input_path))
# 如果指定了输出路径,额外保存一份
if output_file:
output_path = Path(output_file)
df.to_csv(output_path, index=False)
print(f"📁 额外保存到: {output_path}")
return df
def interactive_input():
"""
交互式输入模式 - 手动输入参数
Returns:
tuple: (input_file, output_file) 文件路径元组
"""
print("=== 手动输入模式 ===")
print("请按照提示输入参数...\n")
# 输入文件路径
while True:
input_file = input("请输入Excel文件路径 (例如: data.xlsx): ").strip()
if not input_file:
print("❌ 文件路径不能为空,请重新输入")
continue
input_path = Path(input_file)
if not input_path.exists():
print(f"❌ 文件不存在: {input_path}")
print("提示: 请确保文件路径正确,或者将文件放在当前目录下")
continue
if input_path.suffix.lower() not in ['.xlsx', '.xls']:
print(f"❌ 文件格式错误: {input_path.suffix}")
print("只支持 .xlsx 和 .xls 格式的Excel文件")
continue
break
# 输出文件路径(可选)
output_file = input("请输入输出CSV文件路径 (可选,直接回车使用默认): ").strip()
if not output_file:
output_file = None
print("使用默认输出文件名")
print(f"\n✅ 输入确认:")
print(f" 输入文件: {input_file}")
print(f" 输出文件: {output_file or '自动生成'}")
confirm = input("\n确认开始处理? (y/N): ").strip().lower()
if confirm not in ['y', 'yes', '是', '确认']:
print("❌ 用户取消操作")
return None, None
return input_file, output_file
def main(input_file=None, output_file=None, interactive=False):
"""
主函数 - 支持多种输入方式
Args:
input_file: 输入文件路径(直接调用时使用)
output_file: 输出文件路径(直接调用时使用)
interactive: 是否启用交互式输入模式
"""
# 如果启用交互式输入
if interactive:
input_file, output_file = interactive_input()
if input_file is None:
return None
# 如果提供了直接参数,使用直接参数
if input_file is not None:
try:
return process_file(input_file, output_file)
except Exception as e:
print(f"❌ 处理失败: {e}")
raise
# 否则使用命令行参数
import argparse
parser = argparse.ArgumentParser(
description="无人机数据处理工具 - 将Excel数据转换为GasFlux格式",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用方式:
1. 命令行模式:
python data_processor.py data.xlsx
python data_processor.py data.xlsx -o output.csv
2. 交互式模式:
python data_processor.py --interactive
3. 直接调用:
from data_processor import process_file
df = process_file('data.xlsx', 'output.csv')
4. Python脚本调用:
from data_processor import main
df = main(input_file='data.xlsx', output_file='output.csv')
处理步骤:
1. 读取Excel文件
2. 删除不需要的列
3. 根据文件名修正时间格式
4. 经纬度坐标转换 (除以10^7)
5. 计算气压数据
6. 高度调整 (减去最小值)
7. 时间戳融合
8. 字段重命名为GasFlux格式
"""
)
parser.add_argument('input_file', nargs='?', help='输入的Excel文件路径')
parser.add_argument('-o', '--output', help='输出CSV文件路径(可选,默认自动生成)')
parser.add_argument('-i', '--interactive', action='store_true', help='启用交互式输入模式')
args = parser.parse_args()
# 如果启用交互式模式
if args.interactive:
input_file, output_file = interactive_input()
if input_file is None:
return None
else:
input_file = args.input_file
output_file = args.output
# 如果没有提供输入文件,显示帮助和使用示例
if not input_file:
parser.print_help()
print("\n" + "="*60)
print("📖 使用示例:")
print("="*60)
print("1. 命令行模式:")
print(" python data_processor.py your_file.xlsx")
print(" python data_processor.py data.xlsx -o output.csv")
print("")
print("2. Python脚本中直接调用:")
print(" from data_processor import process_file")
print(" df = process_file('input.xlsx')")
print(" df = process_file('input.xlsx', 'output.csv')")
print("")
print("3. 交互式模式:")
print(" python data_processor.py --interactive")
print("="*60)
return None
# 检查输入文件
input_path = Path(input_file)
if not input_path.exists():
print(f"❌ 错误:输入文件不存在: {input_path}")
sys.exit(1)
if input_path.suffix.lower() not in ['.xlsx', '.xls']:
print(f"❌ 错误:输入文件必须是Excel格式 (.xlsx 或 .xls)")
sys.exit(1)
# 处理文件
try:
df = process_excel_file(str(input_path))
# 如果指定了输出路径,额外保存一份
if output_file:
output_path = Path(output_file)
df.to_csv(output_path, index=False)
print(f"📁 额外保存到: {output_path}")
return df
except KeyboardInterrupt:
print("\n⚠️ 用户中断处理")
sys.exit(1)
except Exception as e:
print(f"\n❌ 处理失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
# 当直接运行脚本时,使用命令行参数模式
main()

58
src/gasflux/gas.py Normal file
View File

@ -0,0 +1,58 @@
"""functions related to gas transformations and calculations, e.g. density, point flux etc."""
import molmass
import pandas as pd
gas_variables = {
"standard_pressure": 1013.25, # hPa/mbar
"standard_temperature": 273.15, # degrees K
"standard_molar_volume": 0.022413969545014, # m3⋅mol-1
}
def mass(formula: str) -> float:
"""Return the molar mass of a gas in g/mol."""
return molmass.Formula(formula.upper()).mass # only accepts capital letters
def gas_density(local_pressure: float, local_temperature_celsius: float, gas: str) -> float: # millibars and celsius
"""
Calculate the density of a gas in kg/m3 based on local pressure and temperature.
Parameters:
- local_pressure: The local pressure in hPa/mbar.
- local_temperature: The local temperature in degrees Celsius.
- gas: The chemical formula of the gas.
Returns:
- The density of the gas in kg/m3.
Assumes ideal gas behavior.
"""
local_temperature_kelvin = local_temperature_celsius + gas_variables["standard_temperature"]
local_volume = (
gas_variables["standard_molar_volume"]
* (gas_variables["standard_pressure"] / local_pressure)
* ((local_temperature_kelvin + gas_variables["standard_temperature"]) / gas_variables["standard_temperature"])
) # m3⋅mol-1
return mass(gas) / 1000 / local_volume
def gas_flux_column(df: pd.DataFrame, gas: str, wind: str = "windspeed") -> pd.DataFrame:
"""
Add columns to the DataFrame for the gas density, mass, and flux.
Parameters:
- df: The DataFrame.
- gas: The chemical formula of the gas.
- wind: The column name for the wind speed (NB - must be perpendicular to the plane)
Returns:
- The DataFrame with the added columns.
"""
average_temp = df["temperature"].mean() # celsius
average_pressure = df["pressure"].mean() # hPa
gd = gas_density(local_pressure=average_pressure, local_temperature_celsius=average_temp, gas=gas) # kg/m3
df[f"{gas}_kg_m3"] = gd * (df[f"{gas}_normalised"] * 1e-6) # kg/m3
df[f"{gas}_kg_h_m2"] = df[f"{gas}_kg_m3"] * df[wind] * 60 * 60 # kg/h/m2
return df

View File

@ -0,0 +1,69 @@
# gasflux_config.yaml
output_dir: ~/gasflux_reports
# required columns and maximum ranges
required_cols:
latitude: [-90, 90]
longitude: [-180, 180]
height_ato: [-200, 500] # meters above takeoff
windspeed: [0, 50] # m/s
winddir: [0, 360] # degrees
temperature: [-50, 60] # degrees Celsius
pressure: [900, 1100] # hPa/mb
# optional gas columns and maximum ppm ranges. Relative concentrations are used so offset can be wrong as long as gain and linearity are correct.
gases:
ch4: [1.5, 500]
co2: [300, 5000]
c2h6: [-0.5, 10]
strategies:
background: "algorithm" # currently only algorithm (via pybaselines) is supported
sensor: "insitu" # currently only insitu is supported
spatial: "curtain" # currently "curtain" and "spiral" are supported
interpolation: "kriging" # currently only kriging is supported
# baseline settings.
algorithmic_baseline_settings:
algorithm: fastchrom
fastchrom: {
"half_window": 6,
"threshold": "custom", #
"min_fwhm": ~,
"interp_half_window": 3,
"smooth_half_window": 3,
"weights": ~,
"max_iter": 100,
"min_length": 2}
fabc : {
"lam": 10000, # The smoothing parameter. Larger values will create smoother baselines. Default is 1e6.
"scale": 10, # The scale at which to calculate the continuous wavelet transform. Should be approximately equal to the index-based full-width-at-half-maximum of the peaks or features in the data. Default is None, which will use half of the value from optimize_window(), which is not always a good value, but at least scales with the number of data points and gives a starting point for tuning the parameter.
"diff_order": 2} # The order of the differential matrix. Must be greater than 0. Default is 2 (second order differential matrix). Typical values are 2 or 1.
dietrich : {
"poly_order": 5,
"smooth_half_window": 5,}
golotvin : {
"half_window": 2,
"sections": 10}
# kriging settings
semivariogram_settings:
model: spherical
estimator: cressie
n_lags: 20
bin_func: even
fit_method: lm
### Size of the search window; if the algorithm complains about not having enough neighbours, consider increasing these
maxlag: 100 # in meters.
tolerance: 10 # in degrees
azimuth: 0 # in degrees (0 is right/horizontal)
bandwidth: 20 # in meters
# fit_sigma: linear # this should allow for a spatial uncertainty but currently producing bugs
ordinary_kriging_settings:
min_points: 3
max_points: 100
grid_resolution: 500
min_nodes: 10
y_min: ~ # manual override for the minimum y value. Leave blank or ~ to use ymin.
cut_ground: True # cuts everything below the ground level in the krig (need height_agl to work at the moment)

View File

@ -0,0 +1,171 @@
"""Functions related to kriging and other kinds of interpolation"""
import numpy as np
import pandas as pd
import skgstat as skg
from scipy import integrate
from . import plotting
def simpsonintegrate(array: np.ndarray, x_cell_size: float, y_cell_size: float) -> float:
"""Function to obtain the volume of the krig in kgh⁻¹, i.e. the cut-fill volume
(negative volumes from background noise are subtracted)."""
grid = np.nan_to_num(array.copy(), copy=False, nan=0)
vol_rows = integrate.simpson(np.transpose(grid)) # this integrates along each row of the grid
vol_grid = integrate.simpson(vol_rows) # this integrates the rows together
return vol_grid * x_cell_size * y_cell_size # type: ignore
def directional_gas_semivariogram(
df: pd.DataFrame, x: str, z: str, gas: str, semivariogram_filter: float | None = None, **semivariogram_settings
):
"""Function to calculate the directional semivariogram - typically horizontally - of a gas in a dataframe."""
if semivariogram_filter:
df = df[df[gas] > semivariogram_filter]
v = skg.DirectionalVariogram(
df[[x, z]].to_numpy(),
df[gas].to_numpy(),
**semivariogram_settings,
)
return v
def ordinary_kriging(
df: pd.DataFrame,
x: str,
y: str,
gas: str,
ordinary_kriging_settings: dict,
semivariogram_filter: float | None = None,
**semivariogram_settings,
):
"""Function to calculate the ordinary kriging of a gas in a dataframe, after calculating a semivariogram."""
gasflux = f"{gas}_kg_h_m2"
skg.plotting.backend("plotly") # type: ignore
cut_ground = ordinary_kriging_settings["cut_ground"]
semivariogram = directional_gas_semivariogram(df, x, y, gasflux, semivariogram_filter, **semivariogram_settings)
ok = skg.OrdinaryKriging(
semivariogram,
coordinates=df[[x, y]].to_numpy(),
values=df[gasflux].to_numpy(),
min_points=ordinary_kriging_settings["min_points"],
max_points=ordinary_kriging_settings["max_points"],
)
x_max = df[x].max()
x_min = df[x].min()
y_max = df[y].max()
y_min = df[y].min() if ordinary_kriging_settings["y_min"] is None else ordinary_kriging_settings["y_min"]
if cut_ground is True:
df["ground_elevation_ato"] = df.loc[:, "height_ato"] - df.loc[:, "height_agl"]
y_min = min(df["ground_elevation_ato"].min(), y_min)
x_range, y_range = x_max - x_min, y_max - y_min
cell_rough_size = np.sqrt((x_range * y_range) / ordinary_kriging_settings["grid_resolution"])
x_nodes, y_nodes = (
max(int(r / cell_rough_size), ordinary_kriging_settings["min_nodes"]) for r in [x_range, y_range]
)
x_cell_size = (x_max - x_min) / x_nodes
y_cell_size = (y_max - y_min) / y_nodes
xx, yy = np.mgrid[
x_min : x_max : x_nodes * 1j,
y_min : y_max : y_nodes * 1j, # type: ignore
] # type: ignore
field = ok.transform(xx.flatten(), yy.flatten()).reshape(xx.shape)
if cut_ground:
field = remove_values_below_ground(df, field, xx, yy)
volume = simpsonintegrate(field, x_cell_size, y_cell_size)
fieldpos = np.copy(field)
fieldpos[fieldpos < 0] = 0
volumepos = simpsonintegrate(fieldpos, x_cell_size, y_cell_size)
fieldneg = np.copy(field)
fieldneg[fieldneg > 0] = 0
volumeneg = simpsonintegrate(fieldneg, x_cell_size, y_cell_size)
error_1s = ok.sigma.reshape(xx.shape)
# np.nan_to_num(error_1s, copy=False, nan=0)
volume_error = simpsonintegrate(error_1s, x_cell_size, y_cell_size)
contour_plot = plotting.contour_krig(df=df, gas=gas, xx=xx, yy=yy, field=field, x=x, y=y, cut_ground=cut_ground)
grid_plot = plotting.heatmap_krig(xx, yy, field)
output_text = (
f"The emissions flux of {gas.upper()} is {volume:.3f}kgh⁻¹; "
f"the cut and fill volumes of the grid are {volumepos:.3f} and {volumeneg:.3f}kgh⁻¹. "
f"The grid itself is {x_nodes}x{y_nodes} nodes, with nodes measuring {x_cell_size:.2f}m x {y_cell_size:.2f}m."
)
krig_variables = {
"gas": gas,
"field": field,
"fieldpos": fieldpos,
"fieldneg": fieldneg,
"xx": xx,
"yy": yy,
"volume": volume,
"volumepos": volumepos,
"volumeneg": volumeneg,
"error field (1 sigma)": error_1s,
"volume_error": volume_error,
}
semivariogram_plot = semivariogram.plot(show=False)
return krig_variables, output_text, contour_plot, grid_plot, semivariogram_plot
def remove_values_below_ground(
df: pd.DataFrame, field: np.ndarray, xx: np.ndarray, yy: np.ndarray, x: str = "x", alt: str = "height_ato"
) -> np.ndarray:
"""
Adjust field values based on elevation data, setting values below ground to NaN.
"""
max_x = df[x].max()
max_y = df[alt].max()
x_right = np.empty_like(xx)
x_right[:-1, :] = xx[1:, :]
x_right[-1, :] = max_x
x_points = (xx + x_right) / 2
y_top = np.empty_like(yy)
y_top[:, :-1] = yy[:, 1:]
y_top[:, -1] = max_y
y_points = (yy + y_top) / 2
x_points_flat = x_points.ravel()
y_points_flat = y_points.ravel()
ground_levels = compute_relative_ground_levels(df, x_points_flat)
below_ground = y_points_flat < ground_levels
field_flat = field.ravel()
field_flat[below_ground] = np.nan
return field_flat.reshape(field.shape)
def compute_relative_ground_levels(
df: pd.DataFrame, x_points: np.ndarray, y1: str = "height_agl", y2: str = "height_ato"
) -> np.ndarray:
"""
Calculate ground levels at given x coordinates, considering elevation above ground and takeoff altitude. A
sort of janky averaged DEM, basically. Will accept either ground elevation and altitude, or height above ground
level and height above takeoff as inputs for y1 and y2.
"""
df_clean = df.dropna(subset=[y1, y2])
df_sorted = df_clean.sort_values(by="x").drop_duplicates(subset="x")
y1_at_x = np.interp(x_points, df_sorted["x"], df_sorted[y1])
y2_at_x = np.interp(x_points, df_sorted["x"], df_sorted[y2])
return y2_at_x - y1_at_x
# def additive_row_integration(df: pd.DataFrame, rowlabel: str = "slice"):
# """2D integration of a dataframe along the x-axis, with the altitude as the y-axis."""
# integrals = {}
# for i in range(df[rowlabel].max() + 1):
# df_slice = df[df[rowlabel] == i]
# df_slice = df_slice.sort_values(by="x")
# line_integral = integrate.simpson(y=df_slice["ch4_kg_h_m2"], x=df_slice["x"])
# area_integral = line_integral * (df_slice["altitude"].max() - df_slice["altitude"].min())
# integrals[i] = area_integral
# return integrals

61
src/gasflux/ml.py Normal file
View File

@ -0,0 +1,61 @@
"""Experimental module for machine learning flight filtering"""
import os
import joblib
import pandas as pd
from . import plotting
import plotly.graph_objects as go
model = None # Lazy loading: Load the model only if it hasn't been loaded yet
def load_model():
"""Load the model from the model file path. If the model has already been loaded, return it."""
global model
if model is None:
default_model_path = os.path.join(os.path.dirname(__file__), "resources/model.pkl")
model_file_path = os.getenv("GASFLUX_MODEL_PATH", default_model_path)
try:
model = joblib.load(model_file_path)
except FileNotFoundError as e:
raise FileNotFoundError(f"Model file not found at {model_file_path}. Please check the file path.") from e
except Exception as e:
raise Exception("An error occurred while loading the model.") from e
return model
def make_prediction(
df: pd.DataFrame,
course_elevation="course_elevation",
height_ato="height_ato",
horiz_spd="horiz_spd",
z_spd="z_spd",
) -> tuple[pd.DataFrame, go.Figure]:
"""Make predictions based on the input DataFrame and add them to the DataFrame.
:param df: DataFrame containing the required features
:param course_azimuth: Name of the column containing the course azimuth
:param course_elevation: Name of the column containing the course elevation
:param height_ato: Name of the column containing the height above take-off
:param idx: Name of the column containing the index
return: Tuple of the DataFrame with the predictions and a Plotly 3D scatter plot of the predictions
"""
model = load_model()
# Ensure the DataFrame contains all the required features
required_features = [course_elevation, height_ato, horiz_spd, z_spd]
if not all(feature in df.columns for feature in required_features):
missing_features = [feature for feature in required_features if feature not in df.columns]
raise ValueError(f"DataFrame is missing (or mislabelled) the following required features: {missing_features}")
# make idx col if not present
if "idx" not in df.columns:
df["idx"] = df.index
cols_for_model = ["course_elevation", "height_ato", "idx", "horiz_spd", "z_spd"]
predictions = model.predict(df[cols_for_model])
df["predictions"] = predictions
fig = plotting.scatter_3d(df)
return df, fig

609
src/gasflux/plotting.py Normal file
View File

@ -0,0 +1,609 @@
"""Various plotting functions mainly based around plotly."""
import matplotlib.colors as mcolors
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import plotly.io as pio
import simplekml
from plotly.subplots import make_subplots
from . import processing
pio.templates["default"] = go.layout.Template(
layout=go.Layout(
margin=go.layout.Margin(l=0, r=0, b=0, t=0, pad=0),
),
)
pio.templates.default = "simple_white+default"
styling = {
"colorscale": "geyser",
}
def blank_figure():
fig = go.Figure()
return fig
def scatter_3d(
df: pd.DataFrame,
color: str = "",
colorbar_title: str = "",
timestamp: str = "timestamp",
x: str = "utm_easting",
y: str = "utm_northing",
z: str = "height_ato",
courses: bool = False,
):
fig = px.scatter_3d(df, x=x, y=y, z=z)
if color:
custom_data = [df[timestamp]]
if courses:
custom_data.extend([df["course_elevation"], df["course_azimuth"]])
custom_data = np.stack(custom_data, axis=-1)
hover_template = [
f"{x}: %{{x:.2f}}",
f"{y}: %{{y:.2f}}",
f"{z}: %{{z:.2f}}",
f"{color}: %{{marker.color:.2f}}",
f"{timestamp}: %{{customdata[0]|%Y-%m-%d %H:%M:%S}}",
"Index: %{pointNumber}",
]
if courses:
hover_template.extend(
[
"Course Elevation: %{customdata[1]:.2f}",
"Course Azimuth: %{customdata[2]:.2f}",
]
)
hover_template_str = "<br>".join(hover_template)
fig.update_traces(
marker=dict(
color=df[color],
size=4,
opacity=0.5,
colorscale=styling["colorscale"],
colorbar=dict(title=colorbar_title),
),
customdata=custom_data,
hovertemplate=hover_template_str,
)
return fig
def scatter_2d(
df: pd.DataFrame,
x: str,
color: str,
y: str = "height_ato",
**kwargs,
):
fig = px.scatter(
df,
x=x,
y=y,
color=color,
color_continuous_scale=styling["colorscale"],
opacity=0.8,
**kwargs,
)
fig.update_traces(
customdata=df.index,
hovertemplate="<br>".join(
[
"x: %{x:.2f}",
"height_ato: %{y:.2f}",
f"{color}: %{{marker.color:.2f}}",
"Time: %{customdata}",
],
),
)
return fig
def time_series(
df: pd.DataFrame,
ys: str | list[str],
x: str = "timestamp",
color: str | None = None,
split=None,
y_mins: float | list[float | int] | None = None,
rolling_average: bool = True,
scatter: bool = True,
rolling_window: int = 5,
y_titles: str | list[str] | None = None,
legend: bool = True,
) -> go.Figure:
colors = px.colors.qualitative.Plotly
if isinstance(ys, str):
ys = [ys]
if y_titles is None:
y_titles = ys
single_title = False
elif isinstance(y_titles, str):
y_titles = [y_titles]
single_title = True
elif isinstance(y_titles, list):
if len(y_titles) != len(ys):
raise ValueError("Length of y_titles must be equal to length of ys")
single_title = False
else:
raise ValueError("Invalid y_titles value")
if isinstance(y_mins, (float | int)):
y_mins = [y_mins]
if isinstance(y_mins, list):
if len(y_mins) != len(ys):
raise ValueError("Length of y_mins must be equal to length of ys")
fig = go.Figure()
axis_space = 0.05
domain_start = axis_space * (len(ys)) if len(ys) > 1 else 0
fig.update_layout(
xaxis=dict(
domain=[domain_start, 1],
),
)
for i, y in enumerate(ys):
yaxis_name = f"yaxis{i+1}"
yaxis_ref = f"y{i+1}"
trace_color = "black" if single_title and i == 0 else colors[i % len(colors)]
marker_i = dict(size=8, opacity=0.3 if rolling_average else 0.5, color=trace_color)
if color is not None:
marker_i["color"] = df[color] # type: ignore
marker_i["colorscale"] = styling["colorscale"]
hover_template = f"{x}: %{{x}}<br>{y}: %{{y:.2f}}<br>"
if color:
hover_template += f"{color}: %{{marker.color:.2f}}<br>"
if scatter:
fig.add_trace(
go.Scatter(
x=df[x],
y=df[y],
name=y,
mode="markers",
marker=marker_i,
yaxis=yaxis_ref,
hovertemplate=hover_template,
showlegend=legend,
)
)
if rolling_average:
df[f"rolling_avg_{i}"] = df[y].rolling(window=rolling_window, min_periods=1).mean()
fig.add_trace(
go.Scatter(
x=df[x],
y=df[f"rolling_avg_{i}"],
name=f"{y} {rolling_window}-point avg",
mode="lines",
line=dict(color=trace_color, width=2),
yaxis=yaxis_ref,
showlegend=legend,
)
)
y_data = df[y]
y_min_var = y_data.min()
y_max_var = y_data.max()
y_range = y_max_var - y_min_var or y_max_var * 0.05
y_axis_min = y_mins[i] if y_mins is not None and y_mins[i] is not None else y_min_var - y_range * 0.05
y_axis_max = y_max_var + y_range * 0.05
if single_title and i == 0:
axis_title = dict(text=y_titles[0], font=dict(color="black"))
elif not single_title:
axis_title = dict(text=y_titles[i], font=dict(color=trace_color))
else:
axis_title = None
axis_config = dict(
title=axis_title,
tickfont=dict(color=trace_color),
range=[y_axis_min, y_axis_max],
side="left",
position=axis_space * i if i > 0 else None,
anchor="free" if i > 0 else None,
overlaying="y" if i > 0 else None,
showgrid=(i == 0),
)
fig.layout[yaxis_name] = axis_config
if split is not None:
fig.add_shape(
type="line",
xref="x",
yref="paper",
x0=split,
y0=0,
x1=split,
y1=1,
line=dict(color="red", width=2),
)
return fig
def background_plotting(df: pd.DataFrame, gas: str):
fig = make_subplots(specs=[[{"secondary_y": True}]])
ymin = df[gas].min()
ymax = df[gas].max()
ylim = [ymin * 0.95, ymax * 1.05]
y2min = df[f"{gas}_normalised"].min()
y2lim = (y2min, y2min + (ylim[1] - ylim[0]))
fig.update_yaxes(range=ylim, secondary_y=False, title_text=f"Sensor {gas} (ppm)")
fig.update_yaxes(range=y2lim, secondary_y=True, title_text=f"Normalised {gas} (ppm)")
fig.add_scatter(x=df["timestamp"], y=df[gas], opacity=0.3, name="Raw Data")
fig.add_scatter(
x=df["timestamp"], y=df[f"{gas}_fit"], mode="lines", name="Fitted Background", line=dict(dash="dash")
)
fig.add_scatter(
x=df["timestamp"], y=df[f"{gas}_normalised"], yaxis="y2", name="Normalised Data", mode="lines", opacity=0.5
)
fig.add_scatter(
x=df["timestamp"],
y=np.where(df[f"{gas}_signal"], df[f"{gas}_normalised"], np.nan),
yaxis="y2",
name="Classed as signal",
mode="lines",
opacity=0.5,
# color
# mode="markers",
# marker=dict(size=3),
)
return fig
def windrose_process(df: pd.DataFrame):
beaufort = {
"0": [0, 1],
"1": [1, 2],
"2": [2, 4],
"3": [4, 6],
"4": [6, 9],
"5": [9, 11],
"6": [11, 14],
"7": [14, 17],
"8": [17, 21],
"9": [21, 25],
"10": [25, 29],
"11": [29, 33],
"12": [33, 200],
}
beaufort_ms = {
"0": "0-1",
"1": "1-2",
"2": "2-4",
"3": "4-6",
"4": "6-9",
"5": "9-11",
"6": "11-14",
"7": "14-17",
"8": "17-21",
"9": "21-25",
"10": "25-29",
"11": "29-33",
"12": "33+",
}
cardinals = {
"N1": [0, 11.25],
"NNE": [11.25, 33.75],
"NE": [33.75, 56.25],
"ENE": [56.25, 78.75],
"E": [78.75, 101.25],
"ESE": [101.25, 123.75],
"SE": [123.75, 146.25],
"SSE": [146.25, 168.75],
"S": [168.75, 191.25],
"SSW": [191.25, 213.75],
"SW": [213.75, 236.25],
"WSW": [236.25, 258.75],
"W": [258.75, 281.25],
"WNW": [281.25, 303.75],
"NW": [303.75, 326.25],
"NNW": [326.25, 348.75],
"N2": [348.75, 360],
}
df["wind_direction_bin"] = pd.cut(
df["winddir"],
bins=[lower for lower, upper in cardinals.values()] + [list(cardinals.values())[-1][1]],
labels=[key for key in cardinals],
right=False,
)
df["wind_direction_bin"] = (
df["wind_direction_bin"].map(lambda x: "N" if x in ["N1", "N2"] else x).astype("category")
)
df["beaufort"] = pd.cut(
df["windspeed"],
bins=[lower for lower, upper in beaufort.values()] + [list(beaufort.values())[-1][1]],
labels=[key for key in beaufort],
right=False,
)
df["beaufort_ms"] = df["beaufort"].map(beaufort_ms)
df_windrose = df.groupby(["wind_direction_bin", "beaufort"], observed=False).size().reset_index(name="count") # type: ignore
df_windrose["frequency"] = df_windrose["count"] / df_windrose["count"].sum() * 100
df_windrose["wind_direction_bin_degs"] = df_windrose["wind_direction_bin"].cat.rename_categories(
{
"N": 0,
"NNE": 22.5,
"NE": 45,
"ENE": 67.5,
"E": 90,
"ESE": 112.5,
"SE": 135,
"SSE": 157.5,
"S": 180,
"SSW": 202.5,
"SW": 225,
"WSW": 247.5,
"W": 270,
"WNW": 292.5,
"NW": 315,
"NNW": 337.5,
},
)
df_windrose["beaufort"] = df_windrose["beaufort"].astype(int)
return df_windrose
def windrose_graph(df, plot_transect=False, theta1=None, theta2=None):
n_colors = 13
colors = px.colors.sample_colorscale("turbo", [n / (n_colors - 1) for n in range(n_colors)])
fig = px.bar_polar(
df,
r="frequency",
theta="wind_direction_bin_degs",
color="beaufort",
labels={
"frequency": "Frequency (%)",
"wind_direction_bin": "Direction",
"beaufort": "Beaufort Scale",
},
color_discrete_map=colors,
)
fig.update_layout(polar=dict(radialaxis={"visible": False, "showticklabels": False}))
fig.update_layout(
polar=dict(
angularaxis={
"showgrid": False,
},
),
)
fig.update_layout(polar_bargap=0)
if plot_transect:
max_freq = df.groupby("wind_direction_bin", observed=False)["frequency"].sum().max()
fig.add_trace(
go.Scatterpolar(
r=[max_freq, max_freq],
theta=[theta1, theta2],
mode="lines",
line=dict(color="black", width=2, dash="dash"),
showlegend=False,
),
)
return fig
def windrose(df: pd.DataFrame, plot_transect=False):
df_windrose = windrose_process(df)
if plot_transect:
theta1, theta2 = processing.bimodal_azimuth(df)
fig = windrose_graph(df_windrose, plot_transect=plot_transect, theta1=theta1, theta2=theta2)
else:
fig = windrose_graph(df_windrose, plot_transect=plot_transect)
return fig
def outliers(original_data: pd.Series, fence_high: float, fence_low: float):
outliers = np.array(original_data > fence_high) | (original_data < fence_low)
fig = make_subplots(rows=1, cols=2, shared_yaxes=True)
fig.add_trace(px.strip(original_data, color=outliers).data[0], row=1, col=1)
if sum(outliers) > 0:
fig.add_trace(px.strip(original_data, color=outliers).data[1], row=1, col=1)
fig.add_shape(
go.layout.Shape(
type="line",
x0=-0.5,
y0=fence_high,
x1=0.5,
y1=fence_high,
line=dict(color="red", width=2),
),
row=1,
col=1,
)
fig.add_shape(
go.layout.Shape(
type="line",
x0=-0.5,
y0=fence_low,
x1=0.5,
y1=fence_low,
line=dict(color="red", width=2),
),
row=1,
col=1,
)
fig.update_traces(offsetgroup=0)
fig.add_trace(px.scatter(original_data, color=outliers).data[0], row=1, col=2)
if sum(outliers) > 0:
fig.add_trace(px.scatter(original_data, color=outliers).data[1], row=1, col=2)
fig.update_layout(showlegend=False, yaxis_title="Windspeed (ms⁻¹)")
return fig
def contour_krig(
df: pd.DataFrame,
gas: str,
# array of float 64
xx: np.ndarray,
yy: np.ndarray,
field: np.ndarray,
cut_ground: bool = False,
x: str = "x",
y: str = "height_ato",
) -> go.Figure:
if np.isnan(field).all():
return blank_figure()
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=df[x],
y=df[y],
mode="markers",
marker={
"color": df[f"{gas}_normalised"],
"colorscale": styling["colorscale"],
"showscale": True,
"colorbar": {
"title": f"{gas} (ppm)",
},
},
showlegend=False,
)
)
fig.add_trace(
go.Contour(
z=field.T,
x=xx[:, 0],
y=yy[0, :],
contours={
"start": field.min(),
"end": field.max(),
"size": (field[~np.isnan(field)].max() - field[~np.isnan(field)].min()) / 21,
},
colorscale=styling["colorscale"],
opacity=0.5,
showlegend=False,
showscale=False,
)
)
fig.update_xaxes(
showline=True,
linewidth=1,
linecolor="black",
title_text="horizontal distance on projected flux plane (m)",
range=[np.min(xx), np.max(xx)],
ticks="outside",
tickwidth=1,
tickcolor="black",
ticklen=5,
nticks=20,
)
fig.update_yaxes(
showline=True,
linewidth=1,
linecolor="black",
title_text="height above takeoff (m)",
range=[np.min(yy), np.max(yy)],
ticks="outside",
tickwidth=1,
tickcolor="black",
ticklen=5,
nticks=10,
)
if cut_ground:
resolution = 200 # how many points to interpolate over
df["ground_elevation_ato"] = df.loc[:, "height_ato"] - df.loc[:, "height_agl"]
df_sorted = df.dropna(subset=[x, "ground_elevation_ato"]).sort_values(x)
x_min, x_max = df_sorted[x].min(), df_sorted[x].max()
x_interp = np.linspace(x_min, x_max, resolution)
ground_ato_interp = np.interp(x_interp, df_sorted[x], df_sorted["ground_elevation_ato"])
fig.add_trace(
go.Scatter(
x=x_interp,
y=ground_ato_interp,
mode="lines",
line=dict(color="black", width=2, dash="dash"),
name="Interpolated Ground Level",
)
)
fig.layout.coloraxis.colorbar.title = "Emissions flux (kg⋅m⁻²⋅h⁻¹)"
return fig
def heatmap_krig(xx: np.ndarray, yy: np.ndarray, field: np.ndarray):
fig = px.imshow(field.T, x=xx[:, 0], y=yy[0, :], color_continuous_scale=styling["colorscale"], origin="lower")
fig.layout.coloraxis.colorbar.title = "Emissions flux (kg⋅m⁻²⋅h⁻¹)"
fig.update_xaxes(
showline=True,
linewidth=1,
linecolor="black",
title_text="horizontal distance on cylindrical projected flux plane (m)",
range=[xx.min(), xx.max()],
ticks="outside",
tickwidth=1,
tickcolor="black",
ticklen=5,
nticks=20,
)
fig.update_yaxes(
showline=True,
linewidth=1,
linecolor="black",
title_text="height above ground level (m)",
range=[yy.min(), yy.max()],
ticks="outside",
tickwidth=1,
tickcolor="black",
ticklen=5,
nticks=10,
)
fig.update_layout(coloraxis_colorbar=dict(len=0.25))
return fig
def create_kml_file(data: pd.DataFrame, output_file: str, column: str, altitudemode: str):
kml = simplekml.Kml()
min_value = data[column].min()
max_value = data[column].max()
custom_colors = [
"#008080",
"#70a494",
"#b4c8a8",
"#f6edbd",
"#edbb8a",
"#de8a5a",
"#ca562c",
] # based on plotly geyser
cmap = mcolors.LinearSegmentedColormap.from_list("custom_cmap", custom_colors)
for _index, row in data.iterrows():
col_normalized = (row[column] - min) / (max_value - min_value)
color = mcolors.rgb2hex(cmap(col_normalized))
pnt = kml.newpoint(coords=[(row["longitude"], row["latitude"], row["height_ato"])], altitudemode=altitudemode)
pnt.iconstyle.icon.href = "http://maps.google.com/mapfiles/kml/shapes/placemark_circle.png"
pnt.iconstyle.color = simplekml.Color.rgb(int(color[1:3], 16), int(color[3:5], 16), int(color[5:], 16))
pnt.iconstyle.scale = 0.6
pnt.description = f"Concentration: {row[column]} ppm"
kml.save(output_file)

View File

@ -0,0 +1,109 @@
"""Functions that organise the data into standard columns in pandas dataframes. Conversion functions (e.g. WGS84 to UTM)
are here but transformations take place in processing.py"""
import geopandas as gpd
import numpy as np
import pandas as pd
from . import plotting
from .processing import circ_median
def data_tests(df: pd.DataFrame):
assert df["ch4"].min() > 1.6, "ch4 values are too low"
assert df.index.is_monotonic_increasing, "data is not sorted by time"
assert df.index.is_unique, "data has duplicate timestamps"
assert df["ch4"].isna().sum() == 0, "ch4 has missing values"
assert df["windspeed"].min() >= 0, "windspeed values are negative"
assert df["windspeed"].max() < 20, "windspeed values are too high"
if df["windspeed"].max() > 15:
print("Warning: windspeed is greater than 15 m/s, perhaps due to errors in the data.")
# make timestamp column from UTCs, Month, Day, Year
def timestamp_from_four_columns(df):
df["Year"] = df["Year"] + 2000
df["time"] = pd.to_datetime(df["UTCs"], unit="s")
df["date"] = pd.to_datetime(df[["Year", "Month", "Day"]])
df["timestamp"] = pd.to_datetime(df["date"].dt.date.astype(str) + " " + df["time"].dt.time.astype(str))
df.index = df["timestamp"]
df.drop(
["Year", "Month", "Day", "time", "date", "timestamp", "UTCs"],
axis=1,
inplace=True,
)
return df
# add UTM from latitudes and longitudes
def add_utm(df: pd.DataFrame) -> pd.DataFrame:
gdf = gpd.GeoDataFrame( # type: ignore
df,
geometry=gpd.points_from_xy(df["longitude"], df["latitude"], crs="EPSG:4326"),
)
utm = gdf.estimate_utm_crs()
gdf = gdf.to_crs(utm)
if not isinstance(gdf, gpd.GeoDataFrame):
raise TypeError("Failed to reproject to a GeoDataFrame")
gdf["utm_easting"] = gdf.geometry.x
gdf["utm_northing"] = gdf.geometry.y
output_df = pd.DataFrame(gdf.drop(columns="geometry"))
return output_df
# add columns for drone course azimuth and elevation
def add_course(df, rolling_window=1):
df["hor_distance"] = np.sqrt((df["utm_northing"].diff()) ** 2 + (df["utm_easting"].diff()) ** 2)
df["vert_distance"] = df["height_ato"].diff()
df["vert_distance"] = pd.to_numeric(df["vert_distance"], errors="coerce")
df["hor_distance"] = pd.to_numeric(df["hor_distance"], errors="coerce")
df["course_azimuth"] = (
(np.degrees(np.arctan2(df["utm_easting"].diff(), df["utm_northing"].diff())) % 360)
.rolling(rolling_window)
.apply(lambda x: circ_median(x), raw=True)
)
df["course_elevation"] = (
np.degrees(np.arctan2(df["vert_distance"], df["hor_distance"]))
.rolling(rolling_window)
.apply(lambda x: circ_median(x), raw=True)
)
return df
def manual_filtering(dict_dfs: dict, split_times: dict, mask_spans: dict) -> dict:
filtered_dfs = {}
for name, df in dict_dfs.items():
if name in mask_spans:
for i in range(len(mask_spans[name])):
df = df.drop(
df.between_time(mask_spans[name][i].split(" - ")[0], mask_spans[name][i].split(" - ")[1]).index,
).copy()
filtered_dfs[name] = df.copy()
if name in split_times:
split_times[name].append("23:59:59")
split_times[name].insert(0, "00:00:00")
for i in range(len(split_times[name]) - 1):
df2 = df.between_time(split_times[name][i], split_times[name][i + 1]).copy()
filtered_dfs[name + "_" + str(i)] = df2.copy()
elif name not in split_times:
filtered_dfs[name] = df.copy()
return filtered_dfs
def remove_outliers(df: pd.DataFrame, column: str, name: str):
q1 = df[column].quantile(0.25)
q3 = df[column].quantile(0.75)
iqr = q3 - q1
fence_low = q1 - 3 * iqr
fence_high = q3 + 3 * iqr
fig = plotting.outliers(df[column], fence_high, fence_low)
outliers = df.loc[(df[column] < fence_low) | (df[column] > fence_high)]
if len(outliers) > 0:
print(f"{len(outliers)} outliers removed from {name} {column} data")
# nan for outliers, not row removal
df.loc[(df[column] < fence_low) | (df[column] > fence_high), column] = float("nan")
elif len(outliers) == 0:
print(f"No outliers found in {name} {column} data")
return df, fig

650
src/gasflux/processing.py Normal file
View File

@ -0,0 +1,650 @@
"""Processing function, usually implying some kind of filtering or data transformation."""
from matplotlib.figure import Figure
from itertools import groupby
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy import odr
from scipy.optimize import least_squares
from scipy.signal import find_peaks
from scipy.stats import circmean
import warnings
import logging
def circ_median(x: np.ndarray | pd.Series) -> float:
"""
Finds the largest "empty" arc in the circle, splits it there and returns the median of the straight line.
Probably there are more elegant ways... see https://github.com/scipy/scipy/issues/6644
Parameters:
x (np.ndarray | pd.Series): The input array of angles.
Returns:
float: The circular median of the input angles.
"""
angles = sorted(x)
n = len(angles)
if n == 1:
return angles[0]
gaps = [(angles[(i + 1) % n] - angles[i]) % 360 for i in range(n)]
largest_gap_index = gaps.index(max(gaps))
rearranged_angles = angles[largest_gap_index + 1 :] + angles[: largest_gap_index + 1]
if n % 2 == 1:
return rearranged_angles[n // 2]
else:
return (rearranged_angles[n // 2 - 1] + rearranged_angles[n // 2]) % 360 / 2
def min_angular_displacement(x: float | np.ndarray, y: float | np.ndarray) -> float | np.ndarray:
"""
Calculates the minimum circular difference between two angles (in 360 degree space)
"""
return np.minimum(np.abs(x - y) % 360, (360 - np.abs(x - y)) % 360)
def wind_offset_correction(df: pd.DataFrame, plane_angle: float) -> pd.DataFrame:
"""
Corrects wind direction data for a given plane angle, assuming the plane is the primary orientation of the dataset.
This is useful for aligning wind data with the plane's orientation, facilitating analysis of wind effects.
Parameters:
df (pd.DataFrame): The input dataframe containing wind direction data.
wind_dir_col (str): Column name for wind direction data.
plane_angle (float): Angle of the plane in degrees.
Returns:
pd.DataFrame: The modified dataframe with corrected wind direction data.
"""
df = df.copy()
df["winddir_rel"] = df.apply(lambda row: abs(90 - min_angular_displacement(row["winddir"], plane_angle)), axis=1) # type: ignore
df["windspeed_measured"] = df["windspeed"]
df["windspeed"] = df["windspeed"] * np.cos(np.radians(df["winddir_rel"]))
return df
def bimodal_azimuth(
df: pd.DataFrame, course_col: str = "course_azimuth", min_height: int = 5, min_diff: int = 160
) -> tuple[float, float]:
"""
Identifies the two most frequent course azimuths in the dataset, ensuring they are sufficiently
distinct. Filters data by height and removes NaNs before analysis.
Parameters:
df (pd.DataFrame): The input dataframe.
course_col (str): Column name for course azimuth data. Default is "course_azimuth".
min_altitude (int): Minimum height (_ato typically) for data to be included. Default is 5.
min_diff (int): Minimum difference between the two modes. Default is 160.
Returns:
tuple: Two modes of the course azimuth.
"""
df = df[df["height_ato"] >= min_height]
data = df[course_col].dropna().to_numpy()
hist, edges = np.histogram(data, bins=50)
edgedist = edges[1] - edges[0]
bin_centers = edges[:-1] + edgedist / 2
max_freq_idx = np.argsort(hist)[-2:] # top 2 frequencies
mode1, mode2 = bin_centers[max_freq_idx]
while min_angular_displacement(mode1, mode2) < min_diff:
if hist[max_freq_idx[0]] < hist[max_freq_idx[1]]:
hist[max_freq_idx[0]] = 0
max_freq_idx = np.argsort(hist)[-2:]
else:
hist[max_freq_idx[1]] = 0
max_freq_idx = np.argsort(hist)[-2:]
mode1, mode2 = bin_centers[max_freq_idx]
if min_angular_displacement(mode1, mode2) < 160:
warnings.warn(
f"Two modes are close together - this probably should never happen: {mode1:.2f} and {mode2:.2f}",
UserWarning,
stacklevel=2,
)
return (mode1, mode2)
# this returns modes of slope from -90 to 90 degrees.
def bimodal_elevation(
df: pd.DataFrame, course_col: str = "course_elevation", min_height: float = 5, max_slope: float = 70
) -> tuple[float, float]:
"""
Identifies the most frequent course elevation in the dataset, adjusted for vertical movements.
Filters data by height and removes NaNs before analysis.
Parameters:
df (pd.DataFrame): The input dataframe.
course_col (str): Column name for course elevation data. Default is "course_elevation".
min_height (int): Minimum height for data to be included. Default is 5.
max_slope (int): Maximum slope to consider, avoTupleiding vertical movements. Default is 70.
Returns:
tuple: Mode of course elevation and its negative, representing possible ascent/descent angles.
"""
df = df[df["height_ato"] >= df["height_ato"].min() + min_height]
data = df[course_col].to_numpy()
data = np.abs(data[~np.isnan(data)])
# to get around the edge case where vertical movements are modal
data = data[data < max_slope]
hist, edges = np.histogram(data, bins=50)
max_freq_idx = np.argsort(hist)[::-1][:2]
mode = edges[max_freq_idx][0]
return (mode, -mode)
def height_transect_splitter(df: pd.DataFrame, height_col: str = "height_ato") -> tuple[pd.DataFrame, Figure]:
"""
Splits the dataset into height-based transects and plots histogram peaks to identify prominent
height ranges. Only works if the flights are flat.
Parameters:
df (pd.DataFrame): The input dataframe containing height data.
Returns:
tuple: Modified dataframe with transect labels and a figure showing the histogram with peaks.
"""
df = df.copy()
heights = df[height_col].to_numpy()
counts, bin_edges = np.histogram(heights, bins=40)
bin_width = bin_edges[1] - bin_edges[0]
bin_edges = np.append(heights.min() - bin_width, bin_edges) # avoid literal edge effects
bin_edges = np.append(bin_edges, heights.max() + bin_width)
counts = np.append(0, counts)
counts = np.append(counts, 0)
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
peaks, properties = find_peaks(counts)
transect_edges = (bin_centers[peaks][:-1] + bin_centers[peaks][1:]) / 2
transect_edges = np.append(heights.min(), transect_edges)
transect_edges = np.append(transect_edges, heights.max())
fig, ax = plt.subplots()
ax.stairs(edges=bin_edges, values=counts, fill=True)
ax.plot(bin_centers[peaks], counts[peaks], "x", color="red")
ax.vlines(transect_edges, ymin=0, ymax=max(counts), color="red")
df["transect_num"] = pd.cut(df[height_col], bins=list(transect_edges), labels=False, include_lowest=True) # type: ignore
return df, fig
def add_transect_azimuth_switches(df: pd.DataFrame, threshold=150, shift=3) -> pd.DataFrame:
"""
Identifies transects based on significant changes in course azimuth, incrementing a transect
counter to distinguish different flight paths. This is a really crude function and should probably
be only used with data that's already been filtered in some way.
Parameters:
df (pd.DataFrame): The input dataframe with course azimuths.
Returns:
pd.DataFrame: The modified dataframe with a new 'transect' column indicating transect IDs.
"""
df = df.copy()
df["transect_num"] = 0
df["prev_course_azimuth"] = df["course_azimuth"].shift(shift) # this gives better behaviour for very neat transects
df["deg_displace"] = df.apply(
lambda row: min_angular_displacement(row["course_azimuth"], row["prev_course_azimuth"]) # type: ignore
if not pd.isnull(row["prev_course_azimuth"])
else np.nan,
axis=1,
)
df.loc[df["deg_displace"] > threshold, "transect_num"] = 1
# remove ones that are next to each other
for i in range(1, len(df)):
if (df.loc[i, "transect_num"] == 1 and df.loc[i - 1, "transect_num"] == 1) or (
df.loc[i, "transect_num"] == 1 and df.loc[i - 2, "transect_num"] == 1
):
df.loc[i, "transect_num"] = 0
df["transect_num"] = df["transect_num"].shift(-shift) # recorrect for shift
df["transect_num"] = df["transect_num"].cumsum() + 1 # 1 indexed
df["transect_num"] = df["transect_num"].ffill()
df = df.drop(columns=["prev_course_azimuth", "deg_displace"])
return df
def course_filter(
df: pd.DataFrame, azimuth_filter: float, azimuth_window: int, elevation_filter: float
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
Filters data based on specified course azimuth and elevation, aiming to isolate transects that
align with main flight directions. First elevation is filtered to remove significant climbs or descents
(beware terrain-following flights). Bimodal course azimuths are calculated and used to filter the
data based on the main flight directions, with a rolling median applied (the window) to smooth the data.
Parameters:
df (pd.DataFrame): The input dataframe.
azimuth_filter (float): The tolerance for deviation from the main course azimuths.
azimuth_window (int): The window size for rolling median calculation of course azimuths.
elevation_filter (float): The tolerance for deviation from horizontal flight.
Returns:
tuple: The filtered dataframe and the original unfiltered dataframe for comparison.
"""
df_filtered = df.copy()
df_filtered = df_filtered[abs(df["course_elevation"]) < elevation_filter]
azi1, azi2 = bimodal_azimuth(df_filtered)
logging.info(f"Drone appears to be flying mainly on the courses {azi1:.2f} degrees and {azi2:.2f} degrees")
df_filtered["rolling_course_azimuth"] = (
df_filtered["course_azimuth"].rolling(azimuth_window, center=True).apply(lambda x: circ_median(x), raw=True)
)
df_filtered = df_filtered[
(df_filtered["rolling_course_azimuth"] < azi1 + azimuth_filter)
& (df_filtered["rolling_course_azimuth"] > azi1 - azimuth_filter)
| (df_filtered["rolling_course_azimuth"] < azi2 + azimuth_filter)
& (df_filtered["rolling_course_azimuth"] > azi2 - azimuth_filter)
]
return df_filtered, df
def mCount_max(data_dict: dict[int, float]) -> tuple[int, int]:
"""
Finds the start and end of the longest monotonic sequence in a dictionary of floats, typically used
to identify a series of continuous altitude measurements. The first and last are retained.
Parameters:
data_dict (Dict[int, float]): Dictionary with sequential numeric keys and numeric values representing measures
such as altitude.
Returns:
tuple: Start and end indices of the longest monotonic sequence in the dictionary.
"""
if len(data_dict) < 2:
raise ValueError("Dictionary must contain at least two values")
if list(data_dict.keys()) != list(range(1, len(data_dict) + 1)):
raise ValueError("Keys must be sequential integers starting from 1")
poscount = 0
negcount = 0
max_pos_count = 0
max_pos_transect = 0
max_neg_count = 0
max_neg_transect = 0
pos_start = 0
neg_start = 0
for i in range(2, len(data_dict) + 1):
if data_dict[i] >= data_dict[i - 1]:
poscount += 1
negcount = 0
elif data_dict[i] < data_dict[i - 1]:
negcount += 1
poscount = 0
if max_pos_count < poscount:
max_pos_count = poscount
max_pos_transect = i
pos_start = i - poscount
elif max_neg_count < negcount:
max_neg_count = negcount
max_neg_transect = i
neg_start = i - negcount
if max_pos_count > 0 or max_neg_count > 0:
if max_pos_count >= max_neg_count:
return pos_start, max_pos_transect
else:
return neg_start, max_neg_transect
else:
return 0, 0
def largest_monotonic_transect_series(
df: pd.DataFrame, transect_col: str = "transect_num", alt_col: str = "height_ato"
) -> tuple[pd.DataFrame, int, int]:
"""
Filters the input dataframe to include only the largest continuous series of transects based on
monotonic altitude changes.
Parameters:
df (pd.DataFrame): The input dataframe with transect and altitude information.
transect_col (str): Column name for transect numbers. Default is "transect_num".
alt_col (str): Column name for altitude data. Default is "height_ato".
Returns:
tuple: The filtered dataframe, start transect, and end transect of the largest monotonic series.
"""
df = add_transect_azimuth_switches(df) # course switches
alt_dict = dict(df.groupby(transect_col)[alt_col].mean())
starttransect, endtransect = mCount_max(alt_dict) # type: ignore
# filter to the biggest monotonic series of values
df = df[(df[transect_col] >= starttransect) & (df[transect_col] <= endtransect)]
logging.info(
f"Parsed a flight of {len(np.unique(df[transect_col]))} transects from {alt_dict[starttransect]:.0f}m"
f" to {alt_dict[endtransect]:.0f}m between {df['timestamp'].iloc[0]} and {df['timestamp'].iloc[-1]}",
)
return df, starttransect, endtransect
def monotonic_transect_groups(
df: pd.DataFrame, transect_col: str = "transect_num", alt_col: str = "height_ato"
) -> tuple[pd.DataFrame, dict[int, str]]:
"""
Groups transects into a dict of monotonic transect sequences based on altitude, facilitating analysis of continuous
flight patterns. Current behaviour is to reuse end transects of previous sequences as the start of the next.
Parameters:
df (pd.DataFrame): The input dataframe with transect and altitude information.
transect_col (str): Column name for transect numbers. Default is "transect_num".
alt_col (str): Column name for altitude data. Default is "height_ato".
Returns:
tuple: The dataframe with a new 'group' column indicating the monotonic group ID, and a
dictionary mapping transects to group IDs.
"""
df = add_transect_azimuth_switches(df)
alt_dict = dict(df.groupby(transect_col)[alt_col].mean())
group_dict = {}
previous_altitude = None
current_group = 1
previous_trend = None
first_transect_in_series = True
for transect, altitude in alt_dict.items():
if previous_altitude is None:
group_dict[transect] = f"Group_{current_group}"
else:
if altitude == previous_altitude:
raise ValueError("Error: altitude is the same as the previous transect")
elif altitude > previous_altitude:
current_trend = "ascending"
else:
current_trend = "descending"
if current_trend != previous_trend and previous_trend is not None and not first_transect_in_series:
current_group += 1 # Increment group counter
first_transect_in_series = True
if current_trend == previous_trend or previous_trend is None:
# handles edge case where someone flies up, flies down one transect and then up again (yes really)
first_transect_in_series = False
group_dict[transect] = f"Group_{current_group}"
previous_trend = current_trend
previous_altitude = altitude
df["group"] = df["transect_num"].map(group_dict)
return df, group_dict
def remove_non_transects(
df: pd.DataFrame,
chain_length: int = 70,
azimuth_tolerance: int = 10,
elevation_tolerance: int = 40,
smoothing_window: int = 5,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
Filters the dataframe to remove segments not matching the criteria for being considered as transects,
based on course azimuth and elevation and segment length.
Parameters:
df (pd.DataFrame): The input dataframe.
chain_length (int): Minimum number of consecutive points to be considered a transect. Default is 70.
azimuth_tolerance (int): Tolerance for deviation from modal course azimuths. Default is 10.
elevation_tolerance (int): Tolerance for deviation from modal course elevation. Default is 40.
smoothing_window (int): Window size for rolling median smoothing of courses. Default is 5. Rolling median
avoids single point errors.
Returns:
tuple: Dataframes of removed segments and retained segments that fit transect criteria.
"""
def get_true_runs(mask):
enumerated_mask = list(enumerate(mask)) # add index numbers to mask
# group consecutive true/false values
groups = groupby(enumerated_mask, key=lambda x: x[1])
# retain groups of True values
true_runs = [list(group) for key, group in groups if key]
return true_runs
def split_runs_on_azimuth_inversion(df, runs, azimuth_inversion_threshold=120):
split_runs = []
for run in runs:
last_azimuth = df.iloc[run[0][0]]["smoothed_course_azimuth"]
current_run = [run[0]]
for point in run[1:]:
current_azimuth = df.iloc[point[0]]["smoothed_course_azimuth"]
if min_angular_displacement(current_azimuth, last_azimuth) > azimuth_inversion_threshold:
split_runs.append(current_run)
current_run = [point]
else:
current_run.append(point)
last_azimuth = current_azimuth
split_runs.append(current_run)
return split_runs
# Create new columns for filtering reasons, initialized with False
df_removed = df.copy()
df_removed["filtered_by_azimuth"] = False
df_removed["filtered_by_elevation"] = False
df_removed["filtered_by_chain"] = False
# Apply azimuth filter
modal_course_azimuths = bimodal_azimuth(df, course_col="course_azimuth")
modal_elevation_azimuths = bimodal_elevation(df, course_col="course_elevation")
# Apply rolling median to course azimuth and elevation and store them in new columns
df_removed["smoothed_course_azimuth"] = (
df_removed["course_azimuth"]
.rolling(smoothing_window, center=True)
.apply(lambda x: circmean(x, 360, 0), raw=True)
.fillna(df_removed["course_azimuth"], inplace=False)
)
df_removed["smoothed_course_elevation"] = (
df_removed["course_elevation"]
.rolling(smoothing_window, center=True)
.apply(lambda x: circmean(x, 360, 0), raw=True)
.fillna(df_removed["course_elevation"], inplace=False)
)
azimuth_mask = df_removed["smoothed_course_azimuth"].apply(
lambda x: any([min_angular_displacement(x, mode) <= azimuth_tolerance for mode in modal_course_azimuths])
)
df_removed.loc[~azimuth_mask, "filtered_by_azimuth"] = True
# Apply elevation filter
elevation_mask = df_removed["smoothed_course_elevation"].apply(
lambda x: any([min_angular_displacement(x, mode) <= elevation_tolerance for mode in modal_elevation_azimuths])
)
df_removed.loc[~elevation_mask, "filtered_by_elevation"] = True
# Combined mask for azimuth and elevation
mask = azimuth_mask & elevation_mask
true_runs = get_true_runs(mask)
split_runs = split_runs_on_azimuth_inversion(df_removed, true_runs)
chain_mask = [len(run) >= chain_length for run in split_runs]
chain_filtered_runs = [run for run, filter_by_chain in zip(split_runs, chain_mask, strict=False) if filter_by_chain]
chain_filtered_indices = [point[0] for run in chain_filtered_runs for point in run]
df_removed.loc[df_removed.index.difference(chain_filtered_indices), "filtered_by_chain"] = True
filtered_segments = [df_removed.iloc[run[0][0] : run[-1][0] + 1] for run in chain_filtered_runs]
df_retained = pd.concat(filtered_segments)
df_removed = df_removed[~df_removed.index.isin(df_retained.index)]
return df_removed, df_retained
def flatten_linear_plane(
df: pd.DataFrame, alt_col: str = "height_ato", distance_filter: float = 10000
) -> tuple[pd.DataFrame, float]:
"""
Transforms a 3D dataset into a linear plane, focusing on the largest contiguous dataset aligned with
the plane's primary orientation. The new x coordinate is the distance along the plane, y is the distance
perpendicular to the plane (useful only for deviation), and z is the altitude.
Parameters:
df (pd.DataFrame): Input dataframe with "utm_easting", "utm_northing", and "height_ato" columns.
alt_col (str): Column name for altitude data. Default is "height_ato".
distance_filter (float): Threshold for filtering points based on their distance from the regression line.
Returns:
tuple: Modified dataframe with new 'x', 'y', and 'z' columns representing transformed coordinates,
and one of the plane's angles of rotation (from N, 0-360) in degrees.
"""
def orthogonal_distance_regression(df: pd.DataFrame) -> tuple[pd.DataFrame, np.ndarray[Any, Any]]:
"""
Perform orthogonal distance regression on the given DataFrame.
Parameters:
df (pd.DataFrame): DataFrame containing "utm_easting" and "utm_northing" columns.
Returns:
tuple[pd.DataFrame, np.ndarra[Any,Any]]]: Updated DataFrame with "distance_from_fit" column
and the fitted parameters (slope, intercept).
"""
def linear_reg_equation(B, x):
return B[0] * x + B[1] # y = mx + c
required_columns = ["utm_easting", "utm_northing"]
if not all(col in df.columns for col in required_columns):
raise ValueError(f"DataFrame must contain columns: {required_columns}")
model = odr.Model(linear_reg_equation)
data = odr.Data(df["utm_easting"], df["utm_northing"])
INITIAL_BETA = [1, 0] # Initial guess of slope=1, intercept=0
odr_instance = odr.ODR(data, model, beta0=INITIAL_BETA)
fit = odr_instance.run()
if fit.stopreason[0] == "Iteration limit reached":
raise RuntimeError("ODR fitting failed to converge")
slope, intercept = fit.beta
df = df.assign(
distance_from_fit=abs((slope * df["utm_easting"] - df["utm_northing"] + intercept) / np.sqrt(slope**2 + 1))
)
return df, fit.beta
df, coefs2D = orthogonal_distance_regression(df)
df = df.loc[df["distance_from_fit"] < distance_filter, :]
df, coefs2D = orthogonal_distance_regression(df) # this is intentionally done twice
df = df.loc[df["distance_from_fit"] < distance_filter, :]
rotation = np.arctan(coefs2D[0])
df.loc[:, "x"] = (df["utm_easting"] - df["utm_easting"].min()) * np.cos(-rotation) - (
df["utm_northing"] - df["utm_northing"].min()
) * np.sin(-rotation)
df.loc[:, "y"] = (df["utm_easting"] - df["utm_easting"].min()) * np.sin(-rotation) + (
df["utm_northing"] - df["utm_northing"].min()
) * np.cos(-rotation)
df.loc[:, "z"] = df[alt_col]
plane_angle = (np.pi / 2) - np.arctan(coefs2D[0])
plane_angle = np.degrees(plane_angle)
return df, plane_angle
## Functions for circular/spiral flights ##
def circle_deviation(df: pd.DataFrame, x_col: str, y_col: str) -> tuple[pd.DataFrame, float, float, float]:
"""
Calculates the deviation of points from a fitted circle and their azimuth angles.
Parameters:
df (pd.DataFrame): The input dataframe containing the data.
x (str): Column name for the x-coordinate.
y (str): Column name for the y-coordinate.
Returns:
Tuple[pd.Dataframe, float, float, float]: A tuple containing the modified dataframe with azimuth angles
and deviations,the radius of the fitted circle, and the coordinates of the circle's center.
"""
df = df.copy()
required_columns = [x_col, y_col]
if not all(col in df.columns for col in required_columns):
raise ValueError(f"DataFrame must contain columns: {required_columns}")
x = df[x_col]
y = df[y_col]
def midhalf(x): # select middle half of data to avoid edge effects
return x.iloc[int(len(x) * 1 / 4) : int(len(x) * 3 / 4)]
x_filter, y_filter = midhalf(x), midhalf(y)
def func(params):
xc, yc, r = params
return np.sqrt((x - xc) ** 2 + (y - yc) ** 2) - r
x_m = np.mean(np.array(x_filter)) # initial guess for parameters
y_m = np.mean(np.array(y_filter))
r_m = np.mean(np.sqrt((np.array(x_filter) - x_m) ** 2 + (np.array(y_filter) - y_m) ** 2))
params0 = np.array([x_m, y_m, r_m])
result = least_squares(func, params0)
xc, yc, r = result.x
deviation = np.sqrt((x - xc) ** 2 + (y - yc) ** 2) - r
# output azimuth in radians with 0 at north and increasing clockwise thanks to modulos
azimuth = np.degrees(np.arctan2(x - xc, y - yc) % (2 * np.pi))
df["circ_azimuth"] = azimuth
df["circ_deviation"] = deviation
return df, r, xc, yc
def recentre_azimuth(df: pd.DataFrame, r: float, x: str = "circ_azimuth", y: str = "ch4_normalised") -> pd.DataFrame:
"""
Recentres the azimuth angles based on the angle of maximum value and computes the distance along the
circumference of the circle.
Parameters:
df (pd.DataFrame): The input dataframe.
r (float): The radius of the circle.
x (str): Column name for azimuth angles. Default is 'circ_azimuth'.
y (str): Column name for the values used to find the maximum. Default is 'ch4_normalised'.
Returns:
pd.DataFrame: The modified dataframe with centered azimuth angles and distances along the circumference.
"""
df = df.copy()
def azimuth_of_max(df: pd.DataFrame, x: str = "circ_azimuth", y: str = "ch4_normalised") -> float:
return df.loc[df[y].idxmax()][x] # type: ignore
centre_azimuth = azimuth_of_max(df, x, y)
df["centred_azimuth"] = df[x] - centre_azimuth
df.loc[df["centred_azimuth"] > 180, "centred_azimuth"] -= 360
df.loc[df["centred_azimuth"] < -180, "centred_azimuth"] += 360
df["circumference_distance"] = r * np.radians(df["centred_azimuth"])
df["circumference_distance"] = df["circumference_distance"] - df["circumference_distance"].min()
return df
def drone_anemo_to_point_wind(
df: pd.DataFrame, yaw_col: str, anemo_u_col: str, anemo_v_col: str, easting_col: str, northing_col: str
) -> pd.DataFrame:
"""
Convert anemometer wind data from drone's coordinate system to Earth's coordinate system
and calculate true wind speed and direction.
Parameters:
df (pd.DataFrame): Input DataFrame containing drone yaw, anemometer data, and drone speed.
yaw_col (str): Column name for drone's yaw (in degrees, range [-180, 180]).
anemo_u_col (str): Column name for anemometer U (wind speed in drone's X direction, from port to starboard).
anemo_v_col (str): Column name for anemometer V (wind speed in drone's Y direction, from aft to nose).
easting_col (str): Column name for drone's speed from west to east
northing_col (str): Column name for drone's speed from south to north
Returns:
pd.DataFrame: DataFrame with calculated true wind speed ("windspeed") and true wind direction ("winddir").
"""
yaw_rad = np.deg2rad(df[yaw_col] % 360)
rotated_U = df[anemo_u_col] * np.cos(yaw_rad) + df[anemo_v_col] * np.sin(yaw_rad)
rotated_V = -df[anemo_u_col] * np.sin(yaw_rad) + df[anemo_v_col] * np.cos(yaw_rad)
true_U = -rotated_U - df[easting_col]
true_V = -rotated_V - df[northing_col]
df["windspeed"] = np.sqrt(true_U**2 + true_V**2)
df["winddir"] = np.degrees(np.arctan2(true_U, true_V)) % 360
return df

View File

@ -0,0 +1,317 @@
import logging
from datetime import datetime
from pathlib import Path
from scipy import stats
import pandas as pd
import plotly.graph_objects as go
import yaml
from src.gasflux import background,plotting,processing,reporting,interpolation,pre_processing,gas
from abc import ABC, abstractmethod
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def read_csv(file_path: Path) -> pd.DataFrame:
"""Read a CSV file and return a DataFrame."""
try:
return pd.read_csv(file_path)
except FileNotFoundError:
logger.exception(f"File not found: {file_path}")
raise
def load_config(config_path: Path) -> dict:
"""Load a YAML config file and return a dictionary."""
try:
with open(config_path) as file:
return yaml.safe_load(file)
except FileNotFoundError:
logger.exception(f"Config file not found: {config_path}")
raise
except yaml.YAMLError as e:
logger.exception(f"Error parsing YAML config: {e}")
raise
class DataValidator: # TODO(me): decide whether to move this to preprocessing
"""Validate the data before processing."""
def __init__(self, df: pd.DataFrame, config: dict) -> None:
"""Initialise the validator with the DataFrame and config."""
self.df = df
self.required_cols = config["required_cols"].copy()
self.required_cols.update(config["gases"])
def validate(self) -> None:
"""Validate the data."""
self._check_is_df()
self._check_cols()
self._check_dtypes()
self._check_ranges()
logger.info("Data validation passed")
def _check_is_df(self) -> None:
"""Check that the input is a DataFrame."""
if not isinstance(self.df, pd.DataFrame):
logging.error("Input data is not a DataFrame.")
raise ValueError("Input data is not a DataFrame.")
def _check_cols(self) -> None:
"""Check that the required columns are present."""
missing_cols = set(self.required_cols) - set(self.df.columns)
if missing_cols:
logging.error(f"Missing or mislabelled columns: {missing_cols}")
raise ValueError(f"Missing or mislabelled columns: {missing_cols}")
def _check_dtypes(self) -> None:
"""Check that the required columns are of the correct type and do not contain NaN values."""
for col in self.required_cols:
if col in self.df:
if self.df[col].isna().any():
logging.error(f"Column '{col}' contains NaN values.")
raise ValueError(f"Column '{col}' contains NaN values.")
if self.df[col].dtype != "float64":
logging.error(f"Column '{col}' is not of type 'float64'.")
raise ValueError(f"Column '{col}' is not of type 'float64'.")
def _check_ranges(self) -> None:
"""Check that the required columns are within the specified ranges."""
for col, (min_val, max_val) in self.required_cols.items():
if col in self.df.columns and not self.df[col].between(min_val, max_val, inclusive="both").all():
logging.error(f"Column '{col}' contains values out of range: {min_val} to {max_val}.")
raise ValueError(f"Column '{col}' contains values out of range: {min_val} to {max_val}.")
class BackgroundStrategy(ABC):
def __init__(self, data_processor):
self.data_processor = data_processor
@abstractmethod
def process(self):
pass
class AlgorithmicBaselineStrategy(BackgroundStrategy):
def process(self):
logger.info("Applying algorithmic background correction")
for gas in self.data_processor.gases:
(
self.data_processor.df,
self.data_processor.figs["background"][gas],
self.data_processor.text[f"background_{gas}"],
) = background.algorithmic_baseline(
df=self.data_processor.df,
gas=gas,
algorithmic_baseline_settings=self.data_processor.config["algorithmic_baseline_settings"],
)
self.data_processor.df_std = self.data_processor.df.copy()
class SensorStrategy(ABC):
def __init__(self, data_processor):
self.data_processor = data_processor
@abstractmethod
def process(self):
pass
class InSituSensorStrategy(SensorStrategy):
def process(self):
logger.info("Processing in-situ (point) data")
for gas in self.data_processor.gases:
self.data_processor.figs["scatter_3d"][gas] = plotting.scatter_3d(
df=self.data_processor.df, color=gas, colorbar_title=f"{gas.upper()} flux (kg/m²/h)"
)
if SpatialProcessingStrategy == CurtainSpatialProcessingStrategy:
self.data_processor.figs["windrose"] = plotting.windrose(self.data_processor.df, plot_transect=True)
else:
self.data_processor.figs["windrose"] = plotting.windrose(self.data_processor.df)
self.data_processor.figs["wind_timeseries"] = plotting.time_series(
self.data_processor.df, ys=["windspeed", "winddir"]
)
class SpatialProcessingStrategy(ABC):
def __init__(self, data_processor):
self.data_processor = data_processor
@abstractmethod
def process(self):
pass
class CurtainSpatialProcessingStrategy(SpatialProcessingStrategy):
def process(self):
logger.info("Applying curtain spatial processing")
self.data_processor.dfs["original"] = self.data_processor.df.copy()
self.data_processor.df, self.data_processor.start_transect, self.data_processor.end_transect = (
processing.largest_monotonic_transect_series(self.data_processor.df)
)
self.data_processor.dfs["removed"] = self.data_processor.dfs["original"].loc[
self.data_processor.dfs["original"].index.difference(self.data_processor.df.index)
]
self.data_processor.df, self.data_processor.plane_angle = processing.flatten_linear_plane(
self.data_processor.df
)
self.data_processor.df = processing.wind_offset_correction(
self.data_processor.df, self.data_processor.plane_angle
)
for gas_name in self.data_processor.gases:
#计算通量
self.data_processor.df = gas.gas_flux_column(self.data_processor.df, gas_name)
self.data_processor.figs["scatter_3d"][gas_name].add_trace(
go.Scatter3d(
x=self.data_processor.dfs["removed"]["utm_easting"],
y=self.data_processor.dfs["removed"]["utm_northing"],
z=self.data_processor.dfs["removed"]["height_ato"],
mode="markers",
marker={"size": 2, "color": "black", "symbol": "circle", "opacity": 0.5},
)
)
class SpiralSpatialProcessingStrategy(SpatialProcessingStrategy):
def process(self):
logger.info("Applying spiral spatial processing")
self.data_processor.dfs["original"] = self.data_processor.df.copy()
# self.data_processor.df, self.data_processor.start_transect, self.data_processor.end_transect = (
# gasflux.processing.largest_monotonic_transect_series(self.data_processor.df)
# no wind offset correction - assume wind is perpendicular to the spiral
self.data_processor.dfs["removed"] = self.data_processor.dfs["original"].loc[
self.data_processor.dfs["original"].index.difference(self.data_processor.df.index)
]
(
self.data_processor.df,
self.data_processor.circle_radius,
self.data_processor.circle_center_x,
self.data_processor.circle_center_y,
) = processing.circle_deviation(self.data_processor.df, x_col="utm_easting", y_col="utm_northing")
self.data_processor.df = processing.recentre_azimuth(
self.data_processor.df, r=self.data_processor.circle_radius
)
self.data_processor.df["x"] = self.data_processor.df["circumference_distance"]
for gas_name in self.data_processor.gases:
self.data_processor.df = gas.gas_flux_column(self.data_processor.df, gas_name)
self.data_processor.figs["scatter_3d"][gas_name].add_trace(
go.Scatter3d(
x=self.data_processor.dfs["removed"]["utm_easting"],
y=self.data_processor.dfs["removed"]["utm_northing"],
z=self.data_processor.dfs["removed"]["height_ato"],
mode="markers",
marker={"size": 2, "color": "black", "symbol": "circle", "opacity": 0.5},
)
)
class InterpolationStrategy(ABC):
def __init__(self, data_processor):
self.data_processor = data_processor
@abstractmethod
def process(self):
pass
class KrigingInterpolationStrategy(InterpolationStrategy):
def process(self):
logger.info("Applying kriging interpolation")
for gas in self.data_processor.gases:
(
self.data_processor.output_vars["krig_parameters"][gas],
self.data_processor.text[f"krig_output_{gas}"],
self.data_processor.figs["contour"][gas],
self.data_processor.figs["krig_grid"][gas],
self.data_processor.figs["semivariogram"][gas],
) = interpolation.ordinary_kriging(
df=self.data_processor.df,
x="x",
y="height_ato",
gas=gas,
ordinary_kriging_settings=self.data_processor.config["ordinary_kriging_settings"],
**self.data_processor.config["semivariogram_settings"],
)
logger.info(f"Kriged {gas}")
class DataProcessor:
def __init__(self, config: dict, df: pd.DataFrame):
self.config: dict = config
self.df: pd.DataFrame = df
self.gases: list[str] = list(config["gases"].keys())
self.processing_time = datetime.now()
self.figs: dict = {
"scatter_3d": {},
"windrose": None,
"wind_timeseries": None,
"background": {},
"contour": {},
"krig_grid": {},
"semivariogram": {},
}
self.text: dict = {}
self.output_vars: dict = {"krig_parameters": {}, "std": {}}
self.dfs: dict = {}
self.reports: dict = {}
def strategy_selection(self):
self.background_strategy: BackgroundStrategy
if self.config["strategies"]["background"] == "algorithm":
self.background_strategy = AlgorithmicBaselineStrategy(self)
self.sensor_strategy: SensorStrategy
if self.config["strategies"]["sensor"] == "insitu":
self.sensor_strategy = InSituSensorStrategy(self)
self.spatial_processing_strategy: SpatialProcessingStrategy
if self.config["strategies"]["spatial"] == "curtain":
self.spatial_processing_strategy = CurtainSpatialProcessingStrategy(self)
if self.config["strategies"]["spatial"] == "spiral":
self.spatial_processing_strategy = SpiralSpatialProcessingStrategy(self)
self.interpolation_strategy: InterpolationStrategy
if self.config["strategies"]["interpolation"] == "kriging":
self.interpolation_strategy = KrigingInterpolationStrategy(self)
def process(self):
self.df = pre_processing.add_utm(self.df)
self.df = pre_processing.add_course(self.df)
DataValidator(self.df, self.config).validate()
self.background_strategy.process()
self.sensor_strategy.process()
self.spatial_processing_strategy.process()
self.interpolation_strategy.process()
# Reporting
for gas in self.gases:
self.reports[gas] = reporting.mass_balance_report(
krig_params=self.output_vars["krig_parameters"][gas],
wind_fig=self.figs["wind_timeseries"],
background_fig=self.figs["background"][gas],
threed_fig=self.figs["scatter_3d"][gas],
krig_fig=self.figs["contour"][gas],
windrose_fig=self.figs["windrose"],
)
# Collecting descriptive variables
self.output_vars["std"]["windspeed"] = self.df["windspeed"].std()
self.output_vars["std"]["windddir"] = stats.circstd(self.df["winddir"], high=360)
for gas in self.gases:
self.output_vars["std"][f"{gas}_background"] = self.df.loc[
~self.df[f"{gas}_signal"], f"{gas}_normalised"
].std()
def process_main(data_file: Path, config_file: Path) -> None:
"""Main function to run the pipeline."""
config = load_config(config_file)
name = data_file.stem
df = read_csv(data_file)
processor = DataProcessor(config, df)
processor.strategy_selection()
processor.process()
reporting.generate_reports(name, processor, config)
logger.info("Processing complete")

190
src/gasflux/qiya.py Normal file
View File

@ -0,0 +1,190 @@
import requests
import time
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def get_pressure_at_location(lat, lon, altitude, date, time, max_retries=3, timeout=30):
"""
获取指定位置、时间、高度的气压
Args:
lat: 纬度
lon: 经度
altitude: 海拔高度 (米)
date: 日期 (格式: YYYY-MM-DD 或 YYYY/MM/DD)
time: 时间 (格式: HH:MM 或 HH:MM:SS)
max_retries: 最大重试次数
timeout: 请求超时时间(秒)
Returns:
float: 气压值 (hPa),获取失败返回 None
"""
# 标准化日期格式为 YYYY-MM-DD
def normalize_date(d):
"""将各种日期格式标准化为 YYYY-MM-DD"""
if not d:
from datetime import datetime
return datetime.now().strftime("%Y-%m-%d")
# 处理斜杠分隔符
if "/" in d:
d = d.replace("/", "-")
parts = d.split("-")
if len(parts) == 3:
year = parts[0]
month = parts[1].zfill(2) # 确保月份是两位数
day = parts[2].zfill(2) # 确保日期是两位数
return f"{year}-{month}-{day}"
else:
# 如果格式不正确,返回今天的日期
from datetime import datetime
return datetime.now().strftime("%Y-%m-%d")
date = normalize_date(date)
# 标准化时间格式为 HH:MM
def normalize_time(t):
"""将各种时间格式标准化为 HH:MM"""
if not t or ":" not in t:
return "12:00" # 默认中午12点
parts = t.split(":")
if len(parts) >= 2:
hour = parts[0].zfill(2) # 确保小时是两位数
minute = parts[1].zfill(2) # 确保分钟是两位数
return f"{hour}:{minute}"
elif len(parts) == 1:
hour = parts[0].zfill(2)
return f"{hour}:00"
else:
return "12:00"
time = normalize_time(time)
url = "https://archive-api.open-meteo.com/v1/archive"
params = {
"latitude": lat,
"longitude": lon,
"start_date": date, # 格式: YYYY-MM-DD
"end_date": date,
"hourly": ["pressure_msl", "surface_pressure"],
"timezone": "auto"
}
# 创建带有重试机制的会话
session = requests.Session()
retry_strategy = Retry(
total=max_retries,
status_forcelist=[429, 500, 502, 503, 504],
backoff_factor=1
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
try:
print(f"正在获取位置 ({lat:.6f}, {lon:.6f}) 在 {date} {time} 的气压数据...")
response = session.get(url, params=params, timeout=timeout)
# 检查响应状态
response.raise_for_status()
data = response.json()
# 检查API错误
if "error" in data:
print(f"API错误: {data['error']}")
return None
# 解析气压数据
if data and "hourly" in data:
times = data["hourly"]["time"]
pressures = data["hourly"]["surface_pressure"] # 地表气压
if not times or not pressures:
print("未找到气压数据")
return None
# 根据时间找到对应气压
target_time = f"{date}T{time}"
if target_time in times:
idx = times.index(target_time)
pressure = pressures[idx]
print(f"成功获取气压: {pressure} hPa")
return pressure
else:
print(f"在数据中未找到时间: {target_time}")
print(f"可用时间范围: {times[0]} 到 {times[-1]}")
return None
else:
print("API响应中没有hourly数据")
return None
except requests.exceptions.ConnectionError as e:
print(f"网络连接错误: {e}")
print("请检查网络连接或稍后重试")
return None
except requests.exceptions.Timeout as e:
print(f"请求超时: {e}")
print(f"已重试 {max_retries} 次,请检查网络连接")
return None
except requests.exceptions.HTTPError as e:
print(f"HTTP错误: {e}")
return None
except ValueError as e:
print(f"数据解析错误: {e}")
return None
except Exception as e:
print(f"未知错误: {e}")
return None
finally:
session.close()
def batch_get_pressure(data_list):
"""
批量获取多个位置的气压数据
Args:
data_list: 包含 (lat, lon, altitude, date, time) 元组的列表
Returns:
list: 气压值列表
"""
results = []
for i, (lat, lon, alt, date, time) in enumerate(data_list):
print(f"\n处理第 {i+1} 个位置...")
pressure = get_pressure_at_location(lat, lon, alt, date, time)
results.append(pressure)
if pressure is not None:
print(f"位置 {i+1}: {pressure} hPa")
else:
print(f"位置 {i+1}: 获取失败")
# 添加短暂延迟,避免请求过于频繁
if i < len(data_list) - 1:
time.sleep(0.5)
return results
# 使用示例
if __name__ == "__main__":
print("=== 气压数据获取工具 ===\n")
# 单个位置示例
print("1. 单个位置查询:")
pressure = get_pressure_at_location(
lat=40.3491370, # 纽约纬度
lon=115.7855289, # 纽约经度 (西经)
altitude=435.789, # 海拔10米
date="2016-02-12",
time="08:00" # HH:MM格式
)
if pressure is not None:
print(f"纽约当前气压: {pressure} hPa")
else:
print("获取纽约气压数据失败")

130
src/gasflux/reporting.py Normal file
View File

@ -0,0 +1,130 @@
"""This module provides functions for generating mass balance reports."""
from pathlib import Path
import plotly.graph_objects as go
from jinja2 import Template
from plotly.io import to_html
from datetime import datetime
import yaml
import logging
from . import plotting
import json
import numpy as np
logger = logging.getLogger(__name__)
def mass_balance_report(
krig_params: dict,
wind_fig: go.Figure,
background_fig: go.Figure,
threed_fig: go.Figure,
krig_fig: go.Figure,
windrose_fig: go.Figure,
) -> str:
"""Generate a mass balance report."""
template_path = Path(__file__).parent / "templates" / "mass_balance_template.html"
# Convert the figures to HTML
plot_htmls = {}
for name, fig in zip(
["3D", "krig", "windrose", "wind", "background"],
[threed_fig, krig_fig, windrose_fig, wind_fig, background_fig],
strict=False,
):
if fig:
plot_htmls[name] = to_html(fig, full_html=False)
else:
plot_htmls[name] = plotting.blank_figure()
summary_data = {
"Estimated flux": f"{krig_params.get('volume', 0):.3f} kgh⁻¹",
}
with Path.open(template_path) as f:
template_content = f.read()
template = Template(template_content)
return template.render(
title="Mass Balance Report",
summary_data=summary_data,
threeD=plot_htmls["3D"],
krig=plot_htmls["krig"],
windrose=plot_htmls["windrose"],
wind=plot_htmls["wind"],
background=plot_htmls["background"],
)
def generate_reports(name: str, processor, config: dict):
"""
Generates reports, configuration files, and processed output variables for gasflux processing runs.
Parameters:
name (str): The name identifier for the current processing run.
processor (object): The processing object containing report data and output variables.
config (dict): Configuration dictionary used for processing.
"""
output_dir = Path(config["output_dir"]).expanduser()
processing_time = datetime.now()
output_path = output_dir / name / processing_time.strftime("%Y-%m-%d_%H-%M-%S-%f_processing_run")
output_path.mkdir(parents=True, exist_ok=True)
# Save reports
for gas, report in processor.reports.items():
report_path = output_path / f"{name}_{gas}_report.html"
with open(report_path, "w", encoding="utf-8") as file:
file.write(report)
# Save config
header = f"# Gasflux output config for file {name} from processing run at {processing_time}\n"
config_path = output_path / f"{name}_config.yaml"
with open(config_path, "w") as file:
file.write(header)
yaml.safe_dump(config, file)
# Save DataFrame to CSV
if hasattr(processor, 'df') and processor.df is not None:
csv_path = output_path / f"{name}_data.csv"
processor.df.to_csv(csv_path, index=False)
logger.info(f"DataFrame saved to {csv_path}")
# Save output variables
output_vars = processor.output_vars
# output_vars = delete_large_arrays(output_vars, threshold_size=50)
header = (
f"# Gasflux output variables for file {name} from processing run at {processing_time}\n"
)
filename = output_path / f"{name}_output_vars.json"
with open(filename, "w") as file:
file.write(header)
json.dump(
output_vars, file, default=lambda item: item.tolist() if isinstance(item, np.ndarray) else item, indent=4
)
logger.info(f"Processing run saved to {output_path}")
def delete_large_arrays(output_vars: dict, threshold_size: int) -> dict:
"""
Iterate through the output_vars dictionary and replace large numpy arrays
with their metadata (e.g., shape and data type).
Parameters:
output_vars (dict): The dictionary containing output data including potential numpy arrays.
threshold_size (int): The number of elements above which an array is considered large.
"""
del_keys = []
for key, value in output_vars.items():
if isinstance(value, dict):
output_vars[key] = delete_large_arrays(value, threshold_size) # recursive
elif isinstance(value, np.ndarray):
if value.size > threshold_size:
del_keys.append(key)
for key in del_keys:
del output_vars[key]
return output_vars

Binary file not shown.

142
src/gasflux/run_example.py Normal file
View File

@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
GasFlux 完整处理示例脚本
这个脚本演示了完整的GasFlux处理流程:
1. 使用data_processor.py处理原始Excel数据
2. GasFlux数据验证和处理:
- 背景校正
- 空间处理
- 克里金插值
- 生成报告
运行方法:
python run_example.py input.xlsx
python run_example.py input.xlsx --output processed_data.csv
"""
import sys
import argparse
from pathlib import Path
# 导入同一包内的模块
# 优先尝试相对导入(当作为包的一部分运行时)
# 如果失败,则尝试绝对导入(当直接运行脚本时)
try:
# 尝试相对导入
from .processing_pipelines import process_main
from .data_processor import process_file
print("✅ 成功导入GasFlux模块(相对导入)")
except ImportError:
try:
# 相对导入失败,尝试绝对导入
from src.gasflux.processing_pipelines import process_main
from src.gasflux.data_processor import process_file
print("✅ 成功导入GasFlux模块(绝对导入)")
except ImportError as e:
print(f"❌ 导入GasFlux模块失败: {e}")
print("请确保GasFlux包结构完整,或从项目根目录运行")
sys.exit(1)
def main():
"""主函数:运行完整的数据处理和GasFlux分析流程"""
parser = argparse.ArgumentParser(
description="GasFlux完整处理流程:Excel数据预处理 + 通量分析",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
python run_example.py data.xlsx # 基本使用
python run_example.py data.xlsx --output result.csv # 指定输出文件名
python run_example.py data.xlsx --no-gasflux # 仅预处理,不进行通量分析
处理步骤:
1. 使用data_processor.py处理Excel文件
2. 对处理后的数据进行GasFlux通量分析
"""
)
parser.add_argument('input_file', help='输入的Excel文件路径 (.xlsx 或 .xls)')
parser.add_argument('-o', '--output', help='预处理后的CSV输出文件名(可选)')
parser.add_argument('--no-gasflux', action='store_true',
help='仅执行数据预处理,跳过GasFlux通量分析')
parser.add_argument('-c', '--config', help='GasFlux配置文件路径(可选,默认使用内置配置)')
args = parser.parse_args()
print("=== GasFlux 完整处理流程 ===\n")
# 检查输入文件
input_path = Path(args.input_file)
if not input_path.exists():
print(f"❌ 错误:输入文件不存在: {input_path}")
return
if input_path.suffix.lower() not in ['.xlsx', '.xls']:
print(f"❌ 错误:输入文件必须是Excel格式: {input_path}")
return
# 确定输出文件名
if args.output:
processed_csv = Path(args.output)
else:
processed_csv = input_path.with_suffix('.processed.csv')
# 确定配置文件
if args.config:
config_file = Path(args.config)
else:
config_file = Path(__file__).parent / "data" / "gasflux_config.yaml"
print(f"输入Excel文件: {input_path}")
print(f"预处理输出CSV: {processed_csv}")
if not args.no_gasflux:
print(f"GasFlux配置文件: {config_file}")
print()
try:
# 第一步:数据预处理
print("🔄 第一步:数据预处理...")
processed_df = process_file(str(input_path), str(processed_csv))
print(f"✅ 数据预处理完成,输出文件: {processed_csv}")
print()
# 如果不需要进行GasFlux分析,直接返回
if args.no_gasflux:
print("⏭️ 跳过GasFlux通量分析")
return
# 第二步:GasFlux通量分析
print("🔄 第二步:GasFlux通量分析...")
# 检查配置文件
if not config_file.exists():
print(f"❌ 错误:GasFlux配置文件不存在: {config_file}")
return
print(f"使用数据文件: {processed_csv}")
print(f"使用配置文件: {config_file}")
print()
# 运行GasFlux处理流程
process_main(processed_csv, config_file)
print("\n✅ GasFlux通量分析完成!")
# 显示输出信息
output_dir = Path("../../examples/basic_usage/5m") / processed_csv.stem / "processing_run"
if output_dir.exists():
print(f"\n📁 GasFlux输出目录: {output_dir}")
print("📊 生成的文件:")
for file in output_dir.rglob("*"):
if file.is_file():
print(f" - {file.name}")
except Exception as e:
print(f"\n❌ 处理失败: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<style>
.grid-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 10px;
height: 800px;
}
.grid-item.double {
grid-column: span 2;
}
.grid-item {
width: 100%;
min-width: 300px;
}
.grid-item .plot-container {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<h1>{{title}}</h1>
<table>
<tbody>
{% for key, value in summary_data.items() %}
<tr>
<td>{{key}}</td>
<td>{{value}}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="grid-container">
<div class="grid-item">
<div class="plot-container">
{{ threeD|safe }}
</div>
</div>
<div class="grid-item double">
<div class="plot-container">
{{ krig|safe }}
</div>
</div>
<div class="grid-item">
<div class="plot-container">
{{ windrose|safe }}
</div>
</div>
<div class="grid-item double">
<div class="plot-container">
{{ wind|safe }}
</div>
</div>
<div class="grid-item double">
<div class="plot-container">
{{ background|safe }}
</div>
</div>
</div>
</body>
</html>

1001
src/gasflux/testdata/exampledata.csv vendored Normal file

File diff suppressed because it is too large Load Diff

95
src/gasflux/testdata/testconfig.yaml vendored Normal file
View File

@ -0,0 +1,95 @@
# testconfig.yaml
horizontal_pixels: 500 # Width of the concentration map in pixels
vertical_pixels: 100 # Height of the concentration map in pixels
num_plumes: 10 # Number of Gaussian plumes
groupiness: 0.5 # Groupiness of the plumes (0.0 to 1.0)
spread: 0.1 # Spread of the plumes (0.0 to 1.0)
wind_reference_height: 10 # Reference height for wind speed calculation (m)
windspeed_avg: 5 # Average wind speed at 10m height (m/s)
windspeed_rel_std: 0.2 # Relative standard deviation of wind speed (0.0 to inf), recommend 0.2-0.4
surface_roughness: 0.1 # Surface roughness length (m)
seed: 42 # Random seed for reproducibility
simplex_octaves: 4 # Number of octaves for simplex noise (1 to inf, def 1)
simplex_persistence: 0.7 # Persistence of simplex noise (0.0 to 1.0, def 0.5) - specifies the amplitude of each octave relative to the one below it
simplex_lacunarity: 2.0 # Lacunarity of simplex noise (1.0 to inf, def 2.0) - specifies the frequency of each octave relative to the one below it
winddir_avg: 0.0 # Average wind direction in degrees rel to plane (0 is CW)
winddir_std: 10 # Standard deviation of wind direction in degrees
timestamp: "2022-09-26 02:03:00"
flight_time_seconds: 1000
sample_frequency: 1
start_coords:
- 54.87667
- 15.41
transect_azimuth: 260 # the wind will start off 90 degrees CW to this azimuth, and is modified relative to that by the winddir_avg. 260 is a good value to test N problems
sampling_altitude_ato_range:
- -10 # negative values should be fine
- 100
sampling_horizontal_range:
- 50
- 950
scene_altitude_range:
- -20
- 120
scene_horizontal_range:
- 0
- 1000
number_of_transects: 10
gases:
ch4:
- 1.95
- 10.0
co2:
- 380.0
- 500.0
c2h6:
- 0.0
- 1.0
temperature: 10.0
pressure: 1000.0
output_dir: ~/gasflux_reports
algorithmic_baseline_settings:
algorithm: fastchrom
semivariogram_settings:
model: spherical
estimator: cressie
n_lags: 20
bin_func: even
fit_method: lm
maxlag: 100
#fit_sigma: linear
tolerance: 10
azimuth: 0
bandwidth: 20
ordinary_kriging_settings:
min_points: 3
max_points: 100
grid_resolution: 500
min_nodes: 10
cut_ground: False
y_min: ~
required_cols:
latitude: [-90, 90]
longitude: [-180, 180]
height_ato: [-100, 500]
windspeed: [0, 30]
winddir: [0, 360]
temperature: [-50, 60]
pressure: [900, 1100]
filters:
course_filter:
azimuth_filter: 10
azimuth_window: 5
elevation_filter: 5
strategies:
background: "algorithm"
sensor: "insitu"
spatial: "curtain"
interpolation: "kriging"

1001
src/gasflux/testdata/testdata.csv vendored Normal file

File diff suppressed because it is too large Load Diff