#!/usr/bin/env python # -*- coding: utf-8 -*- """ 测试部位温度记录表生成脚本 - 忽略传入的 experimentProcess,自行构造固定结构的数据 - 从 InfluxDB 查询每个测试部位在各时间点的瞬时温度值 - 输出格式与应用中的 scriptTable 占位符兼容 - 默认把 {scriptTable1} 放在“测试部位”所在的单元格 环境变量: TABLE_TOKEN 目标占位符,默认 scriptTable1 TABLE_START_ROW 写入起始行偏移,默认 0 TABLE_START_COL 写入起始列偏移,默认 0 TABLE_TIME_SLOTS 逗号分隔的时间刻度,默认 "0.5h,1h,1.5h,2h,2.5h,3h,3.5h" TABLE_MOTOR_SPEED 电机转速标签,默认 "980RPM" EXPERIMENT_START 实验开始时间(ISO 8601 格式,如 2024-01-01T10:00:00Z) EXPERIMENT_END 实验结束时间(ISO 8601 格式) INFLUX_URL InfluxDB URL INFLUX_ORG InfluxDB 组织 INFLUX_TOKEN InfluxDB 访问令牌 INFLUX_BUCKET InfluxDB bucket 名称 INFLUX_MEASUREMENT InfluxDB measurement 名称 """ from __future__ import annotations import json import logging import os import sys from datetime import datetime, timedelta from typing import Any, Dict, List, Optional LOGGER = logging.getLogger(__name__) def _mask_secret(value: Optional[str]) -> str: if value is None: return "" value = value.strip() if not value: return "" if len(value) <= 4: return "****" return f"{value[:4]}****" def _log_environment_variables() -> None: env_keys = [ "TABLE_TOKEN", "TABLE_START_ROW", "TABLE_START_COL", "TABLE_TIME_SLOTS", "TABLE_MOTOR_SPEED", "TABLE_LOG_LEVEL", "TABLE_LOG_FILE", "EXPERIMENT_START", "EXPERIMENT_END", "INFLUX_URL", "INFLUX_ORG", "INFLUX_TOKEN", "INFLUX_BUCKET", "INFLUX_MEASUREMENT", ] for key in env_keys: raw_value = os.environ.get(key) if key.endswith("TOKEN") or key.endswith("PASSWORD"): display_value = _mask_secret(raw_value) else: display_value = raw_value if raw_value is not None else "" LOGGER.info("ENV %s = %s", key, display_value) def _read_all_stdin() -> str: try: if sys.stdin and not sys.stdin.closed and not sys.stdin.isatty(): return sys.stdin.read() except Exception: pass return "" def _load_payload() -> Dict[str, Any]: raw = _read_all_stdin().strip() if not raw and len(sys.argv) > 1: arg = sys.argv[1] if os.path.exists(arg) and os.path.isfile(arg): with open(arg, "r", encoding="utf-8") as fh: raw = fh.read() else: raw = arg if not raw: raw = os.environ.get("EXPERIMENT_JSON", "").strip() if not raw: raw = "{}" data = json.loads(raw) if not isinstance(data, dict): raise ValueError("experiment JSON must be a dict") return data def _time_slots() -> List[str]: raw = os.environ.get("TABLE_TIME_SLOTS", "").strip() if not raw: # 根据图片,时间刻度是:0.5h, 1h, 1.5h, 2h, 2.5h, 3h, 3.5h(7列) return ["0.5h", "1h", "1.5h", "2h", "2.5h", "3h", "3.5h"] slots = [slot.strip() for slot in raw.split(",")] return [slot for slot in slots if slot] def _default_sections() -> List[Dict[str, Any]]: # name -> rows underneath(entries) # 每个 entry 对应一个测试部位,需要映射到 InfluxDB 的 field 或 tag return [ {"name": "主轴承", "entries": [ {"label": "#1", "field": "主轴承#1", "filters": {"data_type": "LSDAQ"}, "result_key": "主轴承#1"}, {"label": "#2", "field": "主轴承#2", "filters": {"data_type": "LSDAQ"}, "result_key": "主轴承#2"}, {"label": "#3", "field": "主轴承#3", "filters": {"data_type": "LSDAQ"}, "result_key": "主轴承#3"}, {"label": "#4", "field": "主轴承#4", "filters": {"data_type": "LSDAQ"}, "result_key": "主轴承#4"}, {"label": "#5", "field": "主轴承#5", "filters": {"data_type": "LSDAQ"}, "result_key": "主轴承#5"}, {"label": "#6", "field": "主轴承#6", "filters": {"data_type": "LSDAQ"}, "result_key": "主轴承#6"}, ]}, {"name": "十字头", "entries": [ {"label": "#1", "field": "十字头#1", "filters": {"data_type": "LSDAQ"}, "result_key": "十字头#1"}, {"label": "#2", "field": "十字头#2", "filters": {"data_type": "LSDAQ"}, "result_key": "十字头#2"}, {"label": "#3", "field": "十字头#3", "filters": {"data_type": "LSDAQ"}, "result_key": "十字头#3"}, {"label": "#4", "field": "十字头#4", "filters": {"data_type": "LSDAQ"}, "result_key": "十字头#4"}, {"label": "#5", "field": "十字头#5", "filters": {"data_type": "LSDAQ"}, "result_key": "十字头#5"}, ]}, {"name": "减速箱小轴承", "entries": [ {"label": "#1(输入法兰端)", "field": "减速箱小轴承1", "filters": {"data_type": "LSDAQ"}, "result_key": "减速箱小轴承#1"}, {"label": "#2", "field": "减速箱小轴承#2", "filters": {"data_type": "LSDAQ"}, "result_key": "减速箱小轴承#2"}, ]}, {"name": "减速箱大轴承", "entries": [ {"label": "#3(大端盖端)", "field": "减速箱大轴承#3", "filters": {"data_type": "LSDAQ"}, "result_key": "减速箱大轴承#3"}, {"label": "#4", "field": "减速箱大轴承#4", "filters": {"data_type": "LSDAQ"}, "result_key": "减速箱大轴承#4"}, ]}, {"name": "润滑油温", "entries": [ {"label": "", "field": "mean", "filters": {"data_type": "润滑油温"}, "result_key": "润滑油温"}, ]}, {"name": "润滑油压", "entries": [ {"label": "(Psi)", "field": "mean", "filters": {"data_type": "润滑油压"}, "result_key": "润滑油压"}, ]}, ] def _parse_time_slot(slot_str: str) -> float: """解析时间刻度字符串(如 '0.5h', '1h')为小时数""" slot_str = slot_str.strip().lower() if slot_str.endswith('h'): try: return float(slot_str[:-1]) except ValueError: pass return 0.0 def _query_influxdb( field_name: str, start_time: datetime, end_time: datetime, influx_url: str, influx_org: str, influx_token: str, influx_bucket: str, influx_measurement: str, filters: Optional[Dict[str, str]] = None, ) -> Optional[float]: """查询 InfluxDB 获取指定字段在指定时间点的瞬时值""" try: from influxdb_client import InfluxDBClient import pandas as pd import warnings from influxdb_client.client.warnings import MissingPivotFunction except ImportError: LOGGER.warning("InfluxDB client not available, skip query for field=%s", field_name) return None try: client = InfluxDBClient(url=influx_url, org=influx_org, token=influx_token) query_api = client.query_api() # 构建 Flux 查询 start_rfc3339 = start_time.strftime('%Y-%m-%dT%H:%M:%SZ') stop_rfc3339 = end_time.strftime('%Y-%m-%dT%H:%M:%SZ') tag_filters = "" if filters: for key, value in filters.items(): tag_filters += f'\n |> filter(fn: (r) => r["{key}"] == "{value}")' LOGGER.debug( "Querying field=%s measurement=%s target_time=%s filters=%s", field_name, influx_measurement, end_time.strftime('%Y-%m-%dT%H:%M:%SZ'), # 使用end_time作为目标时间点 filters or {}, ) # 修改查询逻辑:查询目标时间点附近的最近一个数据点 # 使用一个小的时间窗口(比如前后5分钟)来查找最接近的数据点 target_time = end_time window_minutes = 5 # 前后5分钟的窗口 query_start = target_time - timedelta(minutes=window_minutes) query_end = target_time + timedelta(minutes=window_minutes) query_start_rfc = query_start.strftime('%Y-%m-%dT%H:%M:%SZ') query_end_rfc = query_end.strftime('%Y-%m-%dT%H:%M:%SZ') flux = f''' from(bucket: "{influx_bucket}") |> range(start: {query_start_rfc}, stop: {query_end_rfc}) |> filter(fn: (r) => r["_measurement"] == "{influx_measurement}") |> filter(fn: (r) => r["_field"] == "{field_name}") |> filter(fn: (r) => true){tag_filters} |> sort(columns: ["_time"]) |> last() |> yield(name: "instantaneous") '''.strip() with warnings.catch_warnings(): warnings.simplefilter("ignore", MissingPivotFunction) frames = query_api.query_data_frame(flux) if isinstance(frames, list): df = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame() else: df = frames # 获取瞬时值(最近的一个数据点) if df.empty or '_value' not in df.columns: LOGGER.debug("No instantaneous value found for field=%s", field_name) return None # 取第一行的值(因为查询已经排序并取了last()) instant_value = df['_value'].iloc[0] if pd.isna(instant_value): LOGGER.debug("Instantaneous value is NaN for field=%s", field_name) return None value = float(instant_value) # 如果有时间信息,记录实际的数据时间点 if '_time' in df.columns: actual_time = df['_time'].iloc[0] LOGGER.debug("Field=%s instantaneous_value=%.3f actual_time=%s", field_name, value, actual_time) else: LOGGER.debug("Field=%s instantaneous_value=%.3f", field_name, value) return value except Exception as e: LOGGER.error("Error querying InfluxDB for field=%s: %s", field_name, e) return None finally: try: client.close() except Exception: pass def _get_default_temperature(field_name: str, filters: Optional[Dict[str, Any]] = None) -> float: """为每个测试部位返回默认温度值(用于测试或演示)""" data_type = "" if filters: data_type = str(filters.get("data_type", "")) defaults: Dict[tuple, float] = { ("主轴承#1", ""): 25.5, ("主轴承#2", ""): 26.0, ("主轴承#3", ""): 25.8, ("主轴承#4", ""): 26.2, ("主轴承#5", ""): 25.9, ("主轴承#6", ""): 26.1, ("十字头#1", ""): 28.0, ("十字头#2", ""): 28.2, ("十字头#3", ""): 27.8, ("十字头#4", ""): 28.1, ("十字头#5", ""): 27.9, ("减速箱小轴承#1", ""): 30.0, ("减速箱小轴承#2", ""): 30.2, ("减速箱大轴承#3", ""): 29.5, ("减速箱大轴承#4", ""): 29.8, ("mean", "润滑油温"): 35.0, ("mean", "润滑油压"): 50.0, } return defaults.get((field_name, data_type), 25.0) # 默认25.0度 def _build_cells( time_slots: List[str], sections: List[Dict[str, Any]], motor_speed: str, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, temperature_data: Optional[Dict[str, Dict[str, float]]] = None, use_defaults: bool = False, ) -> List[Dict[str, Any]]: cells: List[Dict[str, Any]] = [] def add_cell(row: int, col: int, value: str = "", rowspan: int = 1, colspan: int = 1) -> None: payload: Dict[str, Any] = {"row": row, "col": col, "value": value} if rowspan > 1: payload["rowspan"] = rowspan if colspan > 1: payload["colspan"] = colspan cells.append(payload) # 模板左侧标题列已经去除,这里仅生成纯数据区,从 (0,0) 开始填入数值。 # current_row 对应模板中的实际数据行索引。 current_row = 0 for section in sections: entries = section.get("entries") or [] if not entries: continue # 每个测试部位子项对应模板中的一行 for entry in entries: # 支持新格式(带 field 映射)和旧格式(纯字符串) if isinstance(entry, dict): field_name = entry.get("field", "") entry_filters = entry.get("filters") entry_key = entry.get("result_key") or field_name else: field_name = "" entry_filters = None entry_key = "" # 仅输出数值列:列索引直接对应时间段 # 强制填充所有列,优先使用查询数据,否则使用默认值 if field_name: # 根据配置决定是否启用默认值 default_base_value: Optional[float] = None if use_defaults: default_base_value = _get_default_temperature(field_name, entry_filters) target_key = entry_key or field_name # 遍历所有时间段列,确保每一列都有数据 for col_idx, slot in enumerate(time_slots): value = None # 优先使用查询到的数据 if temperature_data: slot_data = temperature_data.get(target_key, {}) if slot_data: slot_key = f"{col_idx}_{slot}" value = slot_data.get(slot_key) if value is None and use_defaults and default_base_value is not None: # 使用基础默认值 + 时间段偏移(每个时间段增加0.1度) time_offset = col_idx * 0.1 value = default_base_value + time_offset if value is None: value_str = "" else: # 格式化为字符串(保留1位小数) value_str = f"{value:.1f}" add_cell(current_row, col_idx, value_str) else: # 如果没有字段名,填充空字符串 for col_idx in range(len(time_slots)): add_cell(current_row, col_idx, "") current_row += 1 return cells def _load_temperature_data( time_slots: List[str], sections: List[Dict[str, Any]], start_time: Optional[datetime], end_time: Optional[datetime], ) -> Dict[str, Dict[str, float]]: """从 InfluxDB 查询所有测试部位在各时间点的瞬时温度值""" if not start_time or not end_time: LOGGER.info("Skip data query: missing start/end (%s, %s)", start_time, end_time) return {} influx_url = os.environ.get("INFLUX_URL", "").strip() influx_org = os.environ.get("INFLUX_ORG", "").strip() influx_token = os.environ.get("INFLUX_TOKEN", "").strip() influx_bucket = os.environ.get("INFLUX_BUCKET", "PCM").strip() influx_measurement = os.environ.get("INFLUX_MEASUREMENT", "PCM_Measurement").strip() if not all([influx_url, influx_org, influx_token, influx_bucket, influx_measurement]): LOGGER.warning( "Skip data query: missing Influx config url=%s bucket=%s measurement=%s", influx_url or "", influx_bucket or "", influx_measurement or "", ) return {} # 计算总时长(小时) total_duration = (end_time - start_time).total_seconds() / 3600.0 LOGGER.info( "Fetch instantaneous temperature data window=%s→%s total_hours=%.3f time_points=%s", start_time.isoformat(), end_time.isoformat(), total_duration, ",".join(time_slots), ) # 收集所有需要查询的字段 query_targets: List[Tuple[str, Dict[str, Any]]] = [] for section in sections: entries = section.get("entries") or [] for entry in entries: if isinstance(entry, dict): field_name = entry.get("field", "") if field_name: query_targets.append((field_name, entry)) if not query_targets: return {} # 为每个时间点查询瞬时数据 # 时间刻度采用累计写法(0.5h、1h、1.5h…),每个时间点查询对应时刻的瞬时值 temperature_data: Dict[str, Dict[str, float]] = {} # 计算每个时间点的偏移(小时) # 0.5h表示实验开始后0.5小时的时间点,1h表示实验开始后1小时的时间点,以此类推 slot_durations = [_parse_time_slot(slot) for slot in time_slots] positive_slot_durations = [hours for hours in slot_durations if hours > 0] max_slot_hours = max(positive_slot_durations) if positive_slot_durations else 0.0 force_start_based_window = bool(positive_slot_durations) and (total_duration + 1e-9 < max_slot_hours) if force_start_based_window: LOGGER.debug( "Total duration %.3fh shorter than max slot %.3fh, fall back to start-based windows", total_duration, max_slot_hours, ) prev_slot_end = 0.0 for idx, slot_str in enumerate(time_slots): slot_hours = slot_durations[idx] if idx < len(slot_durations) else _parse_time_slot(slot_str) if slot_hours <= 0: LOGGER.debug("Skip slot index=%d label=%s because parsed hours<=0", idx, slot_str) continue if force_start_based_window: slot_start_offset = prev_slot_end slot_end_offset = slot_hours else: # 限制在实验的有效范围内 slot_end_offset = min(slot_hours, total_duration) slot_start_offset = min(prev_slot_end, total_duration) if slot_end_offset <= slot_start_offset: LOGGER.debug( "Skip slot index=%d label=%s because end_offset<=start_offset (%.3f<=%.3f)", idx, slot_str, slot_end_offset, slot_start_offset, ) prev_slot_end = max(prev_slot_end, slot_end_offset) continue slot_start = start_time + timedelta(hours=slot_start_offset) slot_end = start_time + timedelta(hours=slot_end_offset) if not force_start_based_window: # 确保不超过总结束时间 if slot_start >= end_time: LOGGER.debug( "Skip slot index=%d label=%s start>=end (%s>=%s)", idx, slot_str, slot_start.isoformat(), end_time.isoformat(), ) continue if slot_end > end_time: slot_end = end_time LOGGER.debug( "Slot index=%d label=%s offsets=%.3f→%.3f actual=%s→%s", idx, slot_str, slot_start_offset, slot_end_offset, slot_start.isoformat(), slot_end.isoformat(), ) if force_start_based_window: prev_slot_end = max(prev_slot_end, slot_hours) else: prev_slot_end = max(prev_slot_end, slot_end_offset) # 查询每个字段在当前时间点的瞬时值 # 使用slot_end作为目标时间点(即累计时间点,如0.5h, 1h, 1.5h等) target_time_point = slot_end for field_name, entry in query_targets: result_key = entry.get("result_key") or field_name if not result_key: result_key = field_name entry_filters = entry.get("filters") if isinstance(entry, dict) else None if result_key not in temperature_data: temperature_data[result_key] = {} # 使用索引作为key,因为可能有重复的时间刻度 slot_key = f"{idx}_{slot_str}" # 使用索引+时间刻度作为唯一key # 查询瞬时值:传入相同的时间点作为start和end,函数内部会处理为查找最近的数据点 value = _query_influxdb( field_name, target_time_point, # start_time参数(函数内部不使用) target_time_point, # end_time参数(作为目标时间点) influx_url, influx_org, influx_token, influx_bucket, influx_measurement, filters=entry_filters if entry_filters else None, ) if value is not None: temperature_data[result_key][slot_key] = value LOGGER.debug( "Field=%s result_key=%s slot=%s value=%.3f", field_name, result_key, slot_key, value, ) else: LOGGER.debug( "Field=%s result_key=%s slot=%s yielded no data", field_name, result_key, slot_key, ) prev_slot_end = slot_end_offset return temperature_data def build_temperature_table(_: Dict[str, Any]) -> Dict[str, Any]: token = os.environ.get("TABLE_TOKEN", "scriptTable1") row_offset = int(os.environ.get("TABLE_START_ROW", "0") or 0) col_offset = int(os.environ.get("TABLE_START_COL", "0") or 0) motor_speed = os.environ.get("TABLE_MOTOR_SPEED", "980RPM") # 解析实验时间范围 start_str = os.environ.get("EXPERIMENT_START", "").strip() end_str = os.environ.get("EXPERIMENT_END", "").strip() start_time: Optional[datetime] = None end_time: Optional[datetime] = None if start_str: try: # 支持多种时间格式 for fmt in ["%Y-%m-%dT%H:%M:%SZ"]: try: start_time = datetime.strptime(start_str, fmt).astimezone(tz=None) break except ValueError: continue except Exception as e: print(f"Warning: Failed to parse EXPERIMENT_START '{start_str}': {e}", file=sys.stderr) if end_str: try: for fmt in ["%Y-%m-%dT%H:%M:%SZ"]: try: end_time = datetime.strptime(end_str, fmt).astimezone(tz=None) break except ValueError: continue except Exception as e: print(f"Warning: Failed to parse EXPERIMENT_END '{end_str}': {e}", file=sys.stderr) time_slots = _time_slots() sections = _default_sections() # 查询温度数据 temperature_data = _load_temperature_data(time_slots, sections, start_time, end_time) # 始终禁止默认数据,保证查询不到值时保持空白 use_defaults = False cells = _build_cells( time_slots, sections, motor_speed, start_time, end_time, temperature_data, use_defaults=use_defaults ) for cell in cells: cell["row"] += 4 start_time_row = 1 start_time_value_col = 1 end_time_value_col = 3 utc_aware_dt = datetime.strptime(start_str, "%Y-%m-%dT%H:%M:%S%z") local_dt1 = utc_aware_dt.astimezone(tz=None) local_dt2 = utc_aware_dt.astimezone(tz=None) + timedelta(hours=3.5) start_time_value = local_dt1.strftime("%Y-%m-%d %H:%M:%S") end_time_value = local_dt2.strftime("%Y-%m-%d %H:%M:%S") cells.append({"row": start_time_row, "col": start_time_value_col, "value": start_time_value}) cells.append({"row": start_time_row, "col": end_time_value_col, "value": end_time_value}) influx_url = os.environ.get("INFLUX_URL", "").strip() influx_org = os.environ.get("INFLUX_ORG", "").strip() influx_token = os.environ.get("INFLUX_TOKEN", "").strip() influx_bucket = os.environ.get("INFLUX_BUCKET", "PCM").strip() influx_measurement = os.environ.get("INFLUX_MEASUREMENT", "PCM_Measurement").strip() value = _query_influxdb( "环境温度", start_time, end_time, influx_url, influx_org, influx_token, influx_bucket, influx_measurement, filters={"data_type": "LSDAQ"}, ) # 确保value不是None,避免Word COM操作异常 if value is not None: cells.append({"row": 0, "col": 1, "value": f"{value:.1f}"}) else: cells.append({"row": 0, "col": 1, "value": ""}) return { "token": token, "startRow": row_offset, "startCol": col_offset, "cells": cells, } def main() -> int: try: try: if not logging.getLogger().handlers: log_level_name = os.environ.get("TABLE_LOG_LEVEL", "DEBUG").strip() or "DEBUG" log_level = getattr(logging, log_level_name.upper(), logging.DEBUG) log_file_raw = os.environ.get("TABLE_LOG_FILE", "test.log").strip() or "test.log" log_file = os.path.abspath(log_file_raw) logging.basicConfig( level=log_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=[ logging.FileHandler(log_file, encoding="utf-8"), logging.StreamHandler(sys.stderr), ], ) LOGGER.info("Logging initialized -> file=%s level=%s", log_file, logging.getLevelName(log_level)) _log_environment_variables() sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] except Exception: pass payload = _load_payload() table_spec = build_temperature_table(payload) result = {"tables": [table_spec]} print(json.dumps(result, ensure_ascii=False)) return 0 except Exception as exc: print(f"error: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())