Browse Source

First Commit

Lu Xianghui 4 weeks ago
commit
64370a8e07
9 changed files with 1397 additions and 0 deletions
  1. 5 0
      .gitignore
  2. 24 0
      Dockerfile
  3. 229 0
      README.md
  4. BIN
      __pycache__/main.cpython-310.pyc
  5. 21 0
      config.yaml
  6. 415 0
      main.py
  7. BIN
      points.xlsx
  8. 22 0
      pyproject.toml
  9. 681 0
      能源管理系统API接口文档.md

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+.idea
+.venv
+__pycahe__
+logs
+twps-mes-docking-v2

+ 24 - 0
Dockerfile

@@ -0,0 +1,24 @@
+FROM python:3.10-alpine
+
+WORKDIR /root/workspace/
+COPY ./pyproject.toml ./
+
+RUN MAIN_VERSION=$(cat /etc/alpine-release | cut -d '.' -f 0-2) \
+        && mv /etc/apk/repositories /etc/apk/repositories-bak \
+        && { echo "https://mirrors.aliyun.com/alpine/v${MAIN_VERSION}/main"; \
+        echo "https://mirrors.aliyun.com/alpine/v${MAIN_VERSION}/community"; } >> /etc/apk/repositories \
+        && apk add --update --no-cache tzdata gcc build-base libffi-dev curl && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
+        && mkdir ~/.pip \
+        && printf '[global]\nindex-url=https://mirrors.aliyun.com/pypi/simple/' > ~/.pip/pip.conf \
+        && python3 -m pip install --upgrade pip \
+        && curl -o poetry-install.py -SL https://install.python-poetry.org \
+        && python3 -u poetry-install.py \
+        && rm poetry-install.py \
+        && export PATH="/root/.local/bin:$PATH" \
+        && poetry config virtualenvs.create false --local \
+        && poetry install
+
+COPY . .
+
+
+CMD ["python", "-u", "main.py"]

+ 229 - 0
README.md

@@ -0,0 +1,229 @@
+# JDF Energy Collector
+
+小时能源数据采集程序。程序定时从能源管理系统接口查询上一小时点位数据,通过 `addpointdatum` 接口写入基础数据服务,然后触发 `calc_agg_points_range` 计算聚合接口。
+
+## 数据流程
+
+1. 启动时读取 `config.yaml`。
+2. 启动时读取 `points.xlsx`,建立能源系统 `id` 到平台 `point_id` 的映射。
+3. 每小时在 `schedule.minute` 配置的分钟执行一次。
+4. 调用能源系统登录接口获取 token,默认 6 小时复用一次。
+5. 查询上一小时数据。
+6. 过滤无效数据并组装 `addpointdatum` 请求。
+7. `addpointdatum` 写入成功后,调用 calcagg 回算。
+
+## 环境要求
+
+- Python 3.10+
+- Poetry
+
+依赖见 `pyproject.toml`:
+
+```text
+openpyxl
+PyYAML
+requests
+```
+
+## 安装
+
+```bash
+poetry install
+```
+
+## 配置
+
+默认配置文件为 `config.yaml`。
+
+示例:
+
+```yaml
+energy_chaowang:
+  base_url: "http://127.0.0.1:8089"
+  username: "dt"
+  password: "dt2026"
+  login_timeout_seconds: 5
+  query_timeout_seconds: 10
+  token_refresh_interval_hours: 6
+
+points:
+  file: "points.xlsx"
+
+services:
+  basedataportal: "http://192.168.1.109:18503"
+  calcagg: "http://192.168.1.109:42941"
+
+schedule:
+  minute: 31
+
+logging:
+  file: "logs/jdf-energy-collector.log"
+  retention_days: 3
+```
+
+配置说明:
+
+| 配置项 | 说明 |
+| --- | --- |
+| `energy_chaowang.base_url` | 能源管理系统基础地址 |
+| `energy_chaowang.username` | 登录用户名 |
+| `energy_chaowang.password` | 登录密码 |
+| `energy_chaowang.login_timeout_seconds` | 登录接口超时时间 |
+| `energy_chaowang.query_timeout_seconds` | 小时数据查询接口超时时间 |
+| `energy_chaowang.token_refresh_interval_hours` | token 复用时长,默认 6 小时 |
+| `points.file` | 点位 Excel 文件路径 |
+| `services.basedataportal` | 基础数据服务基础地址,会拼接 `/ai/addpointdatum` |
+| `services.calcagg` | 计算聚合服务基础地址,会拼接 `/api/calcagg/calc_agg_points_range` |
+| `schedule.minute` | 每小时第几分钟执行,范围 0 到 59 |
+| `logging.file` | 日志文件路径 |
+| `logging.retention_days` | 日志保留天数 |
+
+## 点位文件
+
+`points.xlsx` 必须包含两列:
+
+| 列名 | 说明 |
+| --- | --- |
+| `id` | 能源管理系统点位 id,用于查询接口的 `tagNameList` |
+| `point_id` | 平台点位编码,用于 `addpointdatum` 和 calcagg |
+
+程序启动时会校验:
+
+- `id` 不能重复
+- `point_id` 不能重复
+- 至少存在一条有效点位
+
+## 运行
+
+使用默认配置:
+
+```bash
+poetry run python main.py
+```
+
+指定配置文件:
+
+```bash
+poetry run python main.py --config config.yaml
+```
+
+程序为常驻进程,会持续等待下一次调度时间执行。
+
+## 接口调用
+
+### 登录接口
+
+地址:
+
+```text
+{energy_chaowang.base_url}/api/thingshome-admin/sys/service/getToken
+```
+
+登录失败最多重试 3 次,每次间隔 60 秒。
+
+### 小时数据查询接口
+
+地址:
+
+```text
+{energy_chaowang.base_url}/api/thingshome-ems/realTime/selectByCustom
+```
+
+查询范围为上一小时:
+
+- `startTime`: 当前整点减 1 小时
+- `endTime`: 当前整点减 1 秒
+- `dateCode`: `h`
+- `energyTypeList`: `['electric power']`
+
+查询 timeout 时最多重试 3 次,每次间隔 10 秒。
+
+### addpointdatum 写入接口
+
+地址:
+
+```text
+{services.basedataportal}/ai/addpointdatum
+```
+
+请求体示例:
+
+```json
+[
+  {
+    "point_id": "TEST_POINT_DIFF_H",
+    "data": [
+      {
+        "ts": 1781770962,
+        "value": "49.97"
+      }
+    ]
+  }
+]
+```
+
+写入规则:
+
+- `id` 未在 `points.xlsx` 配置时跳过。
+- `fullTime` 或 `thisValue` 缺失时跳过。
+- `thisValue == 0` 时跳过。
+- `fullTime` 转换为秒级时间戳后作为 `ts`。
+- `thisValue` 转为字符串后作为 `value`。
+- 接口返回 `state != 0` 时本轮失败,不继续调用 calcagg。
+
+默认超时为 5 秒。
+
+### calcagg 回算接口
+
+地址:
+
+```text
+{services.calcagg}/api/calcagg/calc_agg_points_range
+```
+
+请求体示例:
+
+```json
+{
+  "end": 1781773199,
+  "begin": 1781769600,
+  "sync_run": true,
+  "point_ids": [
+    "TEST_POINT_DIFF_H"
+  ],
+  "operator_name": "jdf-energy-collector"
+}
+```
+
+calcagg 只会在 `addpointdatum` 成功写入至少一个点位后调用。
+
+## 日志
+
+日志同时输出到控制台和 `logging.file` 配置的文件。
+
+HTTP 请求会打印:
+
+- 请求名称
+- URL
+- timeout
+- headers
+- payload
+- HTTP 状态码
+- 响应体
+- 耗时 `elapsed_ms`
+
+敏感字段会脱敏:
+
+- `authorization`
+- `password`
+- `token`
+
+`addpointdatum` 的完整请求体会打印在日志中。点位较多时,单条日志会比较长。
+
+## 验证
+
+语法检查:
+
+```bash
+python -m py_compile main.py
+```

BIN
__pycache__/main.cpython-310.pyc


+ 21 - 0
config.yaml

@@ -0,0 +1,21 @@
+energy_chaowang:
+  base_url: "http://127.0.0.1:8089"
+  username: "dt"
+  password: "dt2026"
+  login_timeout_seconds: 5
+  query_timeout_seconds: 10
+  token_refresh_interval_hours: 6
+
+points:
+  file: "points.xlsx"
+
+services:
+  basedataportal: "http://192.168.1.109:18503"
+  calcagg: "http://192.168.1.109:42941"
+
+schedule:
+  minute: 31
+
+logging:
+  file: "logs/jdf-energy-collector.log"
+  retention_days: 3

+ 415 - 0
main.py

@@ -0,0 +1,415 @@
+import argparse
+import json
+import logging
+import sys
+import time
+from datetime import datetime, timedelta
+from logging.handlers import TimedRotatingFileHandler
+from pathlib import Path
+
+import openpyxl
+import requests
+import yaml
+
+
+LOGIN_RETRIES = 3
+LOGIN_RETRY_INTERVAL_SECONDS = 60
+QUERY_TIMEOUT_RETRIES = 3
+QUERY_TIMEOUT_RETRY_INTERVAL_SECONDS = 10
+DEFAULT_LOG_FILE = "logs/run.log"
+DEFAULT_LOG_RETENTION_DAYS = 3
+DEFAULT_ADDPOINTDATUM_TIMEOUT_SECONDS = 5
+DEFAULT_CALCAGG_TIMEOUT_SECONDS = 60 * 50  # 50minute
+DEFAULT_TOKEN_REFRESH_INTERVAL_HOURS = 6
+
+LOGGER = logging.getLogger("jdf_energy_collector")
+
+
+def load_config(config_path: str) -> dict:
+    with open(config_path, "r", encoding="utf-8") as file:
+        config = yaml.safe_load(file) or {}
+
+    required_sections = ["energy_chaowang", "points", "services", "schedule"]
+    missing_sections = [section for section in required_sections if section not in config]
+    if missing_sections:
+        raise ValueError(f"配置文件缺少配置段: {', '.join(missing_sections)}")
+
+    return config
+
+
+def setup_logging(config: dict) -> None:
+    log_config = config.get("logging") or {}
+    log_file = Path(log_config.get("file", DEFAULT_LOG_FILE))
+    retention_days = int(log_config.get("retention_days", DEFAULT_LOG_RETENTION_DAYS))
+
+    log_file.parent.mkdir(parents=True, exist_ok=True)
+    formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
+
+    file_handler = TimedRotatingFileHandler(
+        log_file,
+        when="midnight",
+        interval=1,
+        backupCount=retention_days,
+        encoding="utf-8",
+    )
+    file_handler.setFormatter(formatter)
+
+    console_handler = logging.StreamHandler(sys.stdout)
+    console_handler.setFormatter(formatter)
+
+    LOGGER.handlers.clear()
+    LOGGER.setLevel(logging.INFO)
+    LOGGER.addHandler(file_handler)
+    LOGGER.addHandler(console_handler)
+    LOGGER.propagate = False
+
+
+def mask_sensitive(value):
+    if isinstance(value, dict):
+        return {
+            key: "***" if str(key).lower() in {"authorization", "password", "token"} else mask_sensitive(item)
+            for key, item in value.items()
+        }
+    if isinstance(value, list):
+        return [mask_sensitive(item) for item in value]
+    return value
+
+
+def to_log_text(value) -> str:
+    return json.dumps(mask_sensitive(value), ensure_ascii=False, default=str)
+
+
+def post_json(name: str, url: str, payload: dict, headers: dict | None = None, timeout: int | float | None = None):
+    LOGGER.info(
+        "HTTP请求开始: name=%s url=%s timeout=%s headers=%s payload=%s",
+        name,
+        url,
+        timeout,
+        to_log_text(headers or {}),
+        to_log_text(payload),
+    )
+    started_at = time.monotonic()
+
+    try:
+        response = requests.post(url, headers=headers, json=payload, timeout=timeout)
+        elapsed_ms = int((time.monotonic() - started_at) * 1000)
+        try:
+            response_body = response.json()
+        except ValueError:
+            response_body = response.text[:2000]
+
+        LOGGER.info(
+            "HTTP请求结束: name=%s status_code=%s elapsed_ms=%s response=%s",
+            name,
+            response.status_code,
+            elapsed_ms,
+            to_log_text(response_body),
+        )
+        return response
+    except Exception as exc:
+        elapsed_ms = int((time.monotonic() - started_at) * 1000)
+        LOGGER.exception("HTTP请求异常: name=%s elapsed_ms=%s error=%s", name, elapsed_ms, exc)
+        raise
+
+
+def read_points(points_file: str) -> tuple[list[str], dict[str, str]]:
+    workbook = openpyxl.load_workbook(points_file, read_only=True, data_only=True)
+    try:
+        worksheet = workbook.active
+        rows = worksheet.iter_rows(values_only=True)
+
+        try:
+            header = next(rows)
+        except StopIteration as exc:
+            raise ValueError(f"点位文件为空: {points_file}") from exc
+
+        header_index = {str(name).strip(): index for index, name in enumerate(header) if name}
+        if "id" not in header_index or "point_id" not in header_index:
+            raise ValueError("points.xlsx 必须包含 id 和 point_id 两列")
+
+        tag_ids = []
+        id_to_point_id = {}
+        seen_tag_ids = set()
+        seen_point_ids = set()
+        duplicated_tag_ids = set()
+        duplicated_point_ids = set()
+
+        for row in rows:
+            tag_id = row[header_index["id"]]
+            point_id = row[header_index["point_id"]]
+            if tag_id is None or point_id is None:
+                continue
+
+            tag_id = str(tag_id).strip()
+            point_id = str(point_id).strip()
+            if not tag_id or not point_id:
+                continue
+
+            if tag_id in seen_tag_ids:
+                duplicated_tag_ids.add(tag_id)
+            if point_id in seen_point_ids:
+                duplicated_point_ids.add(point_id)
+
+            seen_tag_ids.add(tag_id)
+            seen_point_ids.add(point_id)
+            tag_ids.append(tag_id)
+            id_to_point_id[tag_id] = point_id
+
+        if duplicated_tag_ids:
+            raise ValueError(f"points.xlsx 的 id 列存在重复值: {sorted(duplicated_tag_ids)}")
+        if duplicated_point_ids:
+            raise ValueError(f"points.xlsx 的 point_id 列存在重复值: {sorted(duplicated_point_ids)}")
+        if not tag_ids:
+            raise ValueError(f"点位文件没有有效点位: {points_file}")
+
+        LOGGER.info("点位文件读取完成: file=%s count=%s", points_file, len(tag_ids))
+        return tag_ids, id_to_point_id
+    finally:
+        workbook.close()
+
+
+def load_points_from_config(config: dict) -> tuple[list[str], dict[str, str]]:
+    return read_points(str(Path(config["points"]["file"])))
+
+
+def calculate_hour_range(now: datetime | None = None) -> tuple[datetime, datetime, datetime]:
+    current = now or datetime.now()
+    current_hour = current.replace(minute=0, second=0, microsecond=0)
+    start_time = current_hour - timedelta(hours=1)
+    end_time = current_hour - timedelta(seconds=1)
+    return start_time, end_time, current_hour
+
+
+def format_api_time(value: datetime) -> str:
+    return value.strftime("%Y-%m-%d %H:%M:%S")
+
+
+def local_timestamp(value: datetime) -> int:
+    return int(value.timestamp())
+
+
+def parse_full_time(full_time: str) -> datetime:
+    return datetime.strptime(full_time, "%Y-%m-%d %H:%M:%S")
+
+
+def login(energy_config: dict) -> str:
+    login_url = f"{energy_config['base_url'].rstrip('/')}/api/thingshome-admin/sys/service/getToken"
+    payload = {
+        "username": energy_config["username"],
+        "password": energy_config["password"],
+    }
+    timeout = energy_config.get("login_timeout_seconds", 15)
+
+    for attempt in range(1, LOGIN_RETRIES + 1):
+        try:
+            response = post_json("energy_chaowang_login", login_url, payload, timeout=timeout)
+            response.raise_for_status()
+            result = response.json()
+            if result.get("code") == 0 and result.get("token"):
+                return result["token"]
+
+            LOGGER.warning("登录失败,第 %s 次: %s", attempt, to_log_text(result))
+        except Exception as exc:
+            LOGGER.warning("登录异常,第 %s 次: %s", attempt, exc)
+
+        if attempt < LOGIN_RETRIES:
+            time.sleep(LOGIN_RETRY_INTERVAL_SECONDS)
+
+    raise RuntimeError("登录失败,已达到最大重试次数")
+
+
+class TokenManager:
+    def __init__(self, energy_config: dict):
+        self.energy_config = energy_config
+        self.token = None
+        self.last_login_at = None
+        self.refresh_interval = timedelta(
+            hours=float(energy_config.get("token_refresh_interval_hours", DEFAULT_TOKEN_REFRESH_INTERVAL_HOURS))
+        )
+
+    def get_token(self) -> str:
+        now = datetime.now()
+        if self.token and self.last_login_at and now < self.last_login_at + self.refresh_interval:
+            LOGGER.info("复用登录 Token,下次登录时间: %s", format_api_time(self.last_login_at + self.refresh_interval))
+            return self.token
+
+        LOGGER.info("开始获取登录 Token")
+        self.token = login(self.energy_config)
+        self.last_login_at = datetime.now()
+        LOGGER.info("登录 Token 获取成功,下次登录时间: %s", format_api_time(self.last_login_at + self.refresh_interval))
+        return self.token
+
+
+def query_hour_data(energy_config: dict, token: str, tag_ids: list[str], start_time: datetime, end_time: datetime) -> dict:
+    query_url = f"{energy_config['base_url'].rstrip('/')}/api/thingshome-ems/realTime/selectByCustom"
+    headers = {
+        "Content-Type": "application/json",
+        "token": token,
+    }
+    payload = {
+        "type": "1",
+        "tagNameList": tag_ids,
+        "energyTypeList": ["electric power"],
+        "tb": "0",
+        "startTime": format_api_time(start_time),
+        "endTime": format_api_time(end_time),
+        "dateCode": "h",
+    }
+    timeout = energy_config.get("query_timeout_seconds", 30)
+
+    for attempt in range(1, QUERY_TIMEOUT_RETRIES + 1):
+        try:
+            response = post_json("energy_chaowang_query_hour", query_url, payload, headers=headers, timeout=timeout)
+            response.raise_for_status()
+            result = response.json()
+            if result.get("code") != 0:
+                raise RuntimeError(f"查询接口返回失败: {result}")
+            return result
+        except requests.Timeout as exc:
+            LOGGER.warning("查询接口 timeout,第 %s 次: %s", attempt, exc)
+            if attempt < QUERY_TIMEOUT_RETRIES:
+                time.sleep(QUERY_TIMEOUT_RETRY_INTERVAL_SECONDS)
+
+    raise RuntimeError("查询接口 timeout,已达到最大重试次数")
+
+
+def extract_records(result: dict) -> list[dict]:
+    records = []
+    data_root = result.get("data") or {}
+
+    for point_data in data_root.get("data") or []:
+        records.extend(point_data.get("dataList") or [])
+
+    if records:
+        return records
+
+    return data_root.get("trend") or []
+
+
+def call_addpointdatum(basedataportal_base_url: str, records: list[dict], id_to_point_id: dict[str, str]) -> list[str]:
+    point_ids = []
+    point_id_set = set()
+    data_by_point_id = {}
+
+    for record in records:
+        tag_id = str(record.get("id", "")).strip()
+        point_id = id_to_point_id.get(tag_id)
+        if not point_id:
+            LOGGER.warning("跳过未在 Excel 中配置的点位: %s", tag_id)
+            continue
+
+        name = record.get("name")
+        full_time = record.get("fullTime")
+        this_value = record.get("thisValue")
+        if full_time is None or this_value is None:
+            LOGGER.warning("跳过字段不完整的点位: %s", to_log_text(record))
+            continue
+
+        if this_value == 0:
+            LOGGER.info("跳过 thisValue 为 0 的点位: %s", to_log_text(record))
+            continue
+
+        full_time_ts = local_timestamp(parse_full_time(full_time))
+        LOGGER.info("点位数据: name=%s fullTime=%s thisValue=%s", name, full_time, this_value)
+
+        data_by_point_id.setdefault(point_id, []).append({"ts": full_time_ts, "value": str(this_value)})
+        if point_id not in point_id_set:
+            point_ids.append(point_id)
+            point_id_set.add(point_id)
+
+    if not point_ids:
+        return []
+
+    url = f"{basedataportal_base_url.rstrip('/')}/ai/addpointdatum"
+    payload = [{"point_id": point_id, "data": data_by_point_id[point_id]} for point_id in point_ids]
+    response = post_json(
+        "basedataportal_addpointdatum",
+        url,
+        payload,
+        timeout=DEFAULT_ADDPOINTDATUM_TIMEOUT_SECONDS,
+    )
+    response.raise_for_status()
+    result = response.json()
+    if result.get("state") != 0:
+        raise RuntimeError(f"addpointdatum 接口返回异常: {to_log_text(result)}")
+
+    LOGGER.info("addpointdatum 写入完成: count=%s", len(point_ids))
+    return point_ids
+
+
+def call_calcagg(calcagg_base_url: str, point_ids: list[str], begin: int, end: int) -> None:
+    url = f"{calcagg_base_url.rstrip('/')}/api/calcagg/calc_agg_points_range"
+    payload = {
+        "end": end,
+        "begin": begin,
+        "sync_run": True,
+        "point_ids": point_ids,
+        "operator_name": "jdf-energy-collector",
+    }
+    response = post_json("calcagg_calc_agg_points_range", url, payload, timeout=DEFAULT_CALCAGG_TIMEOUT_SECONDS)
+    response.raise_for_status()
+    result = response.json()
+    if result.get("state") != 0:
+        LOGGER.warning("计算聚合接口返回异常: %s", to_log_text(result))
+
+
+def run_once(config: dict, token_manager: TokenManager, tag_ids: list[str], id_to_point_id: dict[str, str]) -> None:
+    start_time, end_time, current_hour = calculate_hour_range()
+    LOGGER.info(
+        "开始执行小时数据采集: %s - %s, ts:%s-%s",
+        format_api_time(start_time),
+        format_api_time(end_time),
+        start_time,
+        end_time,
+    )
+
+    token = token_manager.get_token()
+    result = query_hour_data(config["energy_chaowang"], token, tag_ids, start_time, end_time)
+    records = extract_records(result)
+
+    written_point_ids = call_addpointdatum(config["services"]["basedataportal"], records, id_to_point_id)
+    if not written_point_ids:
+        LOGGER.info("没有成功写入 addpointdatum 的点位,跳过计算聚合接口调用")
+        return
+
+    begin = local_timestamp(start_time)
+    end = local_timestamp(end_time)
+    call_calcagg(config["services"]["calcagg"], written_point_ids, begin, end)
+    LOGGER.info("本轮采集完成,写入点位数量: %s", len(written_point_ids))
+
+
+def wait_until_next_run(schedule_minute: int) -> None:
+    now = datetime.now()
+    next_run = now.replace(minute=schedule_minute, second=0, microsecond=0)
+    if now > next_run:
+        next_run += timedelta(hours=1)
+
+    sleep_seconds = max(0, (next_run - now).total_seconds())
+    LOGGER.info("下次执行时间: %s", format_api_time(next_run))
+    time.sleep(sleep_seconds)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="小时能源数据采集入库程序")
+    parser.add_argument("--config", default="config.yaml", help="YAML 配置文件路径")
+    args = parser.parse_args()
+
+    config = load_config(args.config)
+    setup_logging(config)
+    tag_ids, id_to_point_id = load_points_from_config(config)
+    token_manager = TokenManager(config["energy_chaowang"])
+
+    schedule_minute = int(config["schedule"].get("minute", 30))
+    if schedule_minute < 0 or schedule_minute > 59:
+        raise ValueError("schedule.minute 必须在 0 到 59 之间")
+
+    while True:
+        wait_until_next_run(schedule_minute)
+        try:
+            run_once(config, token_manager, tag_ids, id_to_point_id)
+        except Exception as exc:
+            LOGGER.exception("本轮采集失败: %s", exc)
+
+
+if __name__ == "__main__":
+    main()

BIN
points.xlsx


+ 22 - 0
pyproject.toml

@@ -0,0 +1,22 @@
+[tool.poetry]
+name = "jdf-energy-collector"
+version = "0.1.0"
+description = ""
+authors = ["Lu Xianghui <xianghui.lu@data-turing.com>"]
+package-mode = false
+
+[tool.poetry.dependencies]
+python = "^3.10"
+openpyxl = "^3.1.5"
+pyyaml = "^6.0.2"
+requests = "^2.32.3"
+
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+
+[[tool.poetry.source]]
+name = "ali"
+url = "https://mirrors.aliyun.com/pypi/simple/"
+priority = "primary"

+ 681 - 0
能源管理系统API接口文档.md

@@ -0,0 +1,681 @@
+# 能源管理系统 API 接口文档
+
+## 1. 文档概述
+
+本文档用于规范能源管理系统后台接口调用标准,为前端开发、第三方对接、接口测试提供统一依据。
+
+适用接口范围包括:
+
+- 账号密码登录并获取授权 Token
+- 按自定义条件查询小时电量数据
+
+## 2. 通用约定
+
+| 项目 | 说明 |
+| --- | --- |
+| 基础服务地址 | `http://192.168.10.180:8089/api` |
+| 请求编码 | `UTF-8` |
+| 默认请求方式 | `POST` |
+| 默认请求头 | `Content-Type: application/json` |
+| 鉴权方式 | 登录接口无需鉴权;业务接口需在请求头携带 `token` |
+
+## 3. 接口分析汇总
+
+| 序号 | 接口 | 路径 | 是否鉴权 | 主要用途 |
+| --- | --- | --- | --- | --- |
+| 1 | 获取授权 Token | `/thingshome-admin/sys/service/getToken` | 否 | 使用账号密码换取全局业务接口 Token |
+| 2 | 查询小时电量数据 | `/thingshome-ems/realTime/selectByCustom` | 是 | 按时间范围、采集点位、能源类型查询小时用电量 |
+
+字段与文档一致性说明:
+
+- `tagNameList` 在参数表中写为 `String`,但请求示例为数组;结合字段名和接口语义,建议按 `Array[String]` 传参。
+- `energyTypeList` 原文写为 `Arrary[String]`,应为 `Array[String]`。
+- 查询接口示例中包含 `type: "1"`,但参数表未说明;建议测试时保留该字段,避免后端依赖。
+- 查询接口返回说明中 `data.dataList.fullTime`、`data.energyValue.thisValue` 结构可能表示 `data` 内含列表对象,实际返回结构需以联调响应为准。
+
+## 4. 获取授权 Token
+
+### 4.1 基础信息
+
+| 项目 | 内容 |
+| --- | --- |
+| 接口路径 | `/thingshome-admin/sys/service/getToken` |
+| 完整地址 | `http://192.168.10.180:8089/api/thingshome-admin/sys/service/getToken` |
+| 请求方式 | `POST` |
+| 是否鉴权 | 否 |
+| 接口用途 | 通过账号密码登录,获取全局接口授权 Token |
+
+### 4.2 请求头
+
+| 参数名 | 是否必填 | 说明 |
+| --- | --- | --- |
+| `Content-Type` | 是 | 固定为 `application/json` |
+
+### 4.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例值 |
+| --- | --- | --- | --- | --- |
+| `username` | String | 是 | 系统后台登录账号 | `dt` |
+| `password` | String | 是 | 系统后台登录密码 | `dt2026` |
+
+### 4.4 请求示例
+
+```json
+{
+  "username": "dt",
+  "password": "dt2026"
+}
+```
+
+### 4.5 返回参数
+
+| 参数名 | 类型 | 说明 |
+| --- | --- | --- |
+| `msg` | String | 操作结果提示 |
+| `code` | Integer | `0` 表示成功,非 `0` 表示失败 |
+| `data` | Object | 固定返回 `null`,无业务数据 |
+| `expire` | Long | Token 有效时长,单位秒,默认 `43200` 秒 |
+| `token` | String | 接口全局授权凭证,业务接口请求头携带 |
+
+### 4.6 成功返回示例
+
+```json
+{
+  "msg": "操作成功",
+  "code": 0,
+  "data": null,
+  "expire": 43200,
+  "token": "3b622f1e7620d97589ba56d4b2ada5f4"
+}
+```
+
+### 4.7 失败返回示例
+
+```json
+{
+  "msg": "账号或密码不正确",
+  "code": 500,
+  "data": null
+}
+```
+
+### 4.8 可执行 curl 测试
+
+```bash
+curl -X POST "http://192.168.10.180:8089/api/thingshome-admin/sys/service/getToken" \
+  -H "Content-Type: application/json" \
+  -d '{"username":"dt","password":"dt2026"}'
+```
+
+### 4.9 可执行 Python 测试
+
+依赖安装:`pip install requests`
+
+```python
+import requests
+
+base_url = "http://192.168.10.180:8089/api"
+url = f"{base_url}/thingshome-admin/sys/service/getToken"
+
+payload = {
+    "username": "dt",
+    "password": "dt2026",
+}
+
+response = requests.post(url, json=payload, timeout=15)
+print("HTTP Status:", response.status_code)
+print(response.text)
+
+response.raise_for_status()
+result = response.json()
+
+if result.get("code") == 0:
+    print("Token:", result.get("token"))
+else:
+    raise RuntimeError(f"登录失败: {result}")
+```
+
+## 5. 自定义条件查询小时电量数据
+
+### 5.1 基础信息
+
+| 项目 | 内容 |
+| --- | --- |
+| 接口路径 | `/thingshome-ems/realTime/selectByCustom` |
+| 完整地址 | `http://192.168.10.180:8089/api/thingshome-ems/realTime/selectByCustom` |
+| 请求方式 | `POST` |
+| 是否鉴权 | 是,请求头必须携带 `token` |
+| 接口用途 | 根据时间范围、采集点位、能源类型查询小时维度用电量,支持同比、环比数据计算 |
+
+### 5.2 请求头
+
+| 参数名 | 类型 | 是否必填 | 说明 |
+| --- | --- | --- | --- |
+| `Content-Type` | String | 是 | 固定为 `application/json` |
+| `token` | String | 是 | 登录接口获取的授权凭证 |
+
+### 5.3 请求参数
+
+| 参数名 | 类型 | 是否必填 | 说明 | 示例值 |
+| --- | --- | --- | --- | --- |
+| `type` | String | 建议传 | 原始示例包含该字段,参数表未说明 | `1` |
+| `tagNameList` | Array[String] | 是 | 设备采集点位 ID 列表 | `["1024a2f19bbe464ab9073b5f5d92eea5"]` |
+| `energyTypeList` | Array[String] | 是 | 能源类型,固定为 `["electric power"]` | `["electric power"]` |
+| `tb` | String | 是 | 固定为 `0` | `0` |
+| `startTime` | String | 是 | 查询开始时间,格式 `yyyy-MM-dd HH:mm:ss` | `2026-07-01 00:00:00` |
+| `endTime` | String | 是 | 查询结束时间,格式 `yyyy-MM-dd HH:mm:ss` | `2026-07-01 23:59:59` |
+| `dateCode` | String | 是 | 小时维度固定为 `h` | `h` |
+
+### 5.4 请求示例
+
+```json
+{
+  "type": "1",
+  "tagNameList": ["1024a2f19bbe464ab9073b5f5d92eea5"],
+  "energyTypeList": ["electric power"],
+  "tb": "0",
+  "startTime": "2026-07-01 00:00:00",
+  "endTime": "2026-07-01 23:59:59",
+  "dateCode": "h"
+}
+```
+
+### 5.5 返回参数
+
+| 参数名 | 类型 | 说明 |
+| --- | --- | --- |
+| `msg` | String | 请求结果提示 |
+| `code` | Integer | 状态码,`0` 表示成功 |
+| `data` | Array/Object | 电量数据结果,实际结构以接口返回为准 |
+| `data.dataList.fullTime` | String | 数据统计小时时间 |
+| `data.energyValue.thisValue` | Double | 当前小时用电量,单位 `kWh` |
+
+### 5.6 可执行 curl 测试
+
+先将登录接口返回的 Token 替换到 `$TOKEN`。
+
+```bash
+export TOKEN="替换为登录接口返回的token"
+
+date && curl -X POST "http://192.168.10.180:8089/api/thingshome-ems/realTime/selectByCustom" \
+  -H "Content-Type: application/json" \
+  -H "token: ${TOKEN}" \
+  -d '{"type":"1","tagNameList":["451a9243-7817-4b75-87df-f1cae52dfa30", "44ca7632-b9a5-47e8-af4b-2fe3971021fc","0a55b5eb-9a7a-401c-954a-9ce4488f5a8b"],"energyTypeList":["electric power"],"tb":"0","startTime":"2026-07-01 15:00:00","endTime":"2026-07-01 18:59:59","dateCode":"h"}'
+```
+
+### 5.7 可执行 Python 测试
+
+依赖安装:`pip install requests`
+
+```python
+import requests
+
+base_url = "http://192.168.10.180:8089/api"
+
+login_url = f"{base_url}/thingshome-admin/sys/service/getToken"
+login_payload = {
+    "username": "dt",
+    "password": "dt2026",
+}
+
+login_response = requests.post(login_url, json=login_payload, timeout=15)
+print("Login HTTP Status:", login_response.status_code)
+print(login_response.text)
+login_response.raise_for_status()
+
+login_result = login_response.json()
+if login_result.get("code") != 0:
+    raise RuntimeError(f"登录失败: {login_result}")
+
+token = login_result["token"]
+
+query_url = f"{base_url}/thingshome-ems/realTime/selectByCustom"
+headers = {
+    "Content-Type": "application/json",
+    "token": token,
+}
+query_payload = {
+    "type": "1",
+    "tagNameList": ["1024a2f19bbe464ab9073b5f5d92eea5"],
+    "energyTypeList": ["electric power"],
+    "tb": "0",
+    "startTime": "2026-07-01 23:30:00",
+    "endTime": "2026-07-01 23:59:59",
+    "dateCode": "h",
+}
+
+query_response = requests.post(query_url, headers=headers, json=query_payload, timeout=30)
+print("Query HTTP Status:", query_response.status_code)
+print(query_response.text)
+query_response.raise_for_status()
+
+query_result = query_response.json()
+if query_result.get("code") != 0:
+    raise RuntimeError(f"查询失败: {query_result}")
+```
+
+### 5.8 响应数据
+
+```
+{
+    "msg": "操作成功",
+    "code": 0,
+    "data": {
+        "unit": "kW·h",
+        "data": [
+            {
+                "name": "全厂",
+                "id": "1024a2f19bbe464ab9073b5f5d92eea5",
+                "parentId": "0",
+                "type": null,
+                "value": 0.0,
+                "tbValue": 0.0,
+                "hbValue": 0.0,
+                "tbCost": 0.0,
+                "hbCost": 0.0,
+                "tbCoal": 0.0,
+                "hbCoal": 0.0,
+                "tbCarbon": 0.0,
+                "hbCarbon": 0.0,
+                "tbCompare": 0.0,
+                "hbCompare": 0.0,
+                "dataList": [
+                    {
+                        "id": "1024a2f19bbe464ab9073b5f5d92eea5",
+                        "costValue": 0.0,
+                        "coalValue": 0.0,
+                        "carbonValue": 0.0,
+                        "hbValue": 0.0,
+                        "tbValue": 0.0,
+                        "hbCarbon": 0.0,
+                        "tbCarbon": 0.0,
+                        "hbCoal": 0.0,
+                        "tbCoal": 0.0,
+                        "hbCost": 0.0,
+                        "tbCost": 0.0,
+                        "sort": null,
+                        "hbCompare": 0.0,
+                        "tbCompare": 0.0,
+                        "name": "全厂",
+                        "unit": "kW·h",
+                        "aggregationType": null,
+                        "fullTime": "2026-07-01 23:00:00",
+                        "forecastValue": null,
+                        "savingValue": null,
+                        "productName": null,
+                        "energyType": "electric power",
+                        "weekTime": null,
+                        "thisValue": 0.0,
+                        "time": "2026-07-01 23",
+                        "unitName": "kW·h",
+                        "acquisitionTime": "2026-07-01 23",
+                        "mean": null,
+                        "up": null,
+                        "low": null,
+                        "std": null,
+                        "cpk": null,
+                        "status": 1,
+                        "paramsList": null,
+                        "ybList": null,
+                        "e": null,
+                        "t": null,
+                        "coalUnit": null,
+                        "carbonUnit": null,
+                        "costUnit": null,
+                        "forestValue": null,
+                        "deviationValue": null,
+                        "forestDeviationValue": null
+                    }
+                ],
+                "unit": "kW·h",
+                "unitName": "kW·h",
+                "costValue": 0.0,
+                "coalValue": 0.0,
+                "carbonValue": 0.0,
+                "sort": 0,
+                "energyType": "electric power",
+                "fullName": "全厂",
+                "accuracy": "0.0",
+                "coalUnit": null,
+                "carbonUnit": null,
+                "costUnit": null
+            }
+        ],
+        "trend": [
+            {
+                "id": "1024a2f19bbe464ab9073b5f5d92eea5",
+                "costValue": 0.0,
+                "coalValue": 0.0,
+                "carbonValue": 0.0,
+                "hbValue": 0.0,
+                "tbValue": 0.0,
+                "hbCarbon": 0.0,
+                "tbCarbon": 0.0,
+                "hbCoal": 0.0,
+                "tbCoal": 0.0,
+                "hbCost": 0.0,
+                "tbCost": 0.0,
+                "sort": null,
+                "hbCompare": 0.0,
+                "tbCompare": 0.0,
+                "name": "全厂",
+                "unit": "kW·h",
+                "aggregationType": null,
+                "fullTime": "2026-07-01 23:00:00",
+                "forecastValue": null,
+                "savingValue": null,
+                "productName": null,
+                "energyType": "electric power",
+                "weekTime": null,
+                "thisValue": 0.0,
+                "time": "2026-07-01 23",
+                "unitName": "kW·h",
+                "acquisitionTime": "2026-07-01 23",
+                "mean": null,
+                "up": null,
+                "low": null,
+                "std": null,
+                "cpk": null,
+                "status": 1,
+                "paramsList": null,
+                "ybList": null,
+                "e": null,
+                "t": null,
+                "coalUnit": null,
+                "carbonUnit": null,
+                "costUnit": null,
+                "forestValue": null,
+                "deviationValue": null,
+                "forestDeviationValue": null
+            }
+        ],
+        "sum": 0.0
+    }
+}
+```
+
+
+
+## 6. 联调建议
+
+- 登录成功后,Token 默认有效期为 `43200` 秒,业务接口测试时优先复用同一个 Token。
+- 若查询接口返回鉴权失败,先重新调用登录接口获取 Token。
+- 若查询结果为空,优先检查 `tagNameList` 点位 ID 是否存在、时间范围内是否有小时电量数据。
+- 若后端对请求头大小写敏感,应保持请求头名称为文档中的 `token`。
+
+## 7. 小时数据采集入库程序
+
+### 7.1 程序目标
+
+使用 Python 编写小时数据采集程序,定时从能源管理系统接口查询上一小时点位数据,通过 `addpointdatum` 接口写入点位数据,并触发计算聚合接口。
+
+已实现脚本:`main.py`。
+
+### 7.2 执行时间规则
+
+程序默认每小时第 `30` 分钟执行一次。
+
+示例:
+
+- `18:30` 执行一次
+- 查询开始时间:`17:00:00`
+- 查询结束时间:`17:59:59`
+- 聚合接口 `begin` 时间戳:`17:00:00`
+- 聚合接口 `end` 时间戳:`18:00:00`
+
+时间戳按当前服务器本地时区转换。
+
+### 7.3 配置文件
+
+程序使用 YAML 配置文件,默认读取 `config.yaml`。
+
+配置示例:
+
+```yaml
+energy_chaowang:
+  base_url: "http://192.168.10.180:8089/api"
+  username: "dt"
+  password: "dt2026"
+  login_timeout_seconds: 15
+  query_timeout_seconds: 30
+  token_refresh_interval_hours: 6
+
+points:
+  file: "points.xlsx"
+
+services:
+  basedataportal: "http://basedataportal-svc:8080"
+  calcagg: "http://127.0.0.1:8080"
+
+schedule:
+  minute: 30
+
+logging:
+  file: "logs/jdf-energy-collector.log"
+  retention_days: 3
+```
+
+字段说明:
+
+| 配置项 | 说明 |
+| --- | --- |
+| `energy_chaowang.base_url` | 能源管理系统接口基础地址 |
+| `energy_chaowang.username` | 登录用户名 |
+| `energy_chaowang.password` | 登录密码 |
+| `energy_chaowang.login_timeout_seconds` | 登录接口超时时间 |
+| `energy_chaowang.query_timeout_seconds` | 小时点位数据查询接口超时时间 |
+| `energy_chaowang.token_refresh_interval_hours` | Token 重新登录间隔,默认 `6` 小时 |
+| `points.file` | 点位 Excel 文件路径 |
+| `services.basedataportal` | 基础数据服务基础地址,例如 `http://basedataportal-svc:8080` |
+| `services.calcagg` | 计算聚合服务基础地址,例如 `http://127.0.0.1:8080` |
+| `schedule.minute` | 每小时执行分钟,默认 `30` |
+| `logging.file` | 日志文件路径 |
+| `logging.retention_days` | 日志保留天数,默认 `3` 天 |
+
+### 7.4 点位来源
+
+点位文件为 `points.xlsx`。
+
+点位文件只在程序启动时读取一次,后续每次 `run_once` 直接使用内存中的点位映射。
+
+启动时会校验:
+
+- `id` 列不能有重复值
+- `point_id` 列不能有重复值
+- 若存在重复值,程序直接退出,不进入常驻调度
+
+使用字段:
+
+| Excel 列名 | 用途 |
+| --- | --- |
+| `id` | 作为能源管理系统查询接口 `tagNameList` 的内容 |
+| `point_id` | 作为 `addpointdatum` 和聚合接口 `point_ids` 的内容 |
+
+Excel 示例:
+
+| id | point_id |
+| --- | --- |
+| `b541250d-23a8-432c-863a-e6807c70da0f` | `P_TEST_1` |
+| `0c1867f8-cc95-46a3-a3f4-ff56b0ffc3bf` | `P_TEST_2` |
+
+### 7.5 登录接口调用规则
+
+程序启动后首次查询小时点位数据前,需要先调用登录接口获取 Token。Token 保存在内存中,后续查询复用该 Token。
+
+登录刷新规则:
+
+- 默认每 `6` 小时重新调用一次登录接口
+- 可通过 `energy_chaowang.token_refresh_interval_hours` 配置
+- 示例:上次登录接口请求时间为 `10:00`,下次登录接口请求时间为 `16:00`
+
+登录重试规则:
+
+- 若登录失败,等待 `60` 秒后重试
+- 最多尝试 `3` 次
+- `3` 次全部失败后,本轮任务终止
+
+### 7.6 小时点位数据查询规则
+
+请求体中的 `tagNameList` 使用 `points.xlsx` 的 `id` 列。
+
+请求体示例:
+
+```json
+{
+  "type": "1",
+  "tagNameList": [
+    "points.xlsx 中的 id"
+  ],
+  "energyTypeList": [
+    "electric power"
+  ],
+  "tb": "0",
+  "startTime": "2026-07-03 17:00:00",
+  "endTime": "2026-07-03 17:59:59",
+  "dateCode": "h"
+}
+```
+
+查询接口重试规则:
+
+- 仅当请求出现 `timeout` 时重试
+- 每次 `timeout` 后等待 `10` 秒
+- 最多尝试 `3` 次
+- 只要任意一次成功,即继续后续 `addpointdatum` 写入和聚合调用
+
+每次调用 HTTP 接口前后都会写入日志,日志包含请求接口、请求参数、响应内容、HTTP 状态码和耗时 `elapsed_ms`。敏感字段如 `password`、`token` 会脱敏。
+
+### 7.7 响应数据解析规则
+
+优先从响应数据中解析:
+
+```text
+data.data[*].dataList[*]
+```
+
+若该结构为空,则尝试读取:
+
+```text
+data.trend[*]
+```
+
+需要获取字段:
+
+| 字段 | 用途 |
+| --- | --- |
+| `id` | 匹配 Excel 中的 `id` |
+| `name` | 打印输出 |
+| `fullTime` | 打印输出,并转换为服务器本地时区时间戳 |
+| `thisValue` | 打印输出;非 `0` 时作为 `addpointdatum` 的 `value` 写入 |
+
+打印内容包含:
+
+```text
+name, fullTime, thisValue
+```
+
+### 7.8 addpointdatum 写入规则
+
+接口地址拼接规则:
+
+```text
+{services.basedataportal}/ai/addpointdatum
+```
+
+请求体为数组,每个元素对应一个 `point_id`。
+
+请求体示例:
+
+```json
+[
+  {
+    "point_id": "Excel中的point_id",
+    "data": [
+      {
+        "ts": 1781770962,
+        "value": "49.97"
+      }
+    ]
+  }
+]
+```
+
+字段说明:
+
+| 字段 | 说明 |
+| --- | --- |
+| `point_id` | 来自 `points.xlsx` 的 `point_id` 列 |
+| `data[].ts` | 接口响应中的 `fullTime` 按服务器本地时区转换后的秒级时间戳 |
+| `data[].value` | 接口响应中的 `thisValue`,按字符串写入 |
+
+写入方式:按点位聚合为一个数组后,一次性调用 `addpointdatum` 接口。
+
+若 `thisValue` 为 `0` 或 `0.0`,该点位跳过写入,并记录当前遍历对象内容。
+
+若 `addpointdatum` 响应中的 `state != 0`,本轮任务失败,不继续调用计算聚合接口。
+
+### 7.9 计算聚合接口调用规则
+
+`addpointdatum` 写入成功后,调用计算聚合接口。
+
+接口地址拼接规则:
+
+```text
+{services.calcagg}/api/calcagg/calc_agg_points_range
+```
+
+请求体:
+
+```json
+{
+  "end": 1893528000,
+  "begin": 1893524400,
+  "sync_run": true,
+  "point_ids": [
+    "Excel中的point_id"
+  ],
+  "operator_id": 1,
+  "operator_name": "jdf-energy-collector"
+}
+```
+
+字段说明:
+
+| 字段 | 说明 |
+| --- | --- |
+| `begin` | 上一个整点时间戳,例如 `17:00:00` |
+| `end` | 当前整点时间戳,例如 `18:00:00` |
+| `sync_run` | 固定为 `true` |
+| `point_ids` | 本轮成功写入 `addpointdatum` 的 Excel `point_id` 列 |
+| `operator_id` | 固定为 `1` |
+| `operator_name` | 固定为 `jdf-energy-collector` |
+
+响应处理规则:
+
+- 等待接口响应
+- 若响应中的 `state != 0`,打印完整响应内容
+- 调用前后写入日志,包含请求体、响应体、HTTP 状态码和耗时 `elapsed_ms`
+
+### 7.10 运行方式
+
+安装依赖:
+
+```bash
+poetry install
+```
+
+常驻运行:
+
+```bash
+poetry run python main.py --config config.yaml
+```
+
+### 7.11 Python 依赖
+
+程序依赖:
+
+```text
+requests
+openpyxl
+PyYAML
+```