Metadata-Version: 2.4
Name: tsysmart_dev_topo
Version: 1.4
Summary: Device topology data management module
Description-Content-Type: text/markdown
Requires-Dist: SQLAlchemy~=2.0
Requires-Dist: fastapi>=0.115
Requires-Dist: uvicorn>=0.34
Requires-Dist: pydantic
Requires-Dist: tsysmart_appmng
Requires-Dist: tsysmart_proj
Dynamic: description
Dynamic: description-content-type
Dynamic: requires-dist
Dynamic: summary

# tsysmart_dev_topo - 设备拓扑

设备拓扑数据管理模块，拓扑版本 **1.3**。提供拓扑数据的查询、导入和 API 服务。可通过 pip wheel 安装。

## 安装

```bash
# 先安装依赖包
pip install tsysmart_appmng -i https://pypi.tsysmart.com/simple/

# 构建 wheel
cd source/scada/python/tsysmart_dev_topo
python -m build --wheel --outdir dist .

# 安装
pip install dist/tsysmart_dev_topo-*.whl

# 验证
python -c "from tsysmart_dev_topo import app, TopoService; print('OK')"
```

## 打包范围（wheel 含 / 不含）

`setup.py` 里用**显式 packages 列表**限定打包内容，以下三者刻意**不打进 wheel**
（打包相关改动请勿把它们加回 `packages` / `package_data`）：

| 目录                                                                                                             | 是否进 wheel | 原因                                                                                                     |
| ---------------------------------------------------------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------- |
| `tests/`                                                                                                         | ❌ 否         | pytest 用例（含需要数据库/HTTP 服务的集成用例），只随源码仓库分发                                        |
| `topo_define/*.csv`                                                                                              | ❌ 否         | 93 个表定义源文件，仅用于源码树内 `tools/gen_tables.py` 生成 `tables.py`；运行期只读已生成的 `tables.py` |
| `topo_custom/`                                                                                                   | ❌ 否         | 项目自定义表结构持久化目录（运行期产物），进 wheel 会被 pip 升级覆盖                                     |
| `tables.py`、`app/`、`tools/`、`install.py`、`upgrade.py`、`tables_provider.py`、`meta.json`、`requirements.txt` | ✅ 是         | 运行期/安装期必需                                                                                        |

因此：**需要重新生成表结构（改 CSV → `gen_tables.py`）时必须用源码仓库**；
已安装环境里 `tools/gen_tables.py` 因缺少 `topo_define/` CSV 目录而无法直接用默认路径生成
（可用 `-i <csv目录>` 指定自己的 CSV 目录）。

> 已安装的 wheel 中不含 `tests/`，故不要在安装环境里执行 `pytest --pyargs tsysmart_dev_topo.tests`；
> 请用源码仓库运行 `python -m pytest tests/ -q`。

**版本号有两套，别混**：`setup.py` 的 `version` 是 wheel/包版本（CI 用它决定是否构建上传）；
`tables.py` 的 `TOPO_VERSION`（当前 `1.3`）是**表结构版本**，`__version__` 对外暴露的是后者。
CI（`build/utils/ci/check_and_build_wheel.sh`）只在本地包版本高于 PyPI 版本时才构建上传：
PyPI 当前是 **1.3**，本地 **1.4** 会被构建上传；以后再改打包范围/内容时记得继续递增版本号，
否则会被判定"不高于 PyPI 版本"而静默跳过。

## 目录结构

```
tsysmart_dev_topo/
├── pyproject.toml              # 打包配置（wheel 构建）
├── setup.py                    # setuptools 兼容打包入口
├── __init__.py                 # 导出 app, TopoService
├── meta.json                   # 模块元数据（名称、版本、简介）
├── install.py                  # 安装 / 卸载 / 升级（安装时自动建表）
├── upgrade.py                  # 数据库升级脚本（结构迁移：列改名映射 + 增量升级 / 智能重建）
├── tables.py                   # 93 张拓扑表 SQLAlchemy ORM 模型
├── data_types.py               # 行业数据类型对照表（data_type/dev_type 取值定义）
├── requirements.txt            # Python 依赖
├── topo_define/                # 93 个 CSV 表定义文件（仅源码仓库，见"打包范围"）
├── app/
│   └── services/
│       ├── topo_service.py     # 内部接口：返回对象 array
│       └── topo_api.py         # 外部接口：FastAPI JSON REST API
├── tests/                      # pytest 用例（仅源码仓库，不打进 wheel）
│   ├── conftest.py             # sys.path 处理
│   ├── _helpers.py             # 断言收集 / 环境变量 / HTTP 助手
│   ├── test_bus_topo.py        # 拓扑简化离线用例
│   ├── test_tables_provider.py # 表结构注入（持久化注册）用例
│   ├── test_data_types.py      # 行业数据类型对照表离线用例
│   ├── test_api.py             # TopoService + REST 集成用例（含虚拟设备/数据类型，无服务自动 skip）
│   ├── test_real_db.py         # 真实库/服务集成用例（无环境自动 skip）
│   ├── test_real_data.py       # 实际台账数据的简化不变量用例
│   ├── test_import_topo.py     # 映射约定（离线）+ SQLite→SQLite 端到端（离线）+ SQLite→PG（env 门控）
│   ├── test_db_create_tables.py# 多 schema 建表（env 门控）
│   └── run_busdata_check.py    # 实际数据检查脚本（也可被 pytest 复用）
└── tools/
    ├── import_topo.py          # 通用数据导入工具
    ├── gen_tables.py           # CSV → tables.py 代码生成器
    ├── gen_cpp_models.py       # tables.py → C++ 模型头生成器
    └── mappings/               # 字段映射配置文件
        ├── scada_ac_net_bus.py
        ├── scada_ac_net_load.py
        └── ...
```
# C++ 部分已独立拆分到平台 lib 目录：
../../lib/devtopolib/               # C++ 库源码与工程
    ├── devtopolib.pro / .cpp       # 库实现
    ├── examples/                   # 示例程序
    └── tests/                      # gtest 单元测试
../../include/devtopolib/           # C++ 公共头文件（生成 + 手工）
    ├── tsysmart_dev_topo.h
    ├── tsysmart_dev_topo_model_api.h
    └── tsysmart_dev_topo_models/   # 93 个分表头（gen_cpp_models.py 生成）
```

## 拓扑表清单（93 张）
| 类别            | 数量 | 包含表                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| --------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 厂站/公共       | 8    | `scada_basevalue`, `scada_basevoltage`, `scada_bay`, `scada_company`, `scada_compute_define`, `scada_subcontrolarea`, `scada_substation`, `scada_voltagelevel`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| 厂站拓扑        | 5    | `scada_ac_net_island`, `scada_ac_net_line`, `scada_ac_net_lineend`, `scada_ac_net_linesegment`, `scada_ac_net_node`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| 交流设备        | 9    | `scada_ac_net_breaker`, `scada_ac_net_bus`, `scada_ac_net_disconnector`, `scada_ac_net_grounddisconnector`, `scada_ac_net_load`, `scada_ac_net_taptype`, `scada_ac_net_transformer`, `scada_ac_net_transformerwinding`, `scada_ac_net_unit`                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| 直流设备        | 6    | `scada_dc_net_breaker`, `scada_dc_net_bus`, `scada_dc_net_lineend`, `scada_dc_net_linesegment`, `scada_dc_net_load`, `scada_dc_net_unit`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| 转换器/储能变流 | 8    | `scada_econv_acac`, `scada_econv_acdc`, `scada_econv_dcdc`, `scada_econv_device`, `scada_econv_device_io`, `scada_econv_pv`, `scada_econv_storage`, `scada_econv_wd`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| 电力设备        | 9    | `scada_dev_heat_gb`, `scada_dev_heat_hstorage`, `scada_elec_agg_unit`, `scada_elec_dev_diesel`, `scada_elec_dev_load`, `scada_elec_dev_pv`, `scada_elec_dev_storage`, `scada_elec_dev_unit`, `scada_elec_dev_wt`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 电能量/电价     | 2    | `scada_elec_tariff_time`, `scada_elec_tariff_trans_dist`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| FES 采集与控制  | 25   | `scada_fes2scd_define`, `scada_fes_bacnet_define`, `scada_fes_channel_define`, `scada_fes_iec104_define`, `scada_fes_iec104_yc_define`, `scada_fes_iec104_yk_define`, `scada_fes_iec104_yt_define`, `scada_fes_iec104_yx_define`, `scada_fes_modbus_define_call`, `scada_fes_modbus_define_data`, `scada_fes_modbus_define_ytyk`, `scada_fes_mqtt_define`, `scada_fes_offline_setvalue`, `scada_fes_opc_define`, `scada_fes_remote_ctrl_para`, `scada_fes_rtu_define`, `scada_fes_schedule_task`, `scada_fes_task_message`, `scada_fes_virtual_dev`, `scada_fes_virtual_dev_point`, `scada_fes_yc_define`, `scada_fes_yk_define`, `scada_fes_yt_define`, `scada_fes_ytyk_message`, `scada_fes_yx_define` |
| SCADA 告警/统计 | 8    | `scada_scd_dev_status_define`, `scada_scd_dev_warn_bind`, `scada_scd_elec_statics`, `scada_scd_stat_demand`, `scada_scd_warn_define`, `scada_scd_warn_history`, `scada_scd_warn_level_define`, `scada_scd_warn_type_define`                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| 热力设备        | 6    | `scada_heat_basevalue`, `scada_therm_dev_inline`, `scada_therm_dev_storage`, `scada_therm_net_bus`, `scada_therm_net_load`, `scada_therm_net_unit`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| 多能转换        | 5    | `scada_mconv_dev_boiler`, `scada_mconv_dev_cchp`, `scada_mconv_dev_chp`, `scada_mconv_dev_e2h`, `scada_mconv_dev_h2c`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| 电力交易/断面   | 2    | `scada_tie`, `scada_tiesens`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |

> 完整表定义见 [tables.py](tables.py)。各表字段类型/主键详情见对应的 CSV 定义文件（`topo_define/`，仅存在于源码仓库，不随 wheel 分发）。

## 数据导入工具（import_topo.py）

通用数据导入工具，支持任意 SQLAlchemy 兼容的源数据库（SQLite / PostgreSQL / MySQL 等）。

### 工作原理

```
┌──────────────────────┐     ┌─────────────────────────┐     ┌──────────────────┐
│  源数据库（任意）      │ --> │  mappings/ 字段映射配置   │ --> │  PG 目标表        │
│  get_db_url 连接      │     │  源表名 → PG 表名          │     │  scada_ac_net_bus │
│  自动识别表            │     │  源字段 → PG 字段          │     │  scada_ac_net_bus  │
│                       │     │  枚举值转换                │     │  scada_ac_net_unit │
│                       │     │                          │     │  ...              │
└──────────────────────┘     └─────────────────────────┘     └──────────────────┘
```

### 映射文件结构（mappings/）

每个 `.py` 文件对应一张 PG 目标表，文件名为 PG 表名：

```python
# scada_ac_net_unit 字段映射

source_version_defines = {
    'THU_EFILE': {
        'table_name': 'unit',           # 源表名
        'primary_keys': ['id'],         # 主键
        'header_list': ['id', ...],     # 源字段清单
        'mapping': {'nd': 'ind', ...},  # 目标列 ← 源列（KEY=目标列, VALUE=源列）
        'value_map': {'un_type': {'V': 2, 'P': 8}},  # 枚举值转换（KEY=目标列）
    },
    'THU_1.0': { ... },
}
```

**方向约定（最容易写错的一点）**：`mapping` 是 **`{目标表列名: 源库列名}`**
—— KEY 是目标列、VALUE 是源列，由 `import_topo.build_insert_row()` 消费：

```python
'mapping': {'nd': 'ind'}                  # 目标列 nd ← 源列 ind
'mapping': {'p_max': 'wmx'}               # 目标列 p_max ← 源列 wmx
'mapping': {'description': 'describeb'}   # 目标列 description ← 源列 describeb
```

规则：
1. **显式映射优先**：目标列取 `mapping` 指定的源列，源行里没有该源列就不填；
2. **同名直通**：未出现在 `mapping` 里的列，目标列名与源列名相同时直接取同名列；
3. `value_map` 的 KEY 也是**目标列**，做取值转换；
4. 目标表里不存在的目标列忽略；源里缺的字段留空（不再报错）。

写错方向的后果是**该字段被静默丢弃**（不报错）：例如把
`{'description': 'describeb'}` 写成 `{'describeb': 'description'}`，
目标表没有 `describeb` 列，`description` 就永远是空。

程序启动时扫描 `mappings/` 目录，根据 `--source_version` 自动建立源表 → PG 表的完整映射关系，无需手动维护映射表清单。

#### 目标表尚未加入 tables.py 的映射（自动跳过）

若某个映射文件的目标表还没进 `tables.py`（例如将来才加入、且加入时会改名），
`load_mappings()` 会**整体跳过该文件**（不注册 `pg_tables` / `source_to_pg`），
并在首次调用时打印一行提示 —— 否则导入时 PG reflect 会失败、被静默跳过，
源表数据一行都进不去。当前被跳过的映射：

| 映射文件                | 目标表（不在 tables.py） | 说明                                                                                          |
| ----------------------- | ------------------------ | --------------------------------------------------------------------------------------------- |
| `scada_dev_storage.py`  | `scada_dev_storage`      | 将来加入时预计改名 `scada_elec_dev_storage`（该表已存在，源表 `elec_dev_storage` 走默认映射） |
| `scada_dev_pv.py`       | `scada_dev_pv`           | 预计改名 `scada_elec_dev_pv`                                                                  |
| `scada_dev_diesel.py`   | `scada_dev_diesel`       | 预计改名 `scada_elec_dev_diesel`                                                              |
| `scada_econv_mt_dev.py` | `scada_econv_mt_dev`     | 表结构里暂无对应表                                                                            |

表加入 `tables.py` 后，把映射文件改名/内容对齐该表即自动生效（无需改代码）。

> 另有两处 mappings 自身与表结构的历史脱节（**暂不改，保持现状**，由
> `tests/test_import_topo.py::test_other_versions_mapping_keys_baseline` 固化跟踪）：
> `THU_EFILE`/`QYOPS_0.5` 的 `scada_tie` 声明了 `p_max/p_min/p_set`（该表里是 `ws/pmax/pmin/pref`），
> `THU_EFILE` 的 `scada_tiesens` 声明了 `dev_idx/tie_idx` —— 这些字段目前不会导入。

---

## 内部接口（topo_service.py）

供其他 Python 模块直接调用的对象式接口。初始化后通过 ORM 模型类（`tables.py` 中定义的类）进行增删改查。

```python
from tsysmart_dev_topo import TopoService
from tsysmart_dev_topo.tables import ScadaAcNetBus, ScadaAcNetLoad, ScadaDevDiesel

svc = TopoService('henan_test', 'topic3_da')
```

### 查询

#### `query(model_class, filters=None, columns=None, limit=None)`

按模型类查询，返回 `list[dict]`。支持复杂过滤条件。

```python
# 全表查询
all_buses = svc.query(ScadaAcNetBus)

# 等值过滤
loads = svc.query(ScadaAcNetLoad, filters={'ld_type': 0})

# 指定返回列
names = svc.query(ScadaElecDevDiesel, columns=['id', 'name', 'p_rated'])

# 限制行数
top5 = svc.query(ScadaAcNetLoad, filters={'ld_type': 0}, limit=5)
```

#### 查询过滤语法

`filters` 参数支持丰富的操作符，多个条件用 AND 连接：

| 语法                    | SQL                | 示例                                 |
| ----------------------- | ------------------ | ------------------------------------ |
| `{'col': val}`          | `col = val`        | `{'ld_type': 0}`                     |
| `{'col': [a, b, c]}`    | `col IN (a, b, c)` | `{'id': ['bus1', 'bus2', 'bus3']}`   |
| `{'col__gt': v}`        | `col > v`          | `{'p_rated__gt': 300}`               |
| `{'col__gte': v}`       | `col >= v`         | `{'p_rated__gte': 300}`              |
| `{'col__lt': v}`        | `col < v`          | `{'p_rated__lt': 500}`               |
| `{'col__lte': v}`       | `col <= v`         | `{'p_rated__lte': 500}`              |
| `{'col__ne': v}`        | `col <> v`         | `{'name__ne': 'default'}`            |
| `{'col__like': '%x%'}`  | `col LIKE '%x%'`   | `{'name__like': '%电站%'}`           |
| `{'col__ilike': '%x%'}` | 自动适配方言       | `{'name__ilike': '%station%'}`       |
| 组合                    | AND                | `{'ld_type': 0, 'p_rated__gt': 100}` |

```python
# IN 批量查询
devs = svc.query(ScadaElecDevDiesel, filters={'id': ['d1', 'd2', 'd3']})

# 范围查询
heavy = svc.query(ScadaAcNetLoad, filters={'s_rated__gte': 500})

# 组合 AND
result = svc.query(ScadaElecDevDiesel,
    filters={'p_rated__gte': 250, 'p_rated__lt': 400, 'name__like': '%批量%'})
```

#### `get_devices(device_names, filters=None)`

跨表批量查询（返回 dict，内部接口中较少用，主要用于 API 层）：

```python
result = svc.get_devices(
    ['scada_ac_net_load', 'scada_elec_dev_storage', 'scada_tie'],
    filters={'scada_ac_net_load': {'ld_type': 1}}
)
# => {'version': '1.3', 'project_code': 'topic3_da', 'tables': {'scada_ac_net_load': [...], ...}}
```

### 插入

#### `insert(obj)`

插入单条记录。**主键重复会抛出异常**（不更新）。

```python
# 构造对象
dev = ScadaElecDevDiesel(id='D001', name='柴油机1号', p_rated=500.0)

# 插入
svc.insert(dev)

# 重复插入报错
try:
    svc.insert(ScadaElecDevDiesel(id='D001', name='重复'))
except Exception:
    print('主键重复，插入失败')  # 符合预期
```

#### `insert_batch(objs)`

批量插入，单条 SQL 执行。**任一主键重复则整批失败**。

```python
batch = [
    ScadaElecDevDiesel(id='B01', name='批量1', p_rated=200.0),
    ScadaElecDevDiesel(id='B02', name='批量2', p_rated=250.0),
    ScadaElecDevDiesel(id='B03', name='批量3', p_rated=300.0),
]
result = svc.insert_batch(batch)
# => {'inserted': 3}
```

### 更新

#### `update(model_class, data, filters)`

按条件批量更新，可一次匹配多行。返回 `{'updated': True, 'matched': N}`。

```python
# 单条更新（按主键）
svc.update(ScadaElecDevDiesel, {'name': '新名称'}, {'id': 'D001'})

# 批量更新（IN）
svc.update(ScadaElecDevDiesel, {'p_rated': 999.0},
    {'id': ['B01', 'B02', 'B03']})

# 按范围批量更新
svc.update(ScadaAcNetLoad, {'run_state': 1},
    {'s_rated__gt': 500})

# 组合条件
svc.update(ScadaElecDevDiesel, {'name': '已退役'},
    {'name__like': '%批量%', 'p_rated__lt': 300})
```

### 删除

#### `delete(obj)`

按主键删除单条记录。

```python
# 构造对象（只需要主键值）
svc.delete(ScadaElecDevDiesel(id='D001'))

# 删除不存在的记录（count=0）
result = svc.delete(ScadaElecDevDiesel(id='nonexist'))
# => {'deleted': True, 'count': 0}
```

#### `delete_by(model_class, filters)`

按条件批量删除。

```python
# 批量删除（IN）
svc.delete_by(ScadaElecDevDiesel, {'id': ['B01', 'B02', 'B03']})

# 按范围删除
svc.delete_by(ScadaElecDevDiesel, {'p_rated__lt': 100})

# 组合条件
svc.delete_by(ScadaElecDevDiesel,
    {'name__like': '%_test%', 'p_rated__lt': 50})
```

### Upsert

#### `upsert(obj)`

插入或更新：主键不存在则插入，存在则更新。自动适配 PostgreSQL / SQLite / MySQL 方言。

```python
# 第一次：插入
svc.upsert(ScadaElecDevDiesel(id='U01', name='upsert1', p_rated=50.0))

# 第二次：同 id → 更新
svc.upsert(ScadaElecDevDiesel(id='U01', name='upsert-更新', p_rated=99.0))
# 数据库中 id='U01' 只有 1 行，name='upsert-更新', p_rated=99.0
```

### 方法总览

| 方法                                                                                                  | 类型  | 说明                                                                                              |
| ----------------------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------- |
| `query(model_class, filters=None, columns=None, limit=None)`                                          | 查    | 按模型类查询，返回 `list[dict]`（project_code 在构造 TopoService 时传入）                         |
| `get_devices(device_names, filters=None)`                                                             | 查    | 跨表批量查询                                                                                      |
| `insert(obj)`                                                                                         | 增    | 插入单条，重复 key 报错                                                                           |
| `insert_batch(objs)`                                                                                  | 增    | 批量插入，重复 key 整批失败                                                                       |
| `update(model_class, data, filters)`                                                                  | 改    | 按条件批量更新                                                                                    |
| `delete(obj)`                                                                                         | 删    | 按 PK 删除单条                                                                                    |
| `delete_by(model_class, filters)`                                                                     | 删    | 按条件批量删除                                                                                    |
| `upsert(obj)`                                                                                         | 增/改 | 存在则更新，不存在则插入                                                                          |
| `create_virtual_dev(dev_id, name, dev_type=None, description=None)`                                   | 增    | 新增虚拟设备（id 唯一，dev_type 见 data_types 设备类型对照）                                      |
| `update_virtual_dev(dev_id, name=None, dev_type=None, description=None)`                              | 改    | 编辑虚拟设备元信息                                                                                |
| `delete_virtual_dev(dev_id)`                                                                          | 删    | 删除虚拟设备（连带删除其全部关联点）                                                              |
| `get_virtual_devs(dev_id=None, dev_type=None, limit=None, offset=0)`                                  | 查    | 查询虚拟设备列表（按 id/dev_type 过滤，附带 point_count）                                         |
| `add_virtual_dev_point(dev_id, if_yc, rtu_id, pnt_no, data_name, data_type=None)`                     | 增    | 新增虚拟设备关联点（把 yc/yx 源点挂到虚拟设备，data_name 同设备唯一，data_type 为测点类型）       |
| `update_virtual_dev_point(dev_id, if_yc, rtu_id, pnt_no, **fields)`                                   | 改    | 编辑关联点（迁移源点 / 改 data_name / data_type）                                                 |
| `delete_virtual_dev_point(dev_id, if_yc, rtu_id, pnt_no)`                                             | 删    | 删除关联点                                                                                        |
| `get_virtual_dev_points(dev_id=None, if_yc=None, data_type=None, limit=None, offset=0)`               | 查    | 查询关联点及实时值（按设备 / if_yc / data_type 过滤，分页）                                       |
| `get_virtual_dev_data(dev_id, if_yc=None, data_type=None)`                                            | 查    | 查询某虚拟设备关联数据（按 data_name 聚合字典；if_yc 过滤 1 遥测/0 遥信，data_type 过滤测点类型） |
| `get_virtual_devs_data(dev_id=None, dev_type=None, if_yc=None, data_type=None, limit=None, offset=0)` | 查    | 按设备类型等条件查虚拟设备并附带每台聚合数据（无匹配数据的设备自动排除）                          |

### 虚拟设备关联（scada_fes_virtual_dev + scada_fes_virtual_dev_point）

**虚拟设备**是聚合若干实时采集点（遥测 yc / 遥信 yx）为一个逻辑设备的抽象，典型用于把分散的
传感器点聚合成"一台光伏机组""一套储能系统"等业务对象。

- **scada_fes_virtual_dev**：虚拟设备主表（id 唯一，name / dev_type / description）
- **scada_fes_virtual_dev_point**：关联点表，把 `scada_fes_yc_define`（if_yc=1 遥测）/ `scada_fes_yx_define`
  （if_yc=0 遥信）中的采集点按 `(dev_id, if_yc, rtu_id, pnt_no)` 挂到虚拟设备下

数据类型标识说明：
- **虚拟设备主表 `dev_type`** = 设备类型（如 `pv` / `storage` / `boiler`），标识"这是什么设备"；
- **关联点表 `data_type`** = 数据类型（如 `power` / `voltage` / `current` / `temperature` / `soc`），
  标识"这个点测什么"；
- **`scada_fes_yc_define.data_type`** = 遥测点数据类型（曾用字段名 `dev_type`），与虚拟设备关联点
  `data_type` 共用同一套取值；
- **`scada_fes_yc_define.dev_type`** = 遥测点所属设备类型表名（tables.py 中的表名，如
  `scada_elec_dev_pv`），配合 `dev_id`（string）定位到具体设备行；
- 数据类型完整对照表见 `data_types.py`，REST 可通过 `GET /topo/datatype` 查询。

关联前自动校验：虚拟设备存在、源采集点在源表存在；同一 `(dev_id, if_yc, rtu_id, pnt_no)` 不允许
重复关联；同虚拟设备下 `data_name` 唯一。

```python
from tsysmart_dev_topo import TopoService

svc = TopoService('henan_test', 'topic3_da')

# 1. 新增虚拟设备（id 必填，dev_type 见 data_types.py）
result = svc.create_virtual_dev('VD001', name='1号虚拟机组', dev_type='pv')
# => {'created': True, 'dev': {'id': 'VD001', 'name': '1号虚拟机组',
#                              'dev_type': 'pv', 'description': None}}
# id 重复、缺失 时抛出 ValueError

# 2. 新增关联点（if_yc=1 从 yc 遥测读；data_name 同设备唯一；data_type 为测点类型）
result = svc.add_virtual_dev_point('VD001', 1, 910001, 920001,
                                   data_name='bus_voltage', data_type='voltage')
# => {'added': True, 'point': {'dev_id': 'VD001', 'if_yc': 1, 'rtu_id': 910001,
#                              'pnt_no': 920001, 'data_name': 'bus_voltage',
#                              'data_type': 'voltage'}}
# 虚拟设备不存在、源点不存在、组合重复、data_name 冲突 时抛出 ValueError

# 3. 编辑关联点（迁移源点 / 改名 / 改测点类型）
svc.update_virtual_dev_point('VD001', 1, 910001, 920001,
                             new_rtu_id=930001, new_pnt_no=930101)  # 迁移到另一 yc 点
svc.update_virtual_dev_point('VD001', 1, 930001, 930101,
                             new_data_name='bus_voltage_v2')       # 改名
svc.update_virtual_dev_point('VD001', 1, 930001, 930101,
                             data_type='voltage')                  # 改测点类型

# 4. 按设备查询全部关联数据（data 按 data_name 聚合 + 实时 value）
result = svc.get_virtual_dev_data('VD001')
# => {'version': '1.3', 'project_code': 'topic3_da', 'dev_id': 'VD001',
#     'dev': {'id': 'VD001', 'name': '1号虚拟机组', 'dev_type': 'pv', 'description': None},
#     'count': 1,
#     'data': {'bus_voltage_v2': {'if_yc': 1, 'rtu_id': 930001, 'pnt_no': 930101,
#                                 'data_type': 'voltage', 'value': 10.5,
#                                 'status': 1, 'refresh_time': 1787562000}}}

# 4b. 按 if_yc 过滤查询（if_yc=1 只取遥测点）
yc_only = svc.get_virtual_dev_data('VD001', if_yc=1)
# 4c. 按测点类型过滤查询（data_type=voltage 只取电压点）
volt_only = svc.get_virtual_dev_data('VD001', data_type='voltage')

# 5. 按设备 + 测点类型查询关联点
pts = svc.get_virtual_dev_points(dev_id='VD001', data_type='voltage')
# 或按 if_yc 过滤：svc.get_virtual_dev_points(dev_id='VD001', if_yc=1)

# 5b. 按设备类型批量查（dev_type=pv 取同类型设备 + 各自关联数据）
pv_devs = svc.get_virtual_devs_data(dev_type='pv')
# 无 temperature 点的设备会被自动排除：get_virtual_devs_data(data_type='temperature')

# 6. 删除关联点 / 删除虚拟设备（级联删点）
svc.delete_virtual_dev_point('VD001', 1, 930001, 930101)
svc.delete_virtual_dev('VD001')
# => {'deleted': True, 'dev_count': 1, 'point_count': 2}
```

### 表名/列名安全校验

所有增删改查方法都会对表名和列名做白名单校验（基于 `tables.py` 中注册的列定义），防止 SQL 注入。非法表名或列名会抛出 `ValueError`。

---

## 外部接口（topo_api.py）

遵循 RESTful 设计，按查→增→改→删组织：

| 方法   | 路径                                                         | 说明                                                                                                |
| ------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| GET    | `/topo/version`                                              | API 版本号                                                                                          |
| GET    | `/topo/topology`                                             | 全部拓扑数据                                                                                        |
| GET    | `/topo/device/{device_type}`                                 | 单表查询                                                                                            |
| GET    | `/topo/devices`                                              | 多表查询 + where 过滤                                                                               |
| POST   | `/topo/device/{device_type}`                                 | 插入单条                                                                                            |
| POST   | `/topo/devices/{device_type}`                                | 批量插入                                                                                            |
| PUT    | `/topo/device/{device_type}/{id}`                            | 完整更新（按 URI 中 id）                                                                            |
| PATCH  | `/topo/device/{device_type}`                                 | 部分更新（query 传 where，body 传 data）                                                            |
| DELETE | `/topo/device/{device_type}/{id}`                            | 按 ID 删除                                                                                          |
| DELETE | `/topo/device/{device_type}`                                 | 条件删除（query 传 where）                                                                          |
| POST   | `/topo/virtualdev`                                           | 新增虚拟设备（id 唯一，dev_type 见 /topo/datatype）                                                 |
| GET    | `/topo/virtualdevs`                                          | 查询虚拟设备列表（按 id/dev_type 过滤，附带 point_count）                                           |
| GET    | `/topo/virtualdevs/data`                                     | 按设备类型等条件查虚拟设备及各自关联数据（dev_type/if_yc/data_type 过滤，无匹配数据的设备自动排除） |
| GET    | `/topo/virtualdev/{dev_id}`                                  | 查询单个虚拟设备关联数据（按 data_name 聚合 + 实时 value，可按 if_yc/data_type 过滤）               |
| PUT    | `/topo/virtualdev/{dev_id}`                                  | 编辑虚拟设备元信息（name/dev_type/description）                                                     |
| DELETE | `/topo/virtualdev/{dev_id}`                                  | 删除虚拟设备（连带删除关联点）                                                                      |
| POST   | `/topo/virtualdev/{dev_id}/points`                           | 新增虚拟设备关联点（if_yc/rtu_id/pnt_no/data_name/data_type）                                       |
| GET    | `/topo/virtualdev/{dev_id}/points`                           | 查询关联点及实时值（按 if_yc/data_type 过滤，分页）                                                 |
| PUT    | `/topo/virtualdev/{dev_id}/points/{if_yc}/{rtu_id}/{pnt_no}` | 编辑关联点（迁移源点/改名/改 data_type）                                                            |
| DELETE | `/topo/virtualdev/{dev_id}/points/{if_yc}/{rtu_id}/{pnt_no}` | 删除关联点                                                                                          |
| GET    | `/topo/datatype`                                             | 行业数据类型对照表（dev_type 取值参考）                                                             |

所有写接口（POST/PUT/PATCH/DELETE）会实际修改数据库。

### 公共参数

| 参数           | 位置  | 必填 | 示例值                                       |
| -------------- | ----- | ---- | -------------------------------------------- |
| `tenant_code`  | Query | 是   | `henan_test`                                 |
| `project_code` | Query | 是   | `topic3_da`                                  |
| `device_type`  | Path  | 是   | `scada_elec_dev_diesel`、`scada_ac_net_load` |

### where 过滤操作符

所有带 `where` 的接口（GET devices / PATCH / DELETE 条件删除）支持以下操作符：

| 语法                   | 含义     | 示例                                      |
| ---------------------- | -------- | ----------------------------------------- |
| `{"col": "val"}`       | 等值     | `{"id": "D001"}`                          |
| `{"col": ["a","b"]}`   | IN       | `{"id": ["D001","D002"]}`                 |
| `{"col__gt": 100}`     | 大于     | `{"p_rated__gt": 100}`                    |
| `{"col__gte": 100}`    | 大于等于 | `{"p_rated__gte": 100}`                   |
| `{"col__lt": 300}`     | 小于     | `{"p_rated__lt": 300}`                    |
| `{"col__lte": 300}`    | 小于等于 | `{"p_rated__lte": 300}`                   |
| `{"col__ne": "v"}`     | 不等于   | `{"id__ne": "D001"}`                      |
| `{"col__like": "%x%"}` | LIKE     | `{"id__like": "API_%"}`                   |
| 多个条件               | AND      | `{"p_rated__gte":100, "p_rated__lt":300}` |

### GET /topo/version

```bash
curl http://127.0.0.1:8123/topo/version
# => {"version": "1.3"}
```

### GET /topo/topology

```bash
curl "http://127.0.0.1:8123/topo/topology?tenant_code=henan_test&project_code=topic3_da"
```

### GET /topo/device/{device_type}

```bash
curl "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?tenant_code=henan_test&project_code=topic3_da"
```

### GET /topo/devices

```bash
# 多表查询
curl "http://127.0.0.1:8123/topo/devices?device_types=scada_elec_dev_diesel,scada_ac_net_load&tenant_code=henan_test&project_code=topic3_da"

# 带过滤（where 参数需 URL 编码）
curl "http://127.0.0.1:8123/topo/devices?device_types=scada_ac_net_load&where=%7B%22scada_ac_net_load%22%3A%7B%22ld_type%22%3A0%7D%7D&tenant_code=henan_test&project_code=topic3_da"
```

### POST /topo/device/{device_type} — 插入单条

```bash
curl -X POST "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?tenant_code=henan_test&project_code=topic3_da" \
  -H "Content-Type: application/json" \
  -d '{"id": "D001", "name": "柴油机1号", "p_rated": 500.0}'
# 主键重复 → 400
```

### POST /topo/devices/{device_type} — 批量插入

```bash
curl -X POST "http://127.0.0.1:8123/topo/devices/scada_elec_dev_diesel?tenant_code=henan_test&project_code=topic3_da" \
  -H "Content-Type: application/json" \
  -d '[{"id": "D001", "name": "柴油机1", "p_rated": 500.0}, {"id": "D002", "name": "柴油机2", "p_rated": 300.0}]'
# 任一条主键重复 → 整批失败（事务回滚）
```

### PUT /topo/device/{device_type}/{id} — 完整更新

```bash
curl -X PUT "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel/D001?tenant_code=henan_test&project_code=topic3_da" \
  -H "Content-Type: application/json" \
  -d '{"id": "D001", "name": "完整更新后名称", "p_rated": 800.0}'
# 需提供所有列的值（完整替换）
```

### PATCH /topo/device/{device_type} — 部分更新

```bash
# 按 id 更新名称（仅更新 name 列）
curl -X PATCH "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?where=%7B%22id%22%3A%22D001%22%7D&tenant_code=henan_test&project_code=topic3_da" \
  -H "Content-Type: application/json" \
  -d '{"name": "部分更新后的名称"}'

# 批量更新（p_rated > 100 的所有行）
curl -X PATCH "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?where=%7B%22p_rated__gt%22%3A100%7D&tenant_code=henan_test&project_code=topic3_da" \
  -H "Content-Type: application/json" \
  -d '{"name": "批量改名"}'
```

### DELETE /topo/device/{device_type}/{id} — 按 ID 删除

```bash
curl -X DELETE "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel/D001?tenant_code=henan_test&project_code=topic3_da"
```

### DELETE /topo/device/{device_type} — 条件删除

```bash
# 删除单条
curl -X DELETE "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?where=%7B%22id%22%3A%22D001%22%7D&tenant_code=henan_test&project_code=topic3_da"

# 批量删除（IN）
curl -X DELETE "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?where=%7B%22id%22%3A%5B%22D001%22%2C%22D002%22%5D%7D&tenant_code=henan_test&project_code=topic3_da"

# 条件删除（p_rated > 500）
curl -X DELETE "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?where=%7B%22p_rated__gt%22%3A500%7D&tenant_code=henan_test&project_code=topic3_da"
```

---

## 安装 / 卸载 / 升级

通过 `tsysmart_appmng` 框架管理：

```bash
# 安装（注册模块 + 自动建表）
python install.py --tenant_code henan_guozhong --project_code henan_test --action install

# 卸载（注销模块，不删表）
python install.py --tenant_code henan_guozhong --project_code henan_test --action uninstall

# 升级（更新注册 + 执行 upgrade.py 数据迁移）
python install.py --tenant_code henan_guozhong --project_code henan_test --action upgrade

# 查询模块信息
python install.py --tenant_code henan_guozhong --project_code henan_test --action query
```

---

## 数据库升级（upgrade.py）

独立于 `install.py` 的升级脚本，支持两种模式。升级前会先自动执行**结构迁移**（见下）。

### 结构迁移（每次升级 / 重建前自动执行）

用 `ALTER TABLE ADD COLUMN` 只能处理"新增列"，无法无损处理**列改名**与**列类型变更**
（改名会被 diff 识别成"删旧列+加新列"，重建模式的备份/恢复按列名交集取数会丢数据）。因此
升级脚本在常规 diff / 备份重建**之前**先执行结构迁移，职责划分如下：

| 差异类型       | 处理方式                                                                                                                                                                                        |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **列改名**     | 按 upgrade.py 文件上方 `_COLUMN_RENAMES` 映射执行 `RENAME COLUMN`，数据保留                                                                                                                     |
| **列类型变更** | 同名列类型与 `tables.py` 不一致时：安全方向（数值→字符串、数值族加宽、字符加宽）自动 `ALTER COLUMN TYPE`（PG/MySQL）；危险方向（如 REAL→INTEGER、VARCHAR 缩短）不自动执行，仅提示用 `--rebuild` |
| **新增列**     | 交由常规增量 `ADD COLUMN`；`--rebuild` 模式由整表重建补齐                                                                                                                                       |
| **多余列**     | 仅报告，不删除                                                                                                                                                                                  |

结构迁移幂等（重复执行安全）。**以后字段改名，只需在 `_COLUMN_RENAMES` 增加一条
`{表名: {旧列名: 新列名}}`**，无需改动其它逻辑；字段类型变更则直接更新 `tables.py`
（`topo_define/` 下 CSV 改动后用 `gen_tables.py` 重新生成）。

当前登记（拓扑 1.3）：

```python
_COLUMN_RENAMES = {
    'scada_fes_yc_define': {'dev_type': 'data_type'},      # 旧 dev_type(数据类型) → data_type
    'scada_fes_virtual_dev_point': {
        'point_name': 'data_name',                          # 关联点 → 关联数据
        'point_type': 'data_type',
    },
}
```

### 增量升级（默认）

对比 `tables.py` 模型与数据库中实际表结构：对**新增列**执行 `ALTER TABLE ADD COLUMN`，
**多余列仅报告不删除**（数据库多余列视为历史遗留，保持原样）。

```bash
# 升级默认 7 个 schema
python upgrade.py --tenant_code henan_test

# 升级指定 schema
python upgrade.py --tenant_code henan_test --all_schemas topic3_da

# 升级单个 project
python upgrade.py --tenant_code henan_test --project_code topic3_da
```

### 智能重建（--rebuild）

逐个表对比结构，**仅重建有变化的表**。重建前同样先执行结构迁移（列改名/类型修正），
保证改名列在备份前已与 `tables.py` 模型同名，数据能被完整备份与恢复。流程：

```
有变化的表
  ├── 有数据 → CREATE TABLE _upgrade_bak_{表名} AS SELECT（备份到同一 schema）
  ├── 有数据 → 导出 bakdata/{时间戳}_{tenant}_{schema}/{表名}.csv（文件备份）
  ├── DROP TABLE {表名} CASCADE
  ├── CREATE TABLE {表名}（按 tables.py 新结构）
  ├── INSERT INTO {表名} SELECT FROM _upgrade_bak_{表名}（恢复数据）
  └── DROP TABLE _upgrade_bak_{表名}（清理备份）
无变化的表 → 跳过
```

```bash
# 智能重建（自动备份 + 恢复）
python upgrade.py --tenant_code henan_test --rebuild

# 指定 schema
python upgrade.py --tenant_code henan_test --rebuild --all_schemas topic3_da
```

### 强制重建（--rebuild --force）

跳过结构对比，所有表全部 `DROP + CREATE`，**不备份**。

```bash
python upgrade.py --tenant_code henan_test --rebuild --force
```

### 参数一览

| 参数             | 必填 | 说明                                           |
| ---------------- | ---- | ---------------------------------------------- |
| `--tenant_code`  | 是   | 租户代码                                       |
| `--project_code` | 否   | 单个项目/schema                                |
| `--all_schemas`  | 否   | 指定 schema 列表（空格分隔），默认全部 7 个    |
| `--rebuild`      | 否   | 启用智能重建模式                               |
| `--force`        | 否   | 配合 `--rebuild`，跳过智能检测，全部重建不备份 |

### 备份位置

智能重建会生成**双重备份**：

1. **PG 备份表** `_upgrade_bak_{表名}` — 与源表位于同一个 schema，恢复完成后自动删除
2. **文件备份** `bakdata/{YYYYMMDD_HHMMSS}_{tenant}_{schema}/{表名}.csv` — 位于 `tsysmart_dev_topo/bakdata/` 目录下，**不会自动删除**，方便追溯每次升级

```bash
# 查看文件备份
ls bakdata/topic3_da/

# 异常中断时 PG 残留备份表
python -c "
from sqlalchemy import create_engine, text
e = create_engine('postgresql+psycopg://...')
rows = e.execute(text(\"SELECT schemaname, tablename FROM pg_tables WHERE tablename LIKE '_upgrade_bak%'\")).fetchall()
print(rows)
"

# 清理 PG 残留
DROP TABLE IF EXISTS _upgrade_bak_xxx;
```

---

## 测试

接口测试（内部 `TopoService` + 外部 REST）现位于 `tests/test_api.py`，
覆盖内部接口和外部接口的增删改查全场景；真实库多项目集成用例在 `tests/test_real_db.py`。

### 运行

```bash
python -m pytest tests/test_api.py -k internal -q     # 仅内部接口
python -m pytest tests/test_api.py -k external -q     # 仅外部接口（需先启动 uvicorn）
python -m pytest tests/test_api.py -q                 # 两者都跑（无环境时自动 skip）
```

启动外部服务：

```bash
uvicorn tsysmart_dev_topo:app --host 127.0.0.1 --port 8123
```

### 内部接口（TopoService）测试覆盖（77 项）

| 类别         | 测试内容                                                                                                                                                                                                                                                                                     | 项数 |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
| 插入         | 单条插入 + 回读验证 + 重复 key 报错                                                                                                                                                                                                                                                          | 4    |
| 批量插入     | insert_batch 3 条 + IN 查询验证 + 批量重复报错                                                                                                                                                                                                                                               | 3    |
| 查询过滤     | 等值 / IN / gt / gte / lt / lte / ne / like / 组合 AND / limit / 全表                                                                                                                                                                                                                        | 11   |
| 批量更新     | IN 批量 matched=2 + 验证                                                                                                                                                                                                                                                                     | 2    |
| 对象删除     | 按 PK + 验证已删除                                                                                                                                                                                                                                                                           | 2    |
| 批量删除     | delete_by IN count=2                                                                                                                                                                                                                                                                         | 1    |
| Upsert       | 新 key 插入 + 已有 key 更新                                                                                                                                                                                                                                                                  | 2    |
| 跨表查询     | ScadaAcNetLoad 过滤 + 空值                                                                                                                                                                                                                                                                   | 3    |
| 清理         | TEST_ 前缀数据全清                                                                                                                                                                                                                                                                           | 1    |
| 虚拟设备     | 新增虚拟设备 / 重复拦截 / 加 yc+yx 关联点 / 重复关联拦截 / data_name 冲突 / 非法 if_yc / 无效源点 / 设备不存在 / 编辑设备 / 编辑关联点(改名+迁移) / 查设备全部数据 / 关联点列表+if_yc+data_type 过滤 / 设备列表(point_count) / 删除关联点 / 删除不存在 / 删除设备级联 / datatype 对照 / 清理 | 26   |
| FES 复合主键 | yc 插入 / 更新 / update_device / 删除 + ytyk 插入 / update_device / delete_by                                                                                                                                                                                                                | 7    |

### 外部接口（HTTP REST）测试覆盖（84 项）

| 类别         | 测试内容                                                                                                                                                                                                                                                     | 项数 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---- |
| 健康检查     | 版本号                                                                                                                                                                                                                                                       | 1    |
| GET 查询     | topology / single device / multi devices / where 过滤                                                                                                                                                                                                        | 8    |
| POST 插入    | 单条 + 回读(name/p_rated) + 重复报错 + 批量 3 条 + IN 验证 + 批量重复报错                                                                                                                                                                                    | 9    |
| PATCH 更新   | 等值/IN/gt/gte/lt/lte/ne/like/组合AND（9 个操作符）+ 批量                                                                                                                                                                                                    | 9    |
| PUT 更新     | 完整更新 + 回读(name/p_rated) + 不存在 ID matched=0                                                                                                                                                                                                          | 5    |
| DELETE 删除  | 按 URI ID + 回读验证 + 等值条件 + IN 批量                                                                                                                                                                                                                    | 5    |
| 跨表查询     | scada_ac_net_load + 过滤                                                                                                                                                                                                                                     | 4    |
| FES 复合主键 | yc / ytyk 的 POST / PATCH / DELETE                                                                                                                                                                                                                           | 7    |
| 虚拟设备     | 建 yc/yx 源数据 / POST 虚拟设备 / 重复设备 400 / 加 yc+yx 关联点 / GET 设备全部数据(实时 value) / GET 关联点 + data_type 过滤 / GET 设备列表 / PUT 编辑设备 / PUT 编辑关联点(改名) / DELETE 关联点 / GET datatype(全量+按 category) / DELETE 设备级联 / 清理 | 17   |
| Cleanup      | 清理后验证 0 残留                                                                                                                                                                                                                                            | 2    |

---

## 开发说明

> 以下表结构生成流程需要 `topo_define/*.csv`——**该目录只在源码仓库中**，不随 wheel 分发；
> 请在源码树内操作，不要试图在已安装环境里重新生成表结构。

### 新增/修改拓扑表

1. 编辑 `topo_define/{表名}.csv` 定义字段
2. 运行 `python tools/gen_tables.py` 重新生成 `tables.py`
3. 运行 `python tools/gen_cpp_models.py --input tables.py --output ../../include/devtopolib/tsysmart_dev_topo_model_common.h` 重新生成 C++ 模型头
4. 在 `tools/mappings/` 下新建/更新 `{表名}.py` 字段映射文件（如需要数据导入）
5. 运行 `upgrade.py --rebuild` 自动对比结构并重建变化的表

### 拓扑版本升级

1. 修改 `topo_define/*.csv` 表定义文件
2. 运行 `python tools/gen_tables.py` 重新生成 `tables.py`
3. 运行 `gen_cpp_models.py` 重新生成 C++ 头文件（路径同上）
4. 更新 `meta.json` 版本号
5. 运行 `upgrade.py --rebuild` 自动对比结构并重建变化的表（有数据自动备份恢复）
6. 或运行 `upgrade.py`（增量模式）仅 ADD COLUMN，不丢数据

### 依赖

| 包                | 用途                       |
| ----------------- | -------------------------- |
| `fastapi`         | REST API 框架              |
| `uvicorn`         | ASGI 服务器                |
| `sqlalchemy`      | ORM / 数据库抽象           |
| `psycopg-binary`  | PostgreSQL 驱动            |
| `networkx`        | bus_topo 拓扑简化建图      |
| `tsysmart_proj`   | 数据库连接管理（平台依赖） |
| `tsysmart_appmng` | 模块安装管理（平台依赖）   |

---

## 不同项目使用不同表结构（持久化注册）

内置 `tables.py`（93 张表）是默认表结构；若项目有独立的表结构（更多/更少的表、字段或版本号），可通过**持久化注册**将项目自己的表结构文件放到包内 `topo_custom/` 目录，进程启动期自动加载（含 `Base` / `TOPO_VERSION` / 多个 SQLAlchemy ORM 模型类），无需修改包内默认文件。

### 1. 生成项目的表结构文件

不同项目的表结构同样用 CSV 定义 + 生成器产出（版本号自定）：

```bash
# 准备项目自己的 CSV 目录（参考 topo_define/*.csv 格式，文件名即表名）
python tools/gen_tables.py -i /path/to/proj_csvdir -o /path/to/proj_tables.py --version 1.0
```

### 2. 注册为项目自定义表结构

```bash
python -m tsysmart_dev_topo.install --action use-tables --tables /path/to/proj_tables.py
# => 校验通过后复制为 <安装目录>/tsysmart_dev_topo/topo_custom/tables.py
```

注册后启动（或重启）服务 / 运行 install / upgrade / import_topo，即自动使用该表结构：

```bash
# 用注册的表结构建表 / 升级 / 导入（无需再传表结构参数）
python -m tsysmart_dev_topo.install --tenant_code <t> --project_code <p> --action install
python -m tsysmart_dev_topo.install --tenant_code <t> --project_code <p> --action upgrade
python -m tsysmart_dev_topo.tools.import_topo --target_tenant <t> --target_project <p> \
    --source_tenant <st> --source_project <sp>

# 启动 API 服务（自动使用注册表结构）
python -m uvicorn tsysmart_dev_topo:app --host 0.0.0.0 --port 8123
```

### 3. 恢复默认 / 查看来源

```bash
python -m tsysmart_dev_topo.install --action unuse-tables   # 删除 topo_custom/tables.py
# 或直接删除 <包目录>/topo_custom/ 目录

# 各命令启动时会打印当前表结构来源与 TOPO_VERSION，例如：
#   [dev_topo] 表结构来源: 项目自定义表结构: .../topo_custom/tables.py
#   [dev_topo] TOPO_VERSION = 1.0
```

> 说明：
> - 表结构在**进程启动期一次性确定**，运行期不切换（多线程安全，无锁）；
> - 注册文件非法（语法错误/缺 `Base`/`TOPO_VERSION`）会在启动加载时报错并回滚，
>   不会残留损坏模块；请用 `gen_tables.py` 生成合法文件后重新注册；
> - `topo_custom/` 不进 wheel 打包，pip 升级不会覆盖它；卸载/清理请用 `unuse-tables`
>   或手动删除目录；
> - 旧版环境变量 `TSYSMART_DEV_TOPO_TABLES` 与运行期 `activate()/reset()` 已移除，
>   统一走持久化注册模式。

---

## 母线拓扑简化（tools/bus_topo.py）

`get_busdata()` 把台账数据（bus / linesegment / lineend / transformer /
开关 / 发电设备 / 负荷等表）构建成拓扑图，再按"电气点"简化：

1. **开关边归一化**：开关在 gen_topo 阶段已成边（ind-jnd）；
   - 合位且投运（`point=1` 且 `run_state=1`）→ 边的 `topo_type` 置为 `None`（同电位连接边）；
   - 分位 → 删除该开关边；
   - 同一对端点的并行开关：任一闭合即视为导通（`gen_topo` 阶段已叠加状态）。
2. **电气点识别与母线合并**（`merge_bus`）：
   - 先断开所有有类型边（`topo_type` 不为 `None`：线路、变压器绕组等）并备份其**原始端点**；
   - 仅按"同电位连接边"求连通分量，每个分量即一个电气点；
   - 分量内有母线 → 合并为一根（台账第一根），记入 `bus_merge_record`，其余设备改挂该母线；
   - 分量内无母线但有线路端子（lineend）→ 设备改挂该 lineend；
     **出现多个 lineend** 视为线路短路 → 记入 `TsysData.warnings` 并打印告警，删除该点内边；
   - 既无母线也无 lineend → 删除分量内边（设备保留，交给岛策略处理）；
   - **恢复有类型边**：按备份的原始端点还原，保持设备之间的关联（不整体重挂到保留母线）；
     仅当端点本身已被合并删除时，才落到该端点的代表节点上；
     两端已并入同一电气点的边直接丢弃 —— **不生成自环边**（建图阶段也会忽略 `ind == jnd` 的输入）。
3. 清理多余虚拟节点、冗余线路端子与空变压器；
4. **去掉电力死岛**（`remove_dead_islands`，默认开）：只统计**投运**（`run_state=1`）的注入设备，满足任一条件即判死岛并删除整岛：
   - 岛内没有电源/负荷/储能等注入设备；
   - 岛内没有投运电源（纯受电岛）；
   - 供电侧（电源）与用电侧（负荷+储能）的**功率区间无交集**：需满足 `供电max ≥ 用电min` 且 `供电min ≤ 用电max`。

   功率字段默认 `p_min`/`p_max`，热力侧为 `thou_min`/`thou_max`，储能充电能力为 `p_charge_min`/`p_charge_max`
   （见 `DEFAULT_INJECTION_FIELDS`）；字段缺失按 0 处理并汇总告警。
   供电侧/用电侧表集合可用 `power_source_types` / `load_types` 覆盖。
   保护：若全图没有任何投运电源，则跳过死岛判定并告警（避免输出空数据集）。
5. **去掉孤岛**（`remove_islands`，默认开）：存在多个（活）分量时，只保留节点最多的主网分量，其余有源孤岛一并删除。

> **储能接入**：储能表没有 `nd` 字段，当 `connect_dev_type` 为 `ACND`（交流节点）/`THND`（热力节点）时，
> `connect_dev_id` 即所关联的 bus 节点号 —— 此时储能会作为设备节点挂入连通图，
> 其**充电能力**（`p_charge_min`/`p_charge_max`）计入用电侧参与功率匹配判定；
> 其它 `connect_dev_type`（指向具体设备）静默忽略、不挂图（该行仍原样输出）。

```python
from tsysmart_dev_topo.tools.bus_topo import get_busdata

# 默认：去掉死岛 + 只保留主网
result = get_busdata(data)

# 只去掉死岛、保留有源孤岛
result = get_busdata(data, remove_islands=False)

# 关闭岛处理（等价旧行为）
result = get_busdata(data, remove_dead_islands=False, remove_islands=False)
```

告警（如"线路短路"）会打印到标准输出，同时记录在 `TsysData.warnings` 列表，
调用方可读取用于上报。

判定“电源”的发电表类型默认取 `DEFAULT_POWER_SOURCE_TYPES`
（`scada_ac_net_unit` / `scada_dc_net_unit` / `scada_therm_net_unit` 等），
不同项目可通过 `power_source_types` 参数传入自己的电源表集合；
若全图没有任何投运电源节点，则跳过死岛删除、只保留最大连通分量并打印告警，避免把整网删空。

## 测试（pytest）

全部用例集中在 `tests/`（**不打进 wheel**，只在源码仓库运行）：

```bash
cd source/scada/python/tsysmart_dev_topo
python -m pytest tests/ -q          # 全量：离线用例直接跑，需要数据库/服务的自动 skip
```

| 用例文件                   | 覆盖内容                                                                               | 依赖                         |
| -------------------------- | -------------------------------------------------------------------------------------- | ---------------------------- |
| `test_bus_topo.py`         | 拓扑简化：开关合并、母线合并、死岛/孤岛、储能挂接、无自环                              | 无（离线）                   |
| `test_tables_provider.py`  | 表结构持久化注册：注册/卸载、隔离校验、加载失败回滚、旧 env 通道已移除                 | 无（离线）                   |
| `test_import_topo.py`      | 映射约定与表结构一致性、SQLite→SQLite 端到端导入（均离线）；SQLite→PostgreSQL 批量导入 | 前两类离线；PG 导入需 env    |
| `test_real_data.py`        | 实际台账数据的简化不变量（无自环、挂接点存在、母线唯一、开关边不残留）                 | 需 env（见下）               |
| `test_api.py`              | TopoService 内部接口 + FastAPI REST 外部接口（增删改查、过滤、批量、**虚拟设备与关联点** / `/topo/datatype`）                   | 需 PostgreSQL / 运行中的 API |
| `test_data_types.py`       | 行业数据类型对照表（`data_types.py`：定义、数量、按行业过滤）            | 无（离线）                   |
| `test_real_db.py`          | 真实库多项目集成（内部接口 + 网关前缀下的 REST）                                       | 需 PostgreSQL / 运行中的 API |
| `test_db_create_tables.py` | 多 schema `create_all` 建表核对                                                        | 需 PostgreSQL                |

辅助文件：`conftest.py`（sys.path）、`_helpers.py`（`Checks` 断言收集 / `env_str` / `http_request`）、
`run_busdata_check.py`（实际数据检查脚本，同时被 `test_real_data.py` 复用）。

### 环境变量总览（未配置即自动 skip，不会让 `pytest tests/` 失败）

| 环境变量                                                           | 用途                                                            | 默认值                             |
| ------------------------------------------------------------------ | --------------------------------------------------------------- | ---------------------------------- |
| `TSYSMART_TOPO_TEST_JSON`                                          | 台账 JSON 文件（`{表名: [行...]}`）驱动 `test_real_data.py`     | 无（跳过）                         |
| `TSYSMART_TOPO_TEST_TENANT` / `TSYSMART_TOPO_TEST_PROJECT`         | 数据库取数（租户 / schema）                                     | `tsysmart_simu_test` / `topic3_da` |
| `TSYSMART_TOPO_TEST_PROJECTS`                                      | `test_real_db.py` 的多项目列表（逗号分隔）                      | `WVN6tNff,topic3_da`               |
| `TSYSMART_TOPO_TEST_API_BASE`                                      | REST 服务地址                                                   | `http://127.0.0.1:8123`            |
| `TSYSMART_TOPO_TEST_API_PREFIX`                                    | `test_real_db.py` 的网关路径前缀                                | `/api`                             |
| `TSYSMART_TOPO_TEST_PG_URL`                                        | PostgreSQL URL（`postgresql+psycopg://user:pass@host:5432/db`） | 无（跳过）                         |
| `TSYSMART_TOPO_TEST_PG_USER` / `_PASSWORD` / `_HOST` / `_DATABASE` | 未给 `PG_URL` 时的分项拼装                                      | `localhost:5432`                   |
| `TSYSMART_TOPO_TEST_SCHEMAS`                                       | `test_db_create_tables.py` 的 schema 列表（逗号分隔）           | 7 个默认 schema                    |
| `TSYSMART_TOPO_TEST_SQLITE_DIR`                                    | SQLite 台账目录（批量导入）                                     | 无（跳过）                         |
| `TSYSMART_TOPO_TEST_SOURCE_VERSION`                                | 导入映射版本                                                    | `THU_1.0`                          |

示例：

```bash
# 用实际台账跑简化不变量
TSYSMART_TOPO_TEST_JSON=netdata.json python -m pytest tests/test_real_data.py -q

# 只跑内部/外部接口用例
python -m pytest tests/test_api.py -k internal -q
python -m pytest tests/test_api.py -k external -q      # 需先启动 API 服务
```

单个文件也支持直接运行（内部调用 pytest）：`python tests/test_bus_topo.py`。

只用实际数据出报告（不跑断言，打印行数对比 / 死岛原因 / 挂接统计）：

```bash
python tests/run_busdata_check.py --data-json netdata.json --out topo_result.json
python tests/run_busdata_check.py --tenant_code henan_test --project_code topic3_da
```
