常规更新
This commit is contained in:
@ -12,122 +12,122 @@ from flask_cors import CORS
|
||||
from werkzeug.utils import secure_filename
|
||||
import yaml
|
||||
|
||||
# Shared utilities imported from shared.py
|
||||
# 从 shared.py 导入共享工具
|
||||
try:
|
||||
# Try relative import (when run as part of package)
|
||||
# 尝试相对导入(作为包的一部分运行时)
|
||||
from .shared import TASK_STATUS_PENDING, TASK_STATUS_PROCESSING, TASK_STATUS_COMPLETED, TASK_STATUS_FAILED, update_task_status as shared_update_task_status
|
||||
except ImportError:
|
||||
# Fallback to absolute import (when run directly)
|
||||
# 回退到绝对导入(直接运行时)
|
||||
from shared import TASK_STATUS_PENDING, TASK_STATUS_PROCESSING, TASK_STATUS_COMPLETED, TASK_STATUS_FAILED, update_task_status as shared_update_task_status
|
||||
|
||||
# Load configuration from INI file
|
||||
# 从 INI 文件加载配置
|
||||
try:
|
||||
from .config_reader import config_reader
|
||||
except ImportError:
|
||||
from config_reader import config_reader
|
||||
|
||||
# Blueprints will be imported after app initialization to avoid circular imports
|
||||
# 蓝图将在应用初始化后导入,以避免循环导入
|
||||
|
||||
# Environment-based configuration management
|
||||
# 基于环境的配置管理
|
||||
class Config:
|
||||
"""Configuration management using INI file with environment variable fallbacks."""
|
||||
"""使用 INI 文件并支持环境变量回退的配置管理"""
|
||||
|
||||
# Server configuration from config_reader
|
||||
# 从 config_reader 获取服务器配置
|
||||
HOST = config_reader.host
|
||||
PORT = config_reader.port
|
||||
DEBUG = config_reader.debug
|
||||
BASE_URL = config_reader.base_url
|
||||
|
||||
# Directory configuration
|
||||
BASE_DIR = None # Will be set dynamically
|
||||
# 目录配置
|
||||
BASE_DIR = None # 将动态设置
|
||||
UPLOAD_FOLDER_NAME = str(config_reader.uploads_path)
|
||||
OUTPUT_FOLDER_NAME = str(config_reader.outputs_path)
|
||||
|
||||
# File size limits (in bytes)
|
||||
# 文件大小限制(字节)
|
||||
MAX_CONTENT_LENGTH = config_reader.max_content_length
|
||||
|
||||
# Logging configuration
|
||||
# 日志配置
|
||||
LOG_LEVEL = config_reader.log_level.upper()
|
||||
LOG_FILE = config_reader.log_file
|
||||
|
||||
# CORS configuration (keeping environment fallback for now)
|
||||
# CORS 配置(暂时保留环境回退)
|
||||
CORS_ORIGINS = os.getenv('GASFLUX_CORS_ORIGINS', '*').split(',')
|
||||
|
||||
# Task management
|
||||
# 任务管理
|
||||
TASK_CLEANUP_INTERVAL = config_reader.task_cleanup_interval
|
||||
MAX_TASK_AGE = config_reader.max_task_age
|
||||
SUCCESSFUL_TASK_CLEANUP_AGE = config_reader.successful_task_cleanup_age
|
||||
FAILED_TASK_CLEANUP_AGE = config_reader.failed_task_cleanup_age
|
||||
|
||||
# Performance tuning
|
||||
# 性能调优
|
||||
THREADS = config_reader.threads
|
||||
CONNECTION_LIMIT = config_reader.connection_limit
|
||||
CHANNEL_TIMEOUT = config_reader.channel_timeout
|
||||
|
||||
# Database configuration
|
||||
# 数据库配置
|
||||
DB_PATH = config_reader.db_path if config_reader.db_path else None
|
||||
|
||||
# Persistence backend
|
||||
# 持久化后端
|
||||
TASK_PERSIST_BACKEND = config_reader.persist_backend
|
||||
|
||||
# Janitor configuration
|
||||
# 清理工具配置
|
||||
JANITOR_DRY_RUN = config_reader.janitor_dry_run
|
||||
|
||||
# Admin bootstrap key
|
||||
# 管理员引导密钥
|
||||
ADMIN_BOOTSTRAP_KEY = config_reader.admin_bootstrap_key
|
||||
|
||||
@classmethod
|
||||
def init_base_dir(cls):
|
||||
"""Initialize base directory based on environment."""
|
||||
"""根据环境初始化基础目录"""
|
||||
try:
|
||||
if getattr(sys, 'frozen', False):
|
||||
# Running in PyInstaller bundle
|
||||
# 在 PyInstaller 打包环境中运行
|
||||
cls.BASE_DIR = Path(sys.executable).parent
|
||||
else:
|
||||
# Running in normal Python environment
|
||||
# 在正常 Python 环境中运行
|
||||
cls.BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
except:
|
||||
# Fallback to current working directory
|
||||
# 回退到当前工作目录
|
||||
cls.BASE_DIR = Path.cwd()
|
||||
|
||||
# Initialize directories based on config
|
||||
# 根据配置初始化目录
|
||||
cls.init_directories()
|
||||
|
||||
@classmethod
|
||||
def init_directories(cls, output_dir=None):
|
||||
"""Initialize upload and output directories from configuration."""
|
||||
# Use paths from config_reader
|
||||
"""从配置初始化上传和输出目录"""
|
||||
# 使用 config_reader 中的路径
|
||||
uploads_path = config_reader.uploads_path
|
||||
outputs_path = config_reader.outputs_path
|
||||
|
||||
# Resolve relative paths to absolute if needed
|
||||
# 如需要,将相对路径解析为绝对路径
|
||||
if not uploads_path.is_absolute():
|
||||
uploads_path = cls.BASE_DIR / uploads_path
|
||||
if not outputs_path.is_absolute():
|
||||
outputs_path = cls.BASE_DIR / outputs_path
|
||||
|
||||
# Set the resolved paths
|
||||
# 设置已解析的路径
|
||||
cls.UPLOAD_FOLDER = uploads_path
|
||||
cls.OUTPUT_FOLDER = outputs_path
|
||||
|
||||
# Create directories
|
||||
# 创建目录
|
||||
cls.UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
|
||||
cls.OUTPUT_FOLDER.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Directories initialized - Upload: {cls.UPLOAD_FOLDER}, Output: {cls.OUTPUT_FOLDER}")
|
||||
|
||||
# For backward compatibility, also set the old-style paths
|
||||
# 为向后兼容,同时设置旧式路径
|
||||
if output_dir:
|
||||
logger.warning("output_dir parameter is deprecated, use gasflux.ini [paths] section instead")
|
||||
|
||||
@classmethod
|
||||
def update_directories_from_config(cls, config_path=None):
|
||||
"""Update directories based on config file. (DEPRECATED: Use gasflux.ini instead)"""
|
||||
"""根据配置文件更新目录(已弃用:请改用 gasflux.ini)"""
|
||||
logger.warning("update_directories_from_config is deprecated. Output directories are now configured via gasflux.ini [paths] section.")
|
||||
# No longer reads output_dir from YAML config - directories are set from INI config in init_directories()
|
||||
# 不再从 YAML 配置读取 output_dir - 目录在 init_directories() 中从 INI 配置设置
|
||||
|
||||
@classmethod
|
||||
def get_log_level(cls):
|
||||
"""Get logging level from string."""
|
||||
"""从字符串获取日志级别"""
|
||||
levels = {
|
||||
'DEBUG': logging.DEBUG,
|
||||
'INFO': logging.INFO,
|
||||
@ -139,7 +139,7 @@ class Config:
|
||||
|
||||
@classmethod
|
||||
def to_dict(cls):
|
||||
"""Return configuration as dictionary for debugging."""
|
||||
"""以字典形式返回配置,用于调试"""
|
||||
return {
|
||||
'host': cls.HOST,
|
||||
'port': cls.PORT,
|
||||
@ -160,12 +160,12 @@ class Config:
|
||||
'channel_timeout': cls.CHANNEL_TIMEOUT
|
||||
}
|
||||
|
||||
# Initialize logging with environment-based configuration
|
||||
# 使用基于环境的配置初始化日志
|
||||
logging.basicConfig(
|
||||
level=Config.get_log_level(),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(), # Console output
|
||||
logging.StreamHandler(), # 控制台输出
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger("gasflux_api")
|
||||
@ -173,7 +173,7 @@ logger.info("Basic logging initialized")
|
||||
|
||||
|
||||
def log_performance(func):
|
||||
"""Decorator to log function performance."""
|
||||
"""用于记录函数性能的装饰器"""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
start_time = time.time()
|
||||
@ -190,24 +190,24 @@ def log_performance(func):
|
||||
raise
|
||||
return wrapper
|
||||
|
||||
# Task status management
|
||||
# Task status constants and storage moved to shared.py
|
||||
# 任务状态管理
|
||||
# 任务状态常量和存储已移至 shared.py
|
||||
|
||||
def update_task_status(task_id, status, message=None, results=None, error=None, output_dir=None):
|
||||
"""Update task status using the shared implementation (writes to SQLite)."""
|
||||
"""使用共享实现更新任务状态(写入 SQLite)"""
|
||||
return shared_update_task_status(task_id, status, message=message, results=results, error=error, output_dir=output_dir)
|
||||
|
||||
|
||||
# Statistics and Monitoring
|
||||
# 统计和监控
|
||||
class APIStatsCollector:
|
||||
"""Collect and manage API statistics."""
|
||||
"""收集和管理 API 统计信息"""
|
||||
|
||||
def __init__(self):
|
||||
self.start_time = time.time()
|
||||
self.reset_stats()
|
||||
|
||||
def reset_stats(self):
|
||||
"""Reset all statistics."""
|
||||
"""重置所有统计信息"""
|
||||
self.stats = {
|
||||
'requests': {
|
||||
'total': 0,
|
||||
@ -238,42 +238,42 @@ class APIStatsCollector:
|
||||
}
|
||||
|
||||
def record_request(self, method, endpoint, status_code, response_time):
|
||||
"""Record an API request."""
|
||||
"""记录 API 请求"""
|
||||
self.stats['requests']['total'] += 1
|
||||
|
||||
# Method stats
|
||||
# 方法统计
|
||||
if method not in self.stats['requests']['by_method']:
|
||||
self.stats['requests']['by_method'][method] = 0
|
||||
self.stats['requests']['by_method'][method] += 1
|
||||
|
||||
# Endpoint stats
|
||||
# 端点统计
|
||||
if endpoint not in self.stats['requests']['by_endpoint']:
|
||||
self.stats['requests']['by_endpoint'][endpoint] = 0
|
||||
self.stats['requests']['by_endpoint'][endpoint] += 1
|
||||
|
||||
# Status stats
|
||||
status_category = str(status_code // 100 * 100) # 200, 400, 500, etc.
|
||||
# 状态统计
|
||||
status_category = str(status_code // 100 * 100) # 200, 400, 500, 等
|
||||
if status_category not in self.stats['requests']['by_status']:
|
||||
self.stats['requests']['by_status'][status_category] = 0
|
||||
self.stats['requests']['by_status'][status_category] += 1
|
||||
|
||||
# Response time stats
|
||||
# 响应时间统计
|
||||
self.stats['requests']['response_times'].append(response_time)
|
||||
|
||||
# Keep only last 1000 response times for memory efficiency
|
||||
# 仅保留最近 1000 个响应时间以节省内存
|
||||
if len(self.stats['requests']['response_times']) > 1000:
|
||||
self.stats['requests']['response_times'] = self.stats['requests']['response_times'][-1000:]
|
||||
|
||||
# Error tracking
|
||||
# 错误跟踪
|
||||
if status_code >= 400:
|
||||
self.stats['requests']['errors'] += 1
|
||||
|
||||
# Update performance stats
|
||||
# 更新性能统计
|
||||
self._update_performance_stats()
|
||||
|
||||
def record_task_status_change(self, old_status, new_status):
|
||||
"""Record task status changes."""
|
||||
if old_status == "unknown": # New task
|
||||
"""记录任务状态变化"""
|
||||
if old_status == "unknown": # 新任务
|
||||
self.stats['tasks']['total_created'] += 1
|
||||
|
||||
if new_status == TASK_STATUS_COMPLETED:
|
||||
@ -281,7 +281,7 @@ class APIStatsCollector:
|
||||
elif new_status == TASK_STATUS_FAILED:
|
||||
self.stats['tasks']['total_failed'] += 1
|
||||
|
||||
# Update status counts
|
||||
# 更新状态计数
|
||||
for status in [old_status, new_status]:
|
||||
if status in self.stats['tasks']['by_status']:
|
||||
if status == old_status and old_status != "unknown":
|
||||
@ -290,15 +290,15 @@ class APIStatsCollector:
|
||||
self.stats['tasks']['by_status'][new_status] += 1
|
||||
|
||||
def record_task_completion_time(self, completion_time):
|
||||
"""Record task completion time."""
|
||||
"""记录任务完成时间"""
|
||||
self.stats['tasks']['processing_times'].append(completion_time)
|
||||
|
||||
# Keep only last 100 processing times
|
||||
# 仅保留最近 100 个处理时间
|
||||
if len(self.stats['tasks']['processing_times']) > 100:
|
||||
self.stats['tasks']['processing_times'] = self.stats['tasks']['processing_times'][-100:]
|
||||
|
||||
def _update_performance_stats(self):
|
||||
"""Update performance statistics."""
|
||||
"""更新性能统计信息"""
|
||||
response_times = self.stats['requests']['response_times']
|
||||
if response_times:
|
||||
self.stats['performance']['avg_response_time'] = sum(response_times) / len(response_times)
|
||||
@ -308,15 +308,15 @@ class APIStatsCollector:
|
||||
self.stats['performance']['uptime_seconds'] = time.time() - self.start_time
|
||||
|
||||
def get_summary(self):
|
||||
"""Get a summary of current statistics."""
|
||||
"""获取当前统计摘要"""
|
||||
current_time = time.time()
|
||||
uptime = current_time - self.start_time
|
||||
|
||||
# Calculate rates
|
||||
# 计算速率
|
||||
requests_per_second = self.stats['requests']['total'] / max(uptime, 1)
|
||||
error_rate = (self.stats['requests']['errors'] / max(self.stats['requests']['total'], 1)) * 100
|
||||
|
||||
# Task completion rate
|
||||
# 任务完成率
|
||||
total_tasks_processed = self.stats['tasks']['total_completed'] + self.stats['tasks']['total_failed']
|
||||
task_success_rate = (self.stats['tasks']['total_completed'] / max(total_tasks_processed, 1)) * 100
|
||||
|
||||
@ -351,7 +351,7 @@ class APIStatsCollector:
|
||||
}
|
||||
|
||||
def _format_uptime(self, seconds):
|
||||
"""Format uptime in human readable format."""
|
||||
"""以人类可读格式格式化运行时间"""
|
||||
days, remainder = divmod(int(seconds), 86400)
|
||||
hours, remainder = divmod(remainder, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
@ -368,15 +368,15 @@ class APIStatsCollector:
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
# Global statistics collector
|
||||
# 全局统计收集器
|
||||
stats_collector = APIStatsCollector()
|
||||
|
||||
# get_task_status moved to shared.py
|
||||
# get_task_status 已移至 shared.py
|
||||
|
||||
# cleanup_old_tasks moved to shared.py
|
||||
# cleanup_old_tasks 已移至 shared.py
|
||||
|
||||
def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
"""Background task to process data asynchronously."""
|
||||
"""异步处理数据的后台任务"""
|
||||
# 确保后台线程里有 Flask 应用上下文
|
||||
with app.app_context():
|
||||
logger.info(f"Job {task_id}: Background processing started for task {task_id}")
|
||||
@ -385,7 +385,7 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
try:
|
||||
update_task_status(task_id, TASK_STATUS_PROCESSING, "开始处理数据...")
|
||||
|
||||
# 1. Load and override config FIRST
|
||||
# 1. 首先加载并覆盖配置
|
||||
logger.info(f"Job {task_id}: Loading configuration from {config_path}")
|
||||
config_start = time.time()
|
||||
|
||||
@ -397,34 +397,34 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
logger.error(f"Job {task_id}: Failed to load config from {config_path}: {str(e)}")
|
||||
raise
|
||||
|
||||
# Update directories based on config output_dir
|
||||
# 根据配置 output_dir 更新目录
|
||||
Config.update_directories_from_config(config_path)
|
||||
|
||||
# Sync app.config with updated directories
|
||||
# 将更新后的目录同步到 app.config
|
||||
app.config['UPLOAD_FOLDER'] = Config.UPLOAD_FOLDER
|
||||
app.config['OUTPUT_FOLDER'] = Config.OUTPUT_FOLDER
|
||||
|
||||
# Task status persistence now uses SQLite only
|
||||
# JSON persistence has been disabled
|
||||
# 任务状态持久化现在仅使用 SQLite
|
||||
# JSON 持久化已禁用
|
||||
# from .shared import set_task_status_file_path, load_task_status_from_file
|
||||
# set_task_status_file_path(Config.OUTPUT_FOLDER / "task_status.json")
|
||||
# load_task_status_from_file()
|
||||
|
||||
# Update job directories to be under the correct config-based paths
|
||||
# 更新任务目录到正确的基于配置的路径下
|
||||
from pathlib import Path
|
||||
job_upload_dir = Path(Config.UPLOAD_FOLDER) / task_id
|
||||
job_output_dir = Path(Config.OUTPUT_FOLDER) / task_id
|
||||
job_upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
job_output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Trigger an update to save output_dir to database
|
||||
# 触发更新将 output_dir 保存到数据库
|
||||
update_task_status(task_id, TASK_STATUS_PROCESSING, "目录已就绪", output_dir=str(job_output_dir))
|
||||
|
||||
# Move uploaded files to the correct config-based directories
|
||||
# 将上传的文件移动到正确的基于配置的目录
|
||||
try:
|
||||
import shutil
|
||||
|
||||
# Move data file to correct uploads directory
|
||||
# 移动数据文件到正确的上传目录
|
||||
if data_path.parent != job_upload_dir:
|
||||
new_data_path = job_upload_dir / data_path.name
|
||||
if data_path != new_data_path:
|
||||
@ -432,7 +432,7 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
data_path = new_data_path
|
||||
logger.info(f"Job {task_id}: Moved data file to {data_path}")
|
||||
|
||||
# Move config file to correct uploads directory (if it's a custom config)
|
||||
# 移动配置文件到正确的上传目录(如果是自定义配置)
|
||||
if config_path.parent != job_upload_dir and config_path.parent != Config.BASE_DIR:
|
||||
new_config_path = job_upload_dir / config_path.name
|
||||
if config_path != new_config_path:
|
||||
@ -451,7 +451,7 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
|
||||
update_task_status(task_id, TASK_STATUS_PROCESSING, "配置已加载,开始预处理...")
|
||||
|
||||
# 2. Data Preprocessing (files are already in correct directories)
|
||||
# 2. 数据预处理(文件已在正确目录中)
|
||||
logger.info(f"Job {task_id}: Starting preprocessing phase...")
|
||||
preprocess_start = time.time()
|
||||
|
||||
@ -465,7 +465,7 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
|
||||
update_task_status(task_id, TASK_STATUS_PROCESSING, "预处理完成,开始GasFlux分析...")
|
||||
|
||||
# Write modified config to a temp file
|
||||
# 将修改后的配置写入临时文件
|
||||
final_config_path = data_path.parent / "final_config.yaml"
|
||||
try:
|
||||
with open(final_config_path, 'w') as f:
|
||||
@ -480,7 +480,7 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
|
||||
update_task_status(task_id, TASK_STATUS_PROCESSING, "配置已加载,开始GasFlux分析...")
|
||||
|
||||
# 3. GasFlux Processing
|
||||
# 3. GasFlux 处理
|
||||
logger.info(f"Job {task_id}: Starting GasFlux analysis...")
|
||||
analysis_start = time.time()
|
||||
|
||||
@ -489,7 +489,7 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
analysis_duration = time.time() - analysis_start
|
||||
logger.info(f"Job {task_id}: GasFlux analysis completed in {analysis_duration:.3f}s")
|
||||
|
||||
# 提取krig_params数据(只保存关键数值)
|
||||
# 提取 krig_params 数据(只保存关键数值)
|
||||
krig_params_data = []
|
||||
if hasattr(processor, 'output_vars') and 'krig_parameters' in processor.output_vars:
|
||||
for gas, params in processor.output_vars['krig_parameters'].items():
|
||||
@ -499,12 +499,12 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
if isinstance(value, (int, float)):
|
||||
clean_params[key] = value
|
||||
elif hasattr(value, 'item') and hasattr(value, 'size'):
|
||||
# numpy数组:只处理单元素数组
|
||||
# numpy 数组:只处理单元素数组
|
||||
if value.size == 1:
|
||||
clean_params[key] = value.item()
|
||||
# 多元素数组跳过,不保存
|
||||
elif hasattr(value, 'item'):
|
||||
# 其他numpy对象尝试转换
|
||||
# 其他 numpy 对象尝试转换
|
||||
try:
|
||||
clean_params[key] = value.item()
|
||||
except ValueError:
|
||||
@ -518,12 +518,12 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
|
||||
update_task_status(task_id, TASK_STATUS_PROCESSING, "GasFlux分析完成,正在生成报告...")
|
||||
|
||||
# Collect results and generate full URLs
|
||||
# 收集结果并生成完整 URL
|
||||
logger.info(f"Job {task_id}: Collecting generated files from {job_output_dir}")
|
||||
results_start = time.time()
|
||||
results = []
|
||||
|
||||
# 先添加krig_params数据
|
||||
# 先添加 krig_params 数据
|
||||
results.extend(krig_params_data)
|
||||
|
||||
try:
|
||||
@ -534,7 +534,7 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
results.append({
|
||||
"name": f.name,
|
||||
"rel_path": rel_path,
|
||||
"download_url": f"/download/{rel_path}", # Relative URL that client can use
|
||||
"download_url": f"/download/{rel_path}", # 客户端可使用的相对 URL
|
||||
"size": file_size
|
||||
})
|
||||
logger.debug(f"Job {task_id}: Found output file: {f.name} ({file_size} bytes)")
|
||||
@ -552,7 +552,7 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
total_duration = time.time() - start_time
|
||||
logger.info(f"Job {task_id}: Processing complete. Total duration: {total_duration:.3f}s, {len(results)} files generated.")
|
||||
|
||||
# Record task completion time for statistics
|
||||
# 为统计记录任务完成时间
|
||||
stats_collector.record_task_completion_time(total_duration)
|
||||
|
||||
update_task_status(task_id, TASK_STATUS_COMPLETED, "处理成功完成", results=results)
|
||||
@ -561,11 +561,11 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
total_duration = time.time() - start_time
|
||||
logger.error(f"Job {task_id}: Processing failed after {total_duration:.3f}s - Error: {str(e)}", exc_info=True)
|
||||
|
||||
# Record failed task processing time for statistics
|
||||
# 为统计记录失败任务处理时间
|
||||
stats_collector.record_task_completion_time(total_duration)
|
||||
logger.error(f"Job {task_id}: Failed task details - Data: {data_path}, Config: {config_path}, Output: {job_output_dir}")
|
||||
|
||||
# Try to capture any partial results
|
||||
# 尝试捕获任何部分结果
|
||||
partial_results = []
|
||||
try:
|
||||
for f in job_output_dir.rglob("*"):
|
||||
@ -574,7 +574,7 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
partial_results.append({
|
||||
"name": f.name,
|
||||
"rel_path": rel_path,
|
||||
"download_url": f"/download/{rel_path}", # Relative URL that client can use
|
||||
"download_url": f"/download/{rel_path}", # 客户端可使用的相对 URL
|
||||
"size": f.stat().st_size,
|
||||
"note": "partial_result"
|
||||
})
|
||||
@ -587,12 +587,12 @@ def process_data_async(task_id, data_path, config_path, job_output_dir):
|
||||
|
||||
update_task_status(task_id, TASK_STATUS_FAILED, error=error_msg, results=partial_results if partial_results else None)
|
||||
|
||||
# Import GasFlux modules
|
||||
# 导入 GasFlux 模块
|
||||
logger.info("Importing GasFlux modules...")
|
||||
import_start = time.time()
|
||||
|
||||
try:
|
||||
# Try absolute imports first (more reliable)
|
||||
# 首先尝试绝对导入(更可靠)
|
||||
from src.gasflux.processing_pipelines import process_main
|
||||
from src.gasflux.data_processor import process_file
|
||||
from src.gasflux.reporting import generate_reports
|
||||
@ -612,62 +612,62 @@ except ImportError as e1:
|
||||
raise ImportError(f"Cannot import GasFlux modules: {e2}")
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app) # Initialize CORS
|
||||
CORS(app) # 初始化 CORS
|
||||
|
||||
# Enhanced logging configuration after app initialization
|
||||
# 应用初始化后的增强日志配置
|
||||
try:
|
||||
log_file_path = Path(Config.LOG_FILE)
|
||||
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create file handler
|
||||
# 创建文件处理器
|
||||
file_handler = logging.FileHandler(log_file_path, encoding='utf-8')
|
||||
file_handler.setLevel(Config.get_log_level())
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
# Add file handler to logger
|
||||
# 将文件处理器添加到日志记录器
|
||||
logger.addHandler(file_handler)
|
||||
logger.info(f"File logging initialized. Log file: {log_file_path.absolute()}")
|
||||
print(f"Log file: {log_file_path.absolute()}") # Also print to console
|
||||
print(f"Log file: {log_file_path.absolute()}") # 同时输出到控制台
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to initialize file logging: {e}")
|
||||
logger.warning(f"Failed to initialize file logging: {e}")
|
||||
|
||||
logger.info("Flask application initialized")
|
||||
|
||||
# Request logging middleware
|
||||
# 请求日志中间件
|
||||
@app.before_request
|
||||
def log_request_info():
|
||||
"""Log incoming request details."""
|
||||
"""记录传入请求详情"""
|
||||
g.start_time = time.time()
|
||||
logger.info(f"REQUEST: {request.method} {request.url} - IP: {request.remote_addr} - User-Agent: {request.headers.get('User-Agent', 'Unknown')}")
|
||||
|
||||
@app.after_request
|
||||
def log_response_info(response):
|
||||
"""Log response details."""
|
||||
"""记录响应详情"""
|
||||
duration = time.time() - g.start_time
|
||||
logger.info(f"RESPONSE: {request.method} {request.url} - Status: {response.status_code} - Duration: {duration:.3f}s")
|
||||
|
||||
# Record statistics
|
||||
# 记录统计信息
|
||||
endpoint = request.url_rule.rule if request.url_rule else request.path
|
||||
stats_collector.record_request(request.method, endpoint, response.status_code, duration)
|
||||
|
||||
return response
|
||||
|
||||
# Initialize configuration from environment variables
|
||||
# 从环境变量初始化配置
|
||||
Config.init_base_dir()
|
||||
|
||||
# Apply configuration to app (directories will be created dynamically based on config)
|
||||
# ALLOWED_DATA_EXTENSIONS and ALLOWED_CONFIG_EXTENSIONS moved to shared.py
|
||||
# 将配置应用到应用(目录将根据配置动态创建)
|
||||
# ALLOWED_DATA_EXTENSIONS 和 ALLOWED_CONFIG_EXTENSIONS 已移至 shared.py
|
||||
|
||||
app.config['MAX_CONTENT_LENGTH'] = Config.MAX_CONTENT_LENGTH
|
||||
app.config['BASE_URL'] = Config.BASE_URL
|
||||
|
||||
# Set upload and output folders from config
|
||||
# 从配置设置上传和输出文件夹
|
||||
app.config['UPLOAD_FOLDER'] = Config.UPLOAD_FOLDER
|
||||
app.config['OUTPUT_FOLDER'] = Config.OUTPUT_FOLDER
|
||||
|
||||
# Database and persistence configuration
|
||||
# 数据库和持久化配置
|
||||
if Config.DB_PATH:
|
||||
app.config['DB_PATH'] = Config.DB_PATH
|
||||
if Config.TASK_PERSIST_BACKEND:
|
||||
@ -676,27 +676,27 @@ app.config['JANITOR_DRY_RUN'] = str(Config.JANITOR_DRY_RUN).lower()
|
||||
if Config.ADMIN_BOOTSTRAP_KEY:
|
||||
app.config['ADMIN_BOOTSTRAP_KEY'] = Config.ADMIN_BOOTSTRAP_KEY
|
||||
|
||||
# Task cleanup configuration
|
||||
# 任务清理配置
|
||||
app.config['SUCCESSFUL_TASK_CLEANUP_AGE'] = Config.SUCCESSFUL_TASK_CLEANUP_AGE
|
||||
app.config['FAILED_TASK_CLEANUP_AGE'] = Config.FAILED_TASK_CLEANUP_AGE
|
||||
app.config['TASK_CLEANUP_INTERVAL'] = Config.TASK_CLEANUP_INTERVAL
|
||||
|
||||
# Debug logging for cleanup configuration
|
||||
# 清理配置的调试日志
|
||||
logger.info(f"App config: FAILED_TASK_CLEANUP_AGE = {app.config['FAILED_TASK_CLEANUP_AGE']}")
|
||||
logger.info(f"App config: TASK_CLEANUP_INTERVAL = {app.config['TASK_CLEANUP_INTERVAL']}")
|
||||
|
||||
# Log current configuration
|
||||
# 记录当前配置
|
||||
logger.info(f"Upload folder: {Config.UPLOAD_FOLDER}")
|
||||
logger.info(f"Output folder: {Config.OUTPUT_FOLDER}")
|
||||
logger.info(f"Configuration: {Config.to_dict()}")
|
||||
|
||||
# Ensure directories exist at startup
|
||||
# 确保启动时目录存在
|
||||
def setup_directories():
|
||||
logger.info("Initializing application directories...")
|
||||
logger.info("正在初始化应用目录...")
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Check if directories already exist
|
||||
# 检查目录是否已存在
|
||||
upload_exists = Config.UPLOAD_FOLDER.exists()
|
||||
output_exists = Config.OUTPUT_FOLDER.exists()
|
||||
|
||||
@ -706,26 +706,26 @@ def setup_directories():
|
||||
duration = time.time() - start_time
|
||||
logger.info(f"Directories initialized in {duration:.3f}s: {Config.UPLOAD_FOLDER} ({'existing' if upload_exists else 'created'}), {Config.OUTPUT_FOLDER} ({'existing' if output_exists else 'created'})")
|
||||
|
||||
# Log directory permissions
|
||||
# 记录目录权限
|
||||
upload_writable = os.access(Config.UPLOAD_FOLDER, os.W_OK)
|
||||
output_writable = os.access(Config.OUTPUT_FOLDER, os.W_OK)
|
||||
logger.info(f"Directory permissions - Upload writable: {upload_writable}, Output writable: {output_writable}")
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
logger.error(f"Failed to create directories after {duration:.3f}s: {e}")
|
||||
logger.error(f"创建目录失败,耗时 {duration:.3f}s: {e}")
|
||||
raise
|
||||
|
||||
# setup_directories() - commented out to avoid creating directories at startup
|
||||
# Directories will be created dynamically based on config when processing tasks
|
||||
# setup_directories() - 已注释掉以避免在启动时创建目录
|
||||
# 目录将在处理任务时根据配置动态创建
|
||||
|
||||
# allowed_file moved to shared.py
|
||||
# allowed_file 已移至 shared.py
|
||||
|
||||
# Initialize database and start background services
|
||||
# 初始化数据库并启动后台服务
|
||||
from .db import init_app as init_db
|
||||
init_db(app)
|
||||
|
||||
# Import blueprints after app initialization to avoid circular imports
|
||||
# 在应用初始化后导入蓝图以避免循环导入
|
||||
from .blueprints.health import health_bp
|
||||
from .blueprints.upload import upload_bp
|
||||
from .blueprints.tasks import tasks_bp
|
||||
@ -737,7 +737,7 @@ from .blueprints.download import download_bp
|
||||
from .blueprints.web import web_bp
|
||||
from .blueprints.api_keys import api_keys_bp
|
||||
|
||||
# Register blueprints
|
||||
# 注册蓝图
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(upload_bp)
|
||||
app.register_blueprint(tasks_bp)
|
||||
@ -749,20 +749,20 @@ app.register_blueprint(download_bp)
|
||||
app.register_blueprint(web_bp)
|
||||
app.register_blueprint(api_keys_bp)
|
||||
|
||||
# Task status persistence now uses SQLite only
|
||||
# JSON persistence has been disabled - functions removed from shared.py
|
||||
# 任务状态持久化现在仅使用 SQLite
|
||||
# JSON 持久化已禁用 - 函数已从 shared.py 中移除
|
||||
|
||||
# Initialize janitor for background cleanup
|
||||
# 初始化清理工具进行后台清理
|
||||
try:
|
||||
from .janitor import start_janitor, reconcile_tasks_on_startup
|
||||
with app.app_context():
|
||||
reconcile_tasks_on_startup()
|
||||
# No longer need to load task status into memory
|
||||
# 不再需要将任务状态加载到内存
|
||||
start_janitor(app)
|
||||
except Exception as e:
|
||||
print(f"⚠ Failed to setup task persistence: {e}")
|
||||
print(f"⚠ 设置任务持久化失败: {e}")
|
||||
|
||||
# _get_file_type and _format_response moved to shared.py
|
||||
# _get_file_type 和 _format_response 已移至 shared.py
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pybaselines as pybs
|
||||
from . import plotting
|
||||
|
||||
# 自定义阈值函数,避免依赖scikit-image
|
||||
def custom_threshold(data):
|
||||
@ -52,11 +51,12 @@ def algorithmic_baseline(
|
||||
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)
|
||||
|
||||
fig = None # plotting disabled
|
||||
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"Positive and negative 95% percentile of baseline: {np.percentile(background, 2.5):.2f} ppm, "
|
||||
f"{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"
|
||||
|
||||
@ -2,11 +2,24 @@
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import skgstat as skg
|
||||
from scipy import integrate
|
||||
import os
|
||||
|
||||
from . import plotting
|
||||
|
||||
# Set matplotlib backend before importing anything that might use it
|
||||
os.environ['MPLBACKEND'] = 'Agg'
|
||||
|
||||
# Import matplotlib and set backend explicitly
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Import scikit-gstat
|
||||
import skgstat as skg
|
||||
|
||||
|
||||
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
|
||||
@ -42,7 +55,6 @@ def ordinary_kriging(
|
||||
):
|
||||
"""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(
|
||||
@ -87,8 +99,11 @@ def ordinary_kriging(
|
||||
# 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)
|
||||
# Plots disabled
|
||||
contour_plot = None
|
||||
grid_plot = None
|
||||
semivariogram_plot = None
|
||||
|
||||
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⁻¹. "
|
||||
@ -107,7 +122,6 @@ def ordinary_kriging(
|
||||
"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
|
||||
|
||||
|
||||
@ -56,6 +56,6 @@ def make_prediction(
|
||||
predictions = model.predict(df[cols_for_model])
|
||||
|
||||
df["predictions"] = predictions
|
||||
fig = plotting.scatter_3d(df)
|
||||
fig = None # plotting disabled
|
||||
|
||||
return df, fig
|
||||
|
||||
@ -1,609 +1,44 @@
|
||||
"""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",
|
||||
}
|
||||
"""
|
||||
Lightweight stub plotting module to disable heavy visualization dependencies.
|
||||
All functions return None so callers can safely check for truthiness.
|
||||
"""
|
||||
|
||||
|
||||
def blank_figure():
|
||||
fig = go.Figure()
|
||||
return fig
|
||||
return None
|
||||
|
||||
|
||||
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_3d(*args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
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 scatter_2d(*args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
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 time_series(*args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
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 background_plotting(*args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
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(*args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
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 outliers(*args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
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 contour_krig(*args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
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 heatmap_krig(*args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
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)
|
||||
def create_kml_file(*args, **kwargs):
|
||||
return None
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
"""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 pyproj import Transformer
|
||||
from .processing import circ_median
|
||||
|
||||
|
||||
@ -37,19 +35,52 @@ def timestamp_from_four_columns(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"))
|
||||
"""
|
||||
Convert WGS84 coordinates to UTM using pyproj.
|
||||
|
||||
return output_df
|
||||
This function replaces the geopandas implementation with a lighter pyproj-based solution.
|
||||
"""
|
||||
# Make a copy to avoid modifying the original DataFrame
|
||||
df = df.copy()
|
||||
|
||||
# Get the average longitude to determine the UTM zone
|
||||
# For simplicity, we'll use the first valid longitude to determine the zone
|
||||
# In production, you might want to use the centroid or handle multiple zones
|
||||
valid_lons = df["longitude"].dropna()
|
||||
if len(valid_lons) == 0:
|
||||
raise ValueError("No valid longitude values found")
|
||||
|
||||
# Calculate UTM zone from longitude
|
||||
# UTM zones are 6 degrees wide, starting from -180
|
||||
lon = valid_lons.iloc[0] # Use first valid longitude
|
||||
zone_number = int((lon + 180) / 6) + 1
|
||||
|
||||
# Determine if it's northern or southern hemisphere
|
||||
# Use first valid latitude
|
||||
valid_lats = df["latitude"].dropna()
|
||||
if len(valid_lats) == 0:
|
||||
raise ValueError("No valid latitude values found")
|
||||
|
||||
lat = valid_lats.iloc[0]
|
||||
hemisphere = 'north' if lat >= 0 else 'south'
|
||||
|
||||
# Create UTM CRS string
|
||||
utm_crs = f"EPSG:326{zone_number:02d}" if hemisphere == 'north' else f"EPSG:327{zone_number:02d}"
|
||||
|
||||
# Create transformer from WGS84 to UTM
|
||||
transformer = Transformer.from_crs("EPSG:4326", utm_crs, always_xy=True)
|
||||
|
||||
# Transform coordinates
|
||||
utm_easting, utm_northing = transformer.transform(
|
||||
df["longitude"].values,
|
||||
df["latitude"].values
|
||||
)
|
||||
|
||||
# Add UTM coordinates to DataFrame
|
||||
df["utm_easting"] = utm_easting
|
||||
df["utm_northing"] = utm_northing
|
||||
|
||||
return df
|
||||
|
||||
|
||||
# add columns for drone course azimuth and elevation
|
||||
@ -97,7 +128,9 @@ def remove_outliers(df: pd.DataFrame, column: str, name: str):
|
||||
iqr = q3 - q1
|
||||
fence_low = q1 - 3 * iqr
|
||||
fence_high = q3 + 3 * iqr
|
||||
fig = plotting.outliers(df[column], fence_high, fence_low)
|
||||
|
||||
fig = None # plotting disabled
|
||||
|
||||
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")
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
"""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
|
||||
@ -134,16 +132,16 @@ def bimodal_elevation(
|
||||
return (mode, -mode)
|
||||
|
||||
|
||||
def height_transect_splitter(df: pd.DataFrame, height_col: str = "height_ato") -> tuple[pd.DataFrame, Figure]:
|
||||
def height_transect_splitter(df: pd.DataFrame, height_col: str = "height_ato") -> tuple[pd.DataFrame, None]:
|
||||
"""
|
||||
Splits the dataset into height-based transects and plots histogram peaks to identify prominent
|
||||
Splits the dataset into height-based transects using 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.
|
||||
tuple: Modified dataframe with transect labels and None (plotting disabled).
|
||||
"""
|
||||
df = df.copy()
|
||||
heights = df[height_col].to_numpy()
|
||||
@ -158,12 +156,8 @@ def height_transect_splitter(df: pd.DataFrame, height_col: str = "height_ato") -
|
||||
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
|
||||
return df, None
|
||||
|
||||
|
||||
def add_transect_azimuth_switches(df: pd.DataFrame, threshold=150, shift=3) -> pd.DataFrame:
|
||||
|
||||
@ -4,7 +4,6 @@ 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
|
||||
@ -124,16 +123,9 @@ 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"]
|
||||
)
|
||||
self.data_processor.figs["scatter_3d"][gas] = None
|
||||
self.data_processor.figs["windrose"] = None
|
||||
self.data_processor.figs["wind_timeseries"] = None
|
||||
|
||||
|
||||
class SpatialProcessingStrategy(ABC):
|
||||
@ -164,15 +156,17 @@ class CurtainSpatialProcessingStrategy(SpatialProcessingStrategy):
|
||||
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},
|
||||
fig = self.data_processor.figs["scatter_3d"].get(gas_name)
|
||||
if fig is not None:
|
||||
fig.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):
|
||||
@ -203,15 +197,17 @@ class SpiralSpatialProcessingStrategy(SpatialProcessingStrategy):
|
||||
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},
|
||||
fig = self.data_processor.figs["scatter_3d"].get(gas_name)
|
||||
if fig is not None:
|
||||
fig.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):
|
||||
|
||||
@ -2,15 +2,12 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import plotly.graph_objects as go
|
||||
from jinja2 import Template
|
||||
from plotly.io import to_html
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import yaml
|
||||
|
||||
import logging
|
||||
from . import plotting
|
||||
|
||||
|
||||
import json
|
||||
@ -21,26 +18,23 @@ 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,
|
||||
wind_fig,
|
||||
background_fig,
|
||||
threed_fig,
|
||||
krig_fig,
|
||||
windrose_fig,
|
||||
) -> str:
|
||||
"""Generate a mass balance report."""
|
||||
"""Generate a mass balance report (plots disabled)."""
|
||||
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()
|
||||
# Plots disabled -> use empty strings
|
||||
plot_htmls = {
|
||||
"3D": "",
|
||||
"krig": "",
|
||||
"windrose": "",
|
||||
"wind": "",
|
||||
"background": "",
|
||||
}
|
||||
|
||||
summary_data = {
|
||||
"Estimated flux": f"{krig_params.get('volume', 0):.3f} kgh⁻¹",
|
||||
|
||||
Reference in New Issue
Block a user