Metadata-Version: 2.4
Name: tsysmart_dev_topo
Version: 1.5
Summary: Device topology data management module
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: SQLAlchemy>=2.0
Requires-Dist: fastapi>=0.115
Requires-Dist: uvicorn>=0.30
Requires-Dist: pydantic>=2.7
Requires-Dist: networkx>=3.6
Requires-Dist: tsysmart_proj
Provides-Extra: manage
Requires-Dist: tsysmart_appmng; extra == "manage"
Provides-Extra: postgres
Requires-Dist: psycopg-binary>=3.2.0; extra == "postgres"
Provides-Extra: export
Requires-Dist: tsysmart_utils>=1.13; extra == "export"
Provides-Extra: all
Requires-Dist: tsysmart_appmng; extra == "all"
Requires-Dist: psycopg-binary>=3.2.0; extra == "all"
Requires-Dist: tsysmart_utils>=1.13; extra == "all"
Dynamic: description
Dynamic: description-content-type
Dynamic: provides-extra
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary

# tsysmart_dev_topo - 设备拓扑

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

## 安装

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

# 安装（查询 / API 服务 / CLI 查询开箱可用）
pip install dist/tsysmart_dev_topo-*.whl

# 可选：需要 install / uninstall / upgrade 管理命令时，额外装 tsysmart_appmng
pip install 'tsysmart_dev_topo[manage]' -i https://pypi.tsysmart.com/simple/

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

# 验证命令行（安装后即得 `tsysmart_dev_topo` 命令，任意目录可用）
tsysmart_dev_topo --help
tsysmart_dev_topo version
```

## 打包范围（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/`、`cli/`、`tools/`、`install.py`、`upgrade.py`、`tables_provider.py`、`meta.json`、`requirements.txt` | ✅ 是         | 运行期/安装期必需；`cli/` 提供 `tsysmart_dev_topo` 命令                              |

因此：**需要重新生成表结构（改 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.5`）是**表结构版本**，`__version__` 对外暴露的是后者。
CI（`build/utils/ci/check_and_build_wheel.sh`）只在本地包版本高于 PyPI 版本时才构建上传：
PyPI 当前是 **1.3**，本地 **1.5** 会被构建上传；以后再改打包范围/内容时记得继续递增版本号，
否则会被判定"不高于 PyPI 版本"而静默跳过。

## 目录结构

```
tsysmart_dev_topo/
├── pyproject.toml              # 打包配置（wheel 构建）
├── setup.py                    # setuptools 兼容打包入口
├── __init__.py                 # 导出 app, TopoService
├── __main__.py                 # CLI 入口：`python -m tsysmart_dev_topo`
├── meta.json                   # 模块元数据（名称、版本、简介）
├── install.py                  # 安装 / 卸载 / 升级（也是 CLI 管理类子命令的实现）
├── upgrade.py                  # 数据库升级脚本（结构迁移：列改名映射 + 列删除 _DROP_COLUMNS + 增量升级 / 智能重建）
├── 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
├── cli/                        # ★ 命令行（随 wheel 分发，安装后即得命令）
│   ├── __init__.py             # 顶层分发：8 个查询子命令 + 管理类转发
│   ├── query.py                # 查询子命令实现（本地直连数据库，无需起服务）
│   └── __main__.py             # `python -m tsysmart_dev_topo.cli` 入口
├── docs/                       # 说明文档与出图产物（仅源码仓库，不打进 wheel）
│   ├── bus_topo_说明.md         # 拓扑简化算法/建图/岛处理/可视化
│   ├── 拓扑化简演示数据.md       # 演示数据集设计与调参
│   ├── 拓扑岛说明.md             # 岛号规则/岛查询/API/CLI
│   └── images/                 # SVG / HTML 出图产物
├── tests/                      # 测试 + 演示数据 + 调试工具（仅源码仓库，不打进 wheel）
│   ├── README.md               # 总览（结构 / 如何跑测试 / 环境变量）
│   ├── conftest.py             # sys.path 处理
│   ├── _helpers.py             # 断言收集 / 环境变量 / HTTP 助手
│   ├── unit/                   # ★ 离线单元测试（无需 DB）
│   │   ├── test_bus_topo.py
│   │   ├── test_tables_provider.py
│   │   └── test_data_types.py
│   ├── integration/            # ★ 集成测试（DB / REST，缺失自动 skip）
│   │   ├── test_api.py
│   │   ├── test_real_db.py
│   │   ├── test_real_data.py
│   │   ├── test_import_topo.py
│   │   └── test_db_create_tables.py
│   ├── demo/                   # ★ 演示数据生成 + 拓扑 SVG 出图
│   │   ├── gen_demo_topology.py
│   │   ├── gen_ieee33_testdata.py
│   │   ├── draw_topo_svg.py
│   │   └── draw_demo_svg.py
│   └── debug/                  # ★ 调试排查工具（连库打印）
│       ├── run_busdata_check.py
│       └── analyze_sqlite.py
└── tools/                      # 生产库代码 + 代码生成器
    ├── bus_topo.py             # 拓扑简化核心（被 topo_service 依赖）
    ├── 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
        └── ...
```

> **目录分工**：`tools/` 只放**生产库代码与代码生成器**；
> 一切测试、演示数据/出图、调试工具都归 `tests/`（按 `unit`/`integration`/`demo`/`debug` 分）。
> 各子目录的用法见 [`tests/README.md`](tests/README.md)。
>
> 各子目录 README：[`unit`](tests/unit/README.md) ·
> [`integration`](tests/integration/README.md) ·
> [`demo`](tests/demo/README.md) · [`debug`](tests/debug/README.md)。
```

> **C++ 部分**：`lib/devtopolib` 与 `include/devtopolib` 已从本仓库移除；
> `tools/gen_cpp_models.py` 仍可用于生成 C++ 模型头（需自行指定输出目录）。

## 拓扑表清单（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_agg_unit`, `scada_dev_heat_gb`, `scada_dev_heat_hstorage`, `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_ac_net_island`（拓扑岛汇总）与 `scada_ac_net_node`（拓扑节点）
> 由简化过程生成，**不作为输入**读取（已从 `DEFAULT_TOPO_TABLE_NAMES` 排除）。
| 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/integration/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, with_children=False)`

跨表批量查询（返回 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.5', 'project_code': 'topic3_da', 'tables': {'scada_ac_net_load': [...], ...}}
```

`with_children=True` 时，拓扑设备行会额外带 `children`（实体设备子数据），
详见 [实体设备子数据（children）](#实体设备子数据children)。

### 插入

#### `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.5', '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}
```

### 表名/列名与防注入

所有增删改查方法都通过 SQLAlchemy Core/ORM 访问数据库：表名先经
`TopoService._table()` 过滤（只认 `TOPO_TABLE_NAMES`，即 `tables.py` 注册的表；
数据库中存在但未注册的表同样不放行），列名取自该 `Table` 的列，取值一律走绑定参数。因此：

- **表名白名单只做过滤，不报错**：不在白名单的表按“没有这张表”处理 ——
  查询返回空、写操作不生效，不会因表名不对而中断请求；
- 表名/列名只用于取 `Table`/`Column` 对象，由方言编译器加引号，不拼进 SQL 文本，
  SQL 注入载荷无效；
- 列名不属于该表时仍抛出 `ValueError`（REST 层转 400）。

---

## 实体设备子数据（children）

实体设备表（`scada_elec_dev_*` / `scada_therm_dev_*`）与聚合资源表（`scada_agg_unit`）
**不参与建图**，但通过 `connect_dev_type` + `connect_dev_id` 关联到拓扑设备。
返回拓扑数据时，会把它们作为**子数据**挂到父记录的 `children` 字段上。

### 关联码（connect_dev_type）

| code | 含义 | 父表 | 匹配键 |
|---|---|---|---|
| `ACND` | 交流节点 | `scada_ac_net_bus` | `nd` |
| `ACUN` | 交流电源 | `scada_ac_net_unit` | `id` |
| `ACLD` | 交流负荷 | `scada_ac_net_load` | `id` |
| `ACLN` | 交流线段 | `scada_ac_net_linesegment` | `id` |
| `DCND` | 直流节点 | `scada_dc_net_bus` | `nd` |
| `DCUN` | 直流电源 | `scada_dc_net_unit` | `id` |
| `DCLD` | 直流负荷 | `scada_dc_net_load` | `id` |
| `THND` | 热节点 | `scada_therm_net_bus` | `nd` |
| `THUN` | 热源 | `scada_therm_net_unit` | `id` |
| `THLD` | 热负荷 | `scada_therm_net_load` | `id` |
| `AGG` | 聚合资源 | `scada_agg_unit` | `id` |

> 节点类（`*ND`）的 `connect_dev_id` 是**母线表的 `nd`**（连接点号），
> 而非母线主键 `id` —— 与 `bus_topo` 建图口径一致（母线节点 id 即 `nd`）。
> 母线在简化中可能被合并，`get_topology` 会用 `node_redirect` 把原 `nd`
> 重定向到代表节点后再匹配。

### 两层挂载

`scada_agg_unit`（台区 / 微电网 / 虚拟电厂 等聚合资源）是**中间层**：

```
拓扑设备 ←──ACLD/ACUN/...── scada_agg_unit ←──AGG── 实体设备
  (父)              (子+父)                    (子)
```

因此会形成**两级嵌套**，每个 `children` 条目都带 `dev_type` 标注来源表名：

```
scada_ac_net_load(L1).children = [
    { id: A1, dev_type: "scada_agg_unit", agg_type: "台区",   # ← 第一级：聚合资源
      connect_dev_type: "ACLD",
      children: [
          { id: P1, dev_type: "scada_elec_dev_pv",          # ← 第二级：实体设备
            connect_dev_type: "AGG" }
      ] }
]
```

### 返回位置

- **顶层保留**：实体设备表仍按原样出现在结果顶层（不因 `children` 而移除）。
- **`dev_type` 只在 `children` 条目**：标注该子行来自哪张表（如
  `scada_elec_dev_pv` / `scada_agg_unit`）；顶层行不加该字段。
  > 注意与**虚拟设备**的 `dev_type`（设备类型 `pv`/`storage`，见下文「虚拟设备」）区分：
  > 这里是**表名**，两者语义不同。
- **无子行**的父记录也会写入空 `children`（`[]`），保证结构稳定。
- **未收录的 code / 无效 id**（空、`-1`）不挂载，该行仅保留在顶层。

### 三个入口

| 入口 | 是否默认注入 | 说明 |
|---|---|---|
| `get_topology()` | ✅ 始终 | 返回简化后数据，用 `node_redirect` 重定向匹配 |
| `get_island(island_no)` | ✅ 始终 | 读 DB 原始行（母线未合并），直接匹配 |
| `get_devices(..., with_children=True)` | 默认 `False` | 需显式开启；`False` 保持原有返回结构 |
| `get_device(..., with_children=True)` | 默认 `False` | 同上（单表封装） |
| `GET /topo/device/{type}`、`GET /topo/devices` | 默认 `true` | 可用 `with_children=false` 关闭 |

> 内部建图路径（`get_topology` 内读取原始数据）**不会**注入 `children`，
> 以免污染 `bus_topo` 的建图输入。

```python
# 拓扑设备行带 children（两级嵌套，每级带 dev_type）
res = svc.get_topology()
load = res['scada_ac_net_load'][0]
load['children']  # => [{'id': 'A1', 'dev_type': 'scada_agg_unit', 'agg_type': '台区', ...}]
load['children'][0]['children']  # => [{'id': 'P1', 'dev_type': 'scada_elec_dev_pv', ...}]

# 单表查询开启子数据
buses = svc.get_device('scada_ac_net_bus', with_children=True)
```

---

## 外部接口（topo_api.py）

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

| 方法   | 路径                                                         | 说明                                                                                                |
| ------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| GET    | `/topo/version`                                              | API 版本号                                                                                          |
| GET    | `/topo/topology`                                             | 简化拓扑数据（busdata）；`device_types` 可留空=全部表，可选 `remove_dead_islands`/`remove_islands`；`node_view="simplified"`；写库**只写岛表 / 节点表两张输出表**，**不写设备表** |
| GET    | `/topo/raw_topology`                                         | **原始拓扑**（**不简化**，按设备连接关系整理，输出岛信息；`nd/ind/jnd` 即台账原值、**不输出 `raw_*`/`tp*`**、从不写库；`node_view="raw"`） |
| GET    | `/topo/raw_island/{island_id}`                               | **原始口径**的单岛明细（母线不合并；岛号与 `/topo/island` **不同套**；`node_view="raw"`） |
| 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.5"}
```

### GET /topo/topology

返回简化后的拓扑数据（busdata）。`device_types` **可留空**——留空时自动使用**默认拓扑表**
（`DEFAULT_TOPO_TABLE_NAMES` = 全部拓扑表 **去掉 `scada_fes*` 采集侧表** 和
**纯输出表 `scada_ac_net_island` / `scada_ac_net_node`**）；也可显式指定参与简化的表集合。

> `scada_fes*`（遥测/遥信/遥控/遥调点表、通道/RTU/Modbus/IEC104 定义等）属于**数据采集处理**，
> 不是拓扑设备/对象：它们没有 `nd`/`ind`/`jnd`，不参与建图，只会被原样透传到结果里。
> 如确需包含，请在 `device_types` 中显式列出。
>
> `scada_ac_net_island` 是**纯输出表**（由拓扑岛计算生成），不作为输入读取。

#### 写库（默认不写；`update_output_tables=true` 才写两张纯输出表）

`get_topology()` / `GET /topo/topology` **默认是纯读取，不产生任何写入**。
仅当显式传 `update_output_tables=true` 时，才把本次结果写进**两张纯输出表**
（先清空再重填，同一事务，幂等）；**任何情况下都不写设备表**
（既不写 `island` 列，也不写任何节点号列）：

| 可选写库目标 | 内容 |
|---|---|
| `scada_ac_net_island` | 拓扑岛汇总（一行一个岛） |
| `scada_ac_net_node` | 拓扑节点（一行一个合并后电气点） |

> - 参数名 `update_output_tables`（默认 `false`）。该参数**不参与缓存指纹**：
>   命中缓存时同样会按需落库。空结果的表自动跳过，不会用空视图覆盖已有数据。
> - 设备行的岛号 / 节点号仅存在于**返回结果**中，不落库；设备表里的
>   `nd`/`ind`/`jnd` 列在库中仍是**台账原值**，永不被本接口改写。
> - `GET /topo/islands`、`GET /topo/island/{id}`、`GET /topo/device_island/...`
>   一律走**内存快照**，既不读库中输出表、也不写库。
> - CLI：`tsysmart_dev_topo topology --update-output-tables` 才落库；
>   `islands` / `island` 需显式加 `--refresh`（**默认不加**）才会重算并更新这两张输出表。

```bash
# 默认拓扑表（device_types 留空；已排除 scada_fes*）
curl "http://127.0.0.1:8123/topo/topology?tenant_code=henan_test&project_code=topic3_da"

# 指定表集合
curl "http://127.0.0.1:8123/topo/topology?tenant_code=henan_test&project_code=topic3_da&device_types=scada_ac_net_bus,scada_ac_net_lineend,scada_ac_net_linesegment,scada_ac_net_breaker,scada_ac_net_disconnector,scada_ac_net_transformer,scada_ac_net_transformerwinding,scada_ac_net_load,scada_ac_net_unit"

# 对比用：保留死岛（不做死岛删除），便于观察被删掉的岛
curl "http://127.0.0.1:8123/topo/topology?tenant_code=henan_test&project_code=topic3_da&remove_dead_islands=false"
```

#### 参数一览

| 参数 | 必填 | 默认 | 说明 |
|---|---|---|---|
| `tenant_code` / `project_code` | 是 | — | 租户 / 项目 |
| `device_types` | 否 | 空=默认拓扑表 | 逗号分隔设备表；留空用 `DEFAULT_TOPO_TABLE_NAMES`（不含 `scada_fes*` 与纯输出表） |
| `remove_dead_islands` | 否 | `true` | 删除死岛（无投运注入 / 无投运电源）；供需功率区间无交集不算死岛，直接抛 `PowerBalanceError`（模型无解，→ HTTP 400） |
| `remove_islands` | 否 | `true` | 只保留最大的**有源主网**分量 |
| `power_source_types` | 否 | 见下 | 供电侧（电源）表集合，逗号分隔；**整体替换** |
| `load_types` | 否 | 见下 | 用电侧（负荷）表集合，逗号分隔；**整体替换** |
| `injection_fields` | 否 | 见下 | 功率区间字段映射；**与默认合并** |

**`power_source_types` 默认**：
```
scada_ac_net_unit,scada_dc_net_unit,scada_therm_net_unit
```

**`load_types` 默认**：
```
scada_ac_net_load,scada_dc_net_load,scada_therm_net_load,scada_elec_dev_storage
```

**`injection_fields` 默认**（格式：`表名:下限字段,上限字段`，多项用 `;` 分隔；未列出的表默认 `p_min,p_max`）：
```
scada_therm_net_unit:thou_min,thou_max;scada_therm_net_load:thou_min,thou_max;scada_elec_dev_storage:p_charge_min,p_charge_max
```

```bash
# 自定义供电/用电侧 + 功率区间字段
curl "http://127.0.0.1:8123/topo/topology?tenant_code=henan_test&project_code=topic3_da&power_source_types=scada_ac_net_unit,scada_dc_net_unit&load_types=scada_ac_net_load,scada_elec_dev_storage&injection_fields=scada_therm_net_unit:thou_min,thou_max"
```

> `injection_fields` 格式非法会返回 **400**。
> `power_source_types` / `load_types` 为**整体替换**（不叠加默认）；
> `injection_fields` 为**与默认合并**（同名表覆盖、其余保留）——三者语义不同。

### 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"
```

> 同一套能力的**命令行**用法（本地直连数据库、无需起服务）见
> [命令行（CLI）](#命令行cli)。

---

## 安装 / 卸载 / 升级

通过 `tsysmart_appmng` 框架管理（属**选装**依赖 `[manage]`；未安装时这些命令会提示
`pip install 'tsysmart_dev_topo[manage]'`）：

```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.5）：

```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/integration/test_api.py`，
覆盖内部接口和外部接口的增删改查全场景；真实库多项目集成用例在 `tests/integration/test_real_db.py`。

### 运行

```bash
python -m pytest tests/integration/test_api.py -k internal -q     # 仅内部接口
python -m pytest tests/integration/test_api.py -k external -q     # 仅外部接口（需先启动 uvicorn）
python -m pytest tests/integration/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 <目标目录>/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，不丢数据

### 依赖

**必需**（`install_requires`，装 wheel 时自动安装）：

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

**选装**（`extras_require`，按需 `pip install 'tsysmart_dev_topo[<extra>]'`）：

| extra | 包 | 用途 |
| ----- | -- | ---- |
| `manage` | `tsysmart_appmng` | `install` / `uninstall` / `upgrade` 管理命令（`install.py` 延迟导入，不装不影响查询与 API） |
| `postgres` | `psycopg-binary` | 连 PostgreSQL 时的驱动 |
| `export` | `tsysmart_utils` | CLI 导出 E 文件（`--export *.e`） |
| `all` | 以上三者 | 一次装齐 |

---

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

内置 `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`，其余设备改挂该母线
      （输出时设备返回层 `nd` = 代表母线节点（**简化后**），台账原值见 `raw_nd`）；
   - 分量内无母线但有线路端子（lineend）→ 设备改挂该 lineend；
     **出现多个 lineend** 视为线路短路 → 记入 `TsysData.warnings` 并打印告警，删除该点内边；
   - 既无母线也无 lineend → 删除分量内边（设备保留，交给岛策略处理）；
   - **恢复有类型边**：按备份的原始端点还原，保持设备之间的关联（不整体重挂到保留母线）；
     仅当端点本身已被合并删除时，才落到该端点的代表节点上；
     两端已并入同一电气点的边直接丢弃 —— **不生成自环边**（建图阶段也会忽略 `ind == jnd` 的输入）。
3. 清理多余虚拟节点、冗余线路端子与空变压器；
4. **去掉电力死岛**（`remove_dead_islands`，默认开）：只统计**投运**（`run_state=1`）的注入设备，满足以下任一条件即判死岛并删除整岛：
   - 岛内没有电源/负荷/储能等注入设备；
   - 岛内没有投运电源（纯受电岛）。

   **供需功率区间无交集 ≠ 死岛**：岛内有投运电源也有投运负荷、但电源可发区间与负荷需求区间完全不重叠时，
   属于**模型无解**（不是"该分量不该供电"），此时直接抛 `PowerBalanceError`（→ HTTP 400）**截断计算**，
   不做删岛兜底 —— 否则会把有源主网整片删空，并与岛表输出（活岛 / `ngen>0`）自相矛盾。
   区间有交集需满足 `供电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` 参数传入自己的电源表集合；
若全图没有任何投运电源节点，则跳过死岛删除、只保留最大连通分量并打印告警，避免把整网删空。

### 顶层输出：拓扑岛汇总表 `scada_ac_net_island`

简化结果里会额外包含 **`scada_ac_net_island`**（**一行一个岛**，岛级汇总），
用于查看简化后母线拓扑的岛结构。该表**不参与建图**（无 `nd/ind/jnd`），是**纯输出表**。

**编号规则**（基于**简化后**的图）：
- **单设备分量**（只有 1 个节点）不成岛：不写入；
- **活岛**（含投运电源）从 **1** 递增；**死岛**从 **-1** 递减；
- 按节点数降序排序，保证编号确定（最大岛取 1 / -1）；
- 岛号即主键 **`id`**。

> **`island = 0` 不算岛**：只有**多节点**分量才是“岛”；**单节点分量**编号 `0`，
> **不写入岛表**（其设备行的 `island` 字段为 `0`）。
>
> ⚠️ **`remove_dead_islands=true` 删除的不只是岛表里的死岛**：
> `_island_death_reason` 只看有无投运电源、不看规模，故 `island=0` 的孤立无源分量**也会被删**。
> 实测 `load 386→105`（281 = 61 死岛 + 220 个 `island=0`）。
> 详见 `docs/拓扑岛说明.md` 第 1 节（活岛/死岛判据 + `island=0` 说明）。

| 字段 | 含义 |
|---|---|
| `id` | 岛号（活岛 >0，死岛 <0） |
| `name` | `活岛N` / `死岛N` |
| `dead_flag` | 1=死岛，0=活岛 |
| `nbs` | 计算母线数 |
| `nnd` | 节点总数 |
| `nbr` | **支路总数**（简化后母线拓扑的线路段/绕组等有类型边） |
| `ngen` / `nload` | 机组数 / 负荷数（**仅投运**，`run_state==1`） |
| `nst` | 厂站数（岛内 `st_id` 去重，全部） |
| `gnp_max` | **最大发电能力** = `Σ机组 max(0,p_max) − Σ负荷/储能 min(0,p_min)` |
| `ldp_min` | **最大负荷能力** = `Σ负荷/储能 max(0,p_max) − Σ机组 min(0,p_min)` |
| `sumgnp` | **总发电有功** = `Σ机组 max(0,p) + Σ负荷/储能 min(0,p)` |
| `sumldp` | **总负荷有功** = `Σ机组 min(0,p) + Σ负荷/储能 max(0,p)` |
| `slack_gn` | 平衡机组（岛内首个投运电源） |

> **功率字段只统计投运设备**（`run_state==1`）；`p` 为带符号有功（倒送/充电为负）。
> 热力侧用 `thou_min/thou_max`、储能用 `p_charge_min/p_charge_max` 作为区间字段；
> 部分表无 `p` 字段（热力/储能），缺失按 0 计且不告警。
> 计数类字段中，**`ngen`（机组数）/ `nload`（负荷数）只统计投运设备**（`run_state==1`）；
> `nbs` / `nnd` / `nbr` / `nst` 统计岛内**全部**对象（不做投运过滤）。

> 岛表是**纯输出表**：`get_opt_data()` 每次都会重新计算；若输入里带了 island 行会被忽略。
> 落库是**可选**的：`get_topology(update_output_tables=True)`（HTTP `update_output_tables=true`）
> 才把本次算出的岛写回该表 —— **先清空、再重填**（同一事务，幂等）；
> **只写岛表 + 拓扑节点表两张输出表，不写设备表**；默认读取不写库。
>
> `GET /topo/islands` / `GET /topo/island/{id}` / `GET /topo/device_island/...`
> **一律走内存快照**，不依赖库中该表是否最新。

```python
result = get_busdata(data)
for isl in result["scada_ac_net_island"]:
    print(isl["id"], isl["name"], "母线", isl["nbs"], "支路", isl["nbr"],
          "发电能力", isl["gnp_max"], "负荷能力", isl["ldp_min"])
```

### 节点号：返回层 `nd` 为简化值 + `raw_nd` 为台账原值

> **语义已翻转（当前版本）**：返回层 `nd` / `ind` / `jnd` 承载**简化后**的拓扑节点号；
> **台账原始连接点号**改由 **`raw_nd` / `raw_ind` / `raw_jnd`** 承载。
> `raw_*` 是**返回层字段、不落库**（库里设备表的 `nd`/`ind`/`jnd` 列仍只存台账原值）。
> 旧字段 `tpnd` / `tpind` / `tpjnd` **已废弃**：既不再返回，也已从数据库删除
> （由 `upgrade.py` 的 `_DROP_COLUMNS` 增量删除，见「数据库升级」）。

| 字段（返回层） | 含义 | 来源 |
|---|---|---|
| `nd` / `ind` / `jnd` | **简化后**的拓扑节点号 | 每次简化重算 |
| `raw_nd` / `raw_ind` / `raw_jnd` | **台账原始**连接点号 | 台账原值（返回层派生，不落库） |

```
母线:   nd=41001861029001  raw_nd=41001861029001   ← 代表母线，自身即电气点
负荷:   nd=41001861029001  raw_nd=41001861029008   ← 已并入代表母线，原值见 raw_nd
线路:   ind=41001861010003 raw_ind=41001861010016
        jnd=114000125010003 raw_jnd=114000125010059
```

- 判断设备**挂在哪个拓扑节点**要用 `nd`；台账里原本接在哪个连接点看 `raw_nd`。
- 被合并掉的母线**不出现在简化输出里**（只给代表母线），追溯靠 `nd` + 拓扑节点表。
- **为什么 `raw_*` 不入库**：库里设备表只保存台账原值，简化值在返回时现算；
  因此简化后的节点号无需落库，`raw_*` 也只出现在返回层。

### 顶层输出：拓扑节点表 `scada_ac_net_node`

简化结果里还会额外包含 **`scada_ac_net_node`**（**一行一个"合并后的电气点"**），
各设备返回的 `nd` / `ind` / `jnd`（简化值）指向其 `id`。该表**不参与建图**，是**纯输出表**。

| 字段 | 含义 |
|---|---|
| `id` | **拓扑节点号** = 代表节点 id（母线代表 / 线路端子）|
| `name` / `code` / `description` | 代表节点的属性（台账行透传）|
| `st_id` / `bv_id` | 代表节点的厂站 / 电压类型 |
| `isl` | 岛号（活岛 >0、死岛 <0、未入岛 0）|
| `v` / `a` / `vbase` | 电压 / 相角 / 电压基准（台账有则透传）|

- **覆盖范围**：电气点在**删除死岛/孤岛之前**采集（`topo_node_snapshot`），
  因此**活岛与死岛的电气点都会列出**，与岛表（含负岛号）保持一致；
  这样任何设备返回的 `nd`（简化后）都能查到，不会悬空。
- **写库**：`get_topology()` 每次都会**先清空、再重填**（与岛表在同一次调用内执行）。

**携带节点号的返回字段（按设备表）**：

| 字段（返回层） | 表 |
|---|---|
| `nd` + `raw_nd` | `scada_ac_net_bus` / `_load` / `_unit` / `_lineend` / `_transformerwinding`、`scada_dc_net_bus` / `_unit` / `_load` / `_lineend`、`scada_therm_net_bus` / `_unit` / `_load` |
| `ind` + `jnd`（及 `raw_ind`/`raw_jnd`） | `scada_ac_net_linesegment` / `_breaker` / `_disconnector` / `_grounddisconnector`、`scada_dc_net_breaker` |

> **数据库设备表已无 `tp*` 列**：`tpnd`/`tpind`/`tpjnd` 已由 `upgrade.py` 的
> `_DROP_COLUMNS` 删除（18 张设备表，增量执行、重复执行幂等）；设备表只保留
> `nd`/`ind`/`jnd` 台账原值列。简化后的节点号只出现在**返回层**的 `nd`/`ind`/`jnd`。
>
> **返回层 `nd`/`ind`/`jnd` 取值只有两种**：已注册的拓扑节点 id，或 **`NULL`**（未归属拓扑节点）。
> 解析结果若不是已注册节点（如**开位开关**的端点、**无母线/端子的孤立分量**设备），
> 一律为 `NULL` —— 因此一旦有值必然能在节点表查到，**不会悬空**。

#### 如何解读返回层的 `nd` / `ind` / `jnd`（简化值）

> 这里的 `ind`/`jnd` 是**简化后**的拓扑节点号；台账原值见 `raw_ind`/`raw_jnd`，供对照。

**① `ind == jnd` → 两端同一电气点**（对开关即**合位**）。
实测分离度 100% 干净（`topic3_da` 820 台开关）：

| `point` | 总数 | `ind == jnd` | `ind != jnd` |
|---|---|---|---|
| **1 合位** | 449 | **383** | **0** |
| **0 分位** | 371 | **0** | 28 |

> 例：`佛111开关`（合位）`raw_ind=41001861010015` / `raw_jnd=41001861010003`
> → `ind = jnd = 41001861010003`（裸点被并入母线「佛110kV虚拟Ⅰ母」的电气点）。
>
> 线路若两端被短接也会相等（此类自环边会被丢弃，故正常线路恒为不等）。

**② 一端有值、一端 `NULL` → `NULL` 侧是"悬空连接点"**，不属于任何电气点。
**只出现在分位开关**（实测合位 0 例）。原因：分位开关的边已被删除、不进简化拓扑，
两端各自归属其它连接形成的电气点，而悬空侧是：

| 悬空原因 | 删除位置（不记 `node_redirect`） | 实测 |
|---|---|---|
| 裸连接点（非母线/端子 `nd`，所在分量无母线也无 lineend） | `del_virtual_nodes`（度 ≤1） | 191/192 |
| 冗余线路端子（不接线路、不挂设备） | `del_lineend_nodes` | 1/192 |
| `-1` / 空占位 | — | 0 |

**③ 两端都 `NULL`** → 两端都悬空（合位但分量无母线/无 lineend、或 `ind=jnd=-1`）。

**④ 方向对齐**：`ind` 恒对应台账 `ind`、`jnd` 恒对应 `jnd`
（`graph.edges` 顺序可能与台账相反，已由 `_orient_topo_pair` 校正）。

### 接口速查（输入 / 输出）

| 接口 | 作用 | 必填输入 | 返回（顶层键） |
|---|---|---|---|
| `GET /topo/version` | 探活 / 查表结构版本 | — | `version` |
| `GET /topo/topology` | 简化拓扑：跑一遍简化；**默认不写库**，`update_output_tables=true` 时只写岛表 / 节点表两张输出表（不写设备表）；`node_view="simplified"` | `tenant_code`、`project_code` | `version`、`project_code`、`node_view`、`table_count`、`tables` |
| `GET /topo/raw_topology` | **原始拓扑**：**不简化**，按设备连接关系整理，输出**岛信息**；`nd/ind/jnd` 即台账原值、**不输出 `raw_*`/`tp*`**、从不写库；`node_view="raw"` | 上两者 | `version`、`project_code`、`node_view`、`table_count`、`tables` |
| `GET /topo/islands` | 岛**汇总列表**（一行一个岛，含母线/支路/机组/负荷计数与功率指标） | `tenant_code`、`project_code`（+ `refresh`） | `version`、`project_code`、`count`、`islands` |
| `GET /topo/raw_islands` | 岛**汇总列表**（**原始口径**，不简化；岛号与 `/topo/islands` **不同套**） | 同上 | 同上 |
| `GET /topo/island/{island_id}` | 单个岛**明细**（**简化口径**）：母线 / 支路 / 电源 / 负荷 / 电压等级 | 上两者 + `island_id`（路径） | `island`、`summary`、`bus`、`branch`、`source`、`load`、`voltage_level`、`counts` |
| `GET /topo/raw_island/{island_id}` | 单个岛**明细**（**原始口径**，不简化；岛号与上一行**不同套**） | 上两者 + `island_id`（路径） | 同上（另含 `topology`/`simplified`） |
| `GET /topo/device_island/{device_type}/{dev_id}` | **设备所在岛**；可选返回其所连**节点上的全部设备**（简化前/后两种口径） | 上两者 + `device_type`、`dev_id`（路径） | `dev_type`、`dev_id`、`name`、`island`、`simplified`、`node`、`nodes`、（`devices`/`device_count`） |
| `GET /topo/device/{device_type}` | 查**单张设备表**原始记录（支持 `where` 过滤；可带 `children`） | 上两者 + `device_type`（路径） | `version`、`project_code`、`device_type`、`row_count`、`data` |
| `GET /topo/devices` | 批量查**多张设备表**原始记录（逗号分隔表名） | `tenant_code`、`project_code`、`device_types` | `version`、`project_code`、`tables`、`table_count`、`row_count` |
| `GET /topo/datatype` | 行业**数据类型对照表**（供虚拟设备选 `data_type`） | `tenant_code`、`project_code` | `version`、`project_code`、`total`、`categories`、`industries`、`types` |

#### 响应样例

**`GET /topo/topology`**（`tables` 按表名分组；`node_view="simplified"`；每行返回层 `nd` 为**简化后**节点、`raw_nd` 为**台账原值**）：

```jsonc
{
  "version": "1.5", "project_code": "topic3_da", "node_view": "simplified", "table_count": 15,
  "tables": {
    "scada_ac_net_bus": [
      { "id": "115404741139239360", "name": "佛35kVⅠ母",
        "nd": "41001861029001",      // 简化后拓扑节点（母线自身即电气点）
        "raw_nd": "41001861029001",  // 台账原始连接点（返回层，不落库）
        "island": "1", "children": [] }
    ],
    "scada_ac_net_load": [
      { "id": "115967692317393517", "name": "佛110kV(中行)负荷",
        "nd": "114000038007003", "raw_nd": "114000038007015", "island": "1",
        "children": [               // ★ 两级嵌套
          { "id": "115686217340682368", "name": "沿河02台区",
            "connect_dev_type": "ACLD", "agg_type": "台区",
            "dev_type": "scada_agg_unit",
            "children": [ { "id": "115686217340682368",
                            "connect_dev_type": "AGG",
                            "dev_type": "scada_elec_dev_load" } ] }
        ] }
    ]
  }
}
```

**`GET /topo/islands`**：

```jsonc
{
  "version": "1.5", "project_code": "topic3_da", "count": 21,
  "islands": [
    { "id": "1", "name": "活岛1", "dead_flag": 0,
      "slack_gn": "信阳.华光风电场/35kV.1号风机",
      "nst": 33, "ngen": 10, "nload": 103, "nbs": 49, "nnd": 221, "nbr": 110,
      "gnp_max": 121.5, "ldp_min": 1210000.0,
      "sumgnp": 1.790544, "sumldp": 101.269076 }
  ]
}
```

**`GET /topo/island/1`**（分类字段已去复数）：

```jsonc
{
  "island": 1,
  "summary": { "id": "1", "name": "活岛1", "nbs": 49, "nnd": 221, "nbr": 110 },
  "bus":    [ { "id": "…239360", "name": "佛35kVⅠ母", "nd": "41001861029001",
                "raw_nd": "41001861029001", "bv_id": "…", "vl_id": "…", "island": "1" } ],
  "branch": [ { "id": "…081026", "name": "Ⅰ佛官线",
                "ind": "41001861010003", "jnd": "114000125010003",
                "raw_ind": "41001861010016", "raw_jnd": "114000125010059" } ],
  "source": [ { "id": "…951678", "name": "…佛储#1机", "nd": "41001861029001",
                "raw_nd": "41001861029020" } ],
  "load":   [ { "id": "…665016", "name": "佛35接地变1负荷",
                "nd": "41001861029001", "raw_nd": "41001861029008" } ],
  "voltage_level": { "basevoltage": [...], "voltagelevel": [...] },
  "counts": { "bus": 49, "branch": 110, "source": 10, "load": 101,
              "basevoltage": 4, "voltagelevel": 35 }
}
```

**`GET /topo/device_island/...`** 三种形态：

```jsonc
// (a) 默认：只给岛号与节点
{ "island": "1", "simplified": true,
  "node": "41001861029001", "nodes": ["41001861029001"] }

// (b) with_node_devices=true（简化后）→ 节点上 21 个设备
{ "island": "1", "simplified": true, "node": "41001861029001",
  "device_count": 21,
  "devices": [ { "dev_type": "scada_ac_net_breaker", "id": "…135487",
                 "name": "佛351手车开关", "island": "1" } ] }

// (c) simplified=false（简化前）→ 节点上仅 2 个设备
{ "island": "1", "simplified": false, "node": "41001861029008",
  "device_count": 2,
  "devices": [ { "dev_type": "scada_ac_net_disconnector", "id": "…863376",
                 "name": "佛35接地1手车刀闸", "island": "1" } ] }
```

**错误响应**：非法过滤列名 → **HTTP 400**；表名不在白名单（或不存在）→ **HTTP 200** 且返回空结果
（`island=null`、`nodes=[]`）；不存在的设备 → **HTTP 200** 且 `island=null`、`nodes=[]`。

### 设备所在岛查询（`get_device_island` / `GET /topo/device_island/...`）

给定**设备表名 + 设备 id**，查询它在哪个岛；可选返回**所连节点上的全部设备**。

```python
svc.get_device_island('scada_ac_net_load', '115967691092665016',
                      with_node_devices=True, simplified=True)
```

| 参数 | 默认 | 说明 |
|---|---|---|
| `with_node_devices` | `False` | 是否附带该节点上的**全部设备** |
| `simplified` | `True` | `True`=**简化后**拓扑节点（与 `/topo/topology` 返回的 `nd`/`ind`/`jnd` 同域）；`False`=**简化前**原始连接点（即返回层的 `raw_*` 台账原值，含被合并设备） |
| `refresh` | `False` | 忽略缓存、重建快照与索引 |

返回 `{dev_type, dev_id, name, island, simplified, node, nodes, devices?, device_count?}`：

- `island`：活岛 `>0`、死岛 `<0`、单设备/未入图 `0`、不参与岛计算 `null`
- `nodes`：该设备**触达的全部节点**（去重 + **字典序升序**）。**两端设备可能 2 个**
- `node`：**恒等于 `nodes[0]`**（无节点时 `null`）。单端设备二者等价，给 `node` 只是省去取值
- `devices`：设备清单，条目为 `{dev_type, id, name, island}`，**按 (表名, id) 排序**；
  **只覆盖 `node`（= `nodes[0]`）这一个节点，不含另一端**

| 设备类型 | `nodes` 个数 | 说明 |
|---|---|---|
| 单端设备（母线 / 负荷 / 电源 / 挂接绕组） | **1** | `node` 与 `nodes[0]` 相同 |
| 两端设备（线路段 / 开关 / 双绕组变） | **2**（简化后若两端被合并到同一代表母线则降为 **1**，如闭合开关） | 线路段有阻抗不合并，仍为 2 |

> ⚠️ **两个易混点**
> 1. `nodes` 先排序再回填（保证与 `refresh` 无关、结果可复现），因此 **`nodes[0]`
>    取的是字符串最小**的节点号，**不保证**是台账 `ind`/首端。
> 2. `island` 对两端设备取**先遇到的那端**（`node_island[u]` 优先，退化到 `v`）；
>    仅当两端分属不同岛（如分位开关）才可能看出差异。
>
> 两端设备若要查两端的设备清单，请按 `nodes` 中每个节点分别查询，或直接用
> `get_devices` 拉全量。

```bash
# HTTP
curl "http://127.0.0.1:8123/topo/device_island/scada_ac_net_load/115967691092665016?tenant_code=tsysmart&project_code=test&with_node_devices=true"

# CLI（安装 wheel 后即可用，无需源码树）
tsysmart_dev_topo device-island scada_ac_net_load 115967691092665016 \
    --tenant tsysmart --project topic3_da --with-node-devices [--before]
```

> **缓存**：与 `GET /topo/topology` 共用全量快照 + 索引（命中 ~0.1ms）；
> `list_islands` / `get_device` 不缓存。详见 `docs/拓扑岛说明.md` §4.4。

### 岛查询 API / CLI（岛号不再回写设备表）

`get_topology()` **从不写设备表**：岛号只存在于**返回结果**中（显式
`update_output_tables=true` 时还会写进 `scada_ac_net_island` / `scada_ac_net_node`
两张输出表），不再写回各设备表的 `island` 字段（设备表只保留台账列）。
死岛即使已从设备输出中删除，其设备在**返回结果**里仍会拿到**负岛号**，
便于可视化区分活岛/死岛。

视图标识：`/topo/topology` 返回 `node_view="simplified"`、`/topo/raw_topology`
返回 `node_view="raw"`；单岛 / 设备口径接口则以 `topology`（`"simplified"`/`"raw"`）
与 `simplified`（`true`/`false`）字段区分。

查询接口（均走内存快照）：

| 接口 | 说明 |
|---|---|
| `GET /topo/islands` | 岛汇总列表（派生自内存快照） |
| `GET /topo/island/{island_id}` | 单岛明细（**简化口径**）：**母线 / 支路 / 电源 / 负荷 / 电压等级**（负号可查死岛） |
| `GET /topo/raw_island/{island_id}` | 单岛明细（**原始口径**，不简化；岛号与上一行**不同套**） |

```bash
# 列出所有岛（默认不写库，走内存快照）
tsysmart_dev_topo islands --tenant tsysmart --project topic3_da

# 重算并更新岛表 / 节点表（显式写库）
tsysmart_dev_topo islands --tenant tsysmart --project topic3_da --refresh

# 查看活岛 1 明细（--brief 只打印计数）
tsysmart_dev_topo island 1 --tenant tsysmart --project topic3_da --brief

# 保留死岛（同时关闭两个删除开关）后再查询
tsysmart_dev_topo islands --tenant tsysmart --project topic3_da --keep-all-islands

# JSON 输出（stdout 纯净，可被脚本解析）
tsysmart_dev_topo islands --tenant tsysmart --project topic3_da --json | jq '.count'
```

> `--json` 的 stdout **只有 JSON**（第三方导入噪声、日志、告警全部转 stderr），
> 因此可直接 `| jq` 或 `json.loads()`。

> 完整说明见 **`docs/拓扑岛说明.md`**（编号规则、岛号语义、API/CLI 用法、计数口径）。

## 命令行（CLI）

安装 wheel 后即得命令 **`tsysmart_dev_topo`**（也可用 `python -m tsysmart_dev_topo`
或 `python -m tsysmart_dev_topo.cli`，**无需源码树**）。

```bash
pip install tsysmart_dev_topo
tsysmart_dev_topo --help
```

### 查询类（**本地直连数据库**，无需启动 REST 服务）

| 子命令 | 对应 REST 接口 | 说明 |
|---|---|---|
| `version` | `GET /topo/version` | 表结构版本 |
| `topology` | `GET /topo/topology` | 简化拓扑（默认各表行数；`--table T` 看单表；**只写岛表 / 节点表两张输出表**） |
| `raw-topology` | `GET /topo/raw_topology` | **原始拓扑**（不简化，含岛信息；`nd/ind/jnd`=台账原值，无 `raw_*`/`tp*`） |
| `islands` | `GET /topo/islands` | 岛汇总列表（**简化口径**） |
| `raw-islands` | `GET /topo/raw_islands` | 岛汇总列表 —— **原始口径**（不简化；岛号与 `islands` 不同套） |
| `island N` | `GET /topo/island/{id}` | 单岛明细（`N` 可为负，如 `-1` 死岛） |
| `raw-island N` | `GET /topo/raw_island/{id}` | 单岛明细 —— **原始口径**（不简化；岛号与 `island` 不同套） |
| `device-island TYPE ID` | `GET /topo/device_island/...` | 设备所在岛（`--with-node-devices`、`--before`） |
| `device TYPE` | `GET /topo/device/{type}` | 单表记录（`--where`、`--no-children`） |
| `devices T1,T2` | `GET /topo/devices` | 多表记录（**类型可留空=全部表**，见下） |
| `datatype` | `GET /topo/datatype` | 数据类型对照表 |

```bash
# 1) 简化拓扑（只写岛表 / 节点表两张输出表，不写设备表）
tsysmart_dev_topo topology --tenant tsysmart --project topic3_da

# 1c) 导出到文件（JSON / E 文件，按扩展名或 --format）
tsysmart_dev_topo topology --tenant tsysmart --project topic3_da --export /tmp/topo.json
tsysmart_dev_topo topology --tenant tsysmart --project topic3_da --export /tmp/topo.e
tsysmart_dev_topo raw-topology --tenant tsysmart --project topic3_da \
    --export /tmp/raw.e --format efile

# 1c2) 导出**全部设备数据**（devices 的 device_types 可留空）
tsysmart_dev_topo devices --tenant tsysmart --project topic3_da --export /tmp/devices.json
#   ↑ 留空 = 全部**默认拓扑表**（约 66 张，排除 scada_fes* 采集侧与纯输出表）
tsysmart_dev_topo devices --all-tables --tenant tsysmart --project topic3_da \
    --export /tmp/devices_all.e      # ← 含 scada_fes*，约 93 张
#   导出/打印时**自动跳过空表**（本项目 66 张里只有 15 张有数据）

# 1d) 原始拓扑（不简化；按设备连接关系整理，含岛信息）
tsysmart_dev_topo raw-topology --tenant tsysmart --project topic3_da

# 1e) 原始口径的单岛明细（岛号与 island 子命令不是同一套）
tsysmart_dev_topo raw-island 1 --tenant tsysmart --project topic3_da --brief

# 2) 岛汇总 / 单岛明细
tsysmart_dev_topo islands --tenant tsysmart --project topic3_da
tsysmart_dev_topo island 1 --tenant tsysmart --project topic3_da --brief

# 3) 设备所在岛 + 该节点上的全部设备（简化后 / 简化前）
tsysmart_dev_topo device-island scada_ac_net_load 115967691092665016 \
    --tenant tsysmart --project topic3_da --with-node-devices
tsysmart_dev_topo device-island scada_ac_net_load 115967691092665016 \
    --tenant tsysmart --project topic3_da --with-node-devices --before

# 4) 单表 / 多表查询（支持 where 过滤）
tsysmart_dev_topo device scada_ac_net_bus --tenant tsysmart --project topic3_da \
    --where '{"run_state": 1}' --limit 5
tsysmart_dev_topo devices scada_ac_net_bus,scada_ac_net_unit \
    --tenant tsysmart --project topic3_da

# 5) 数据类型对照表
tsysmart_dev_topo datatype --tenant tsysmart --project topic3_da --json | jq '.total'
```

#### 导出到文件（**全部查询子命令**，`version` 除外）

| 参数 | 说明 |
|---|---|
| `--export PATH` | 写入文件；格式按 `--format`，或按扩展名推断（`.e`/`.efile`/`.edat` → E 文件，其余 → JSON） |
| `--format json\|efile` | 显式指定格式 |
| `--delimiter \t\|' '` | E 文件字段分隔符（默认 tab） |
| `--efile-code` | E 文件头 `Code`（默认 `GB2312`；可设 `UTF-8`） |
| `--output PATH` | **[已废弃]** 等价于 `--export`，始终写 JSON（保持兼容） |

**支持的子命令**（`version` 无导出意义）：`topology` / `raw-topology` /
`islands` / `raw-islands` / `island N` / `raw-island N` /
`device-island TYPE ID` / `device TYPE` / `devices [T1,T2]` / `datatype`。

```bash
# 岛：汇总 / 单岛明细
tsysmart_dev_topo islands      --tenant tsysmart --project topic3_da --export /tmp/islands.json
tsysmart_dev_topo raw-islands  --tenant tsysmart --project topic3_da --export /tmp/raw_isl.json
tsysmart_dev_topo island 1     --tenant tsysmart --project topic3_da --export /tmp/island1.e
tsysmart_dev_topo raw-island 1 --tenant tsysmart --project topic3_da --export /tmp/raw_island1.e

# 设备：单表 / 多表（多表可留空 = 全部表）
tsysmart_dev_topo device  scada_ac_net_bus --tenant tsysmart --project topic3_da --export /tmp/bus.json
tsysmart_dev_topo devices --tenant tsysmart --project topic3_da --export /tmp/devices.json
tsysmart_dev_topo device-island scada_ac_net_load 115967691092665016 \
    --with-node-devices --export /tmp/di.json
tsysmart_dev_topo datatype --export /tmp/dt.json
```

> **E 文件**复用平台 `tsysmart_utils.efile_io`（`EBook`）。E 文件是**扁平文本**，
> 无法表示嵌套结构 → **`children` 列会被自动剔除**（执行时会提示到 stderr；
> 需要 `children` 请用 JSON）。文件按头部 `Code` 编码（默认 GB2312 → gb18030）。
>
> 导出内容是**以表名为键的块**（岛/明细为 `summary`/`bus`/`branch`/… 等固定块名）。

**公共参数**

| 参数 | 说明 |
|---|---|
| `--tenant, -t` | 租户编码（默认 `tsysmart`） |
| `--project, -p` | 项目编码（默认 `test`） |
| `--json` | JSON 输出；**stdout 只有 JSON**（噪声转 stderr），可 `\| jq` |
| `--limit N` | 每类最多打印条数，`0`=不限 |
| `--refresh` / `--no-refresh` | 是否先跑一次 `get_topology` 重算并**更新岛表 / 节点表**（**默认否** = 不写库，直接用缓存/按需重建快照） |
| `--update-output-tables` | 仅 `topology`：读取时**更新两张纯输出表**（`scada_ac_net_island` / `scada_ac_net_node`）；默认不写库 |
| `--keep-all-islands` | 刷新时保留全部活岛与死岛 |
| `--export PATH` / `--format json\|efile` | **全部查询子命令**（`version` 除外）：导出到文件（详见上文） |
| `--all-tables` | 仅 `devices`：留空时改为导出**全部模型表**（含 `scada_fes*`，约 93 张） |

> 读取接口**默认不写库**：`topology` 需显式加 `--update-output-tables`、
> `islands` / `island` 需显式加 `--refresh`，才会更新岛表 / 节点表两张输出表。
> 设备表在任何情况下都不写。

### 管理类（转发给 `install.py`，仍按 `--action` 解析）

```bash
tsysmart_dev_topo install   --tenant_code <t> --project_code <p>
tsysmart_dev_topo upgrade   --tenant_code <t> --project_code <p>
tsysmart_dev_topo uninstall --tenant_code <t>
tsysmart_dev_topo query     --tenant_code <t>
tsysmart_dev_topo use-tables --tables /path/to/proj_tables.py
tsysmart_dev_topo unuse-tables
```

> 与旧写法等价：`python -m tsysmart_dev_topo.install --action install ...`（保持可用）。

---

## 测试（pytest）

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

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

| 用例文件                   | 覆盖内容                                                                               | 依赖                         |
| -------------------------- | -------------------------------------------------------------------------------------- | ---------------------------- |
| `test_bus_topo.py`         | 拓扑简化：开关合并、母线合并、死岛/孤岛、储能挂接、无自环、**返回层 `nd`=简化值 / `raw_nd`=台账原值 + 拓扑节点表** | 无（离线）                   |
| `test_connect_dev.py`      | 实体设备关联：code→(表,键) 映射、两级嵌套、`dev_type` 标注、母线合并重定向               | 无（离线）                   |
| `test_tables_provider.py`  | 表结构持久化注册：注册/卸载、隔离校验、加载失败回滚、旧 env 通道已移除                 | 无（离线）                   |
| `test_cli_query.py`        | CLI 写库开关透传：`topology` 默认不写库 / `--update-output-tables`；`islands`/`island` 的 `--refresh` 默认关 | 无（离线）                   |
| `test_packaging.py`        | 打包依赖声明（AST 静态解析）：`tsysmart_appmng` 必须是 `[manage]` 选装 extra，不得进 `install_requires` | 无（离线）                   |
| `test_connect_dev_children.py` | 子数据 `children` 端到端（`get_topology`/`get_island`/`get_devices` 开关）         | 无（临时 SQLite）            |
| `test_topo_node_numbers.py` | 节点号语义（`nd`/`ind`/`jnd`=简化值、`raw_*`=台账原值）、拓扑节点表、岛粒度=简化后、children 经简化节点解析、幂等 | 无（临时 SQLite）            |
| `test_device_island.py`    | 设备所在岛：三形态返回、两端设备双节点、`--with-node-devices`、简化前/后口径、无效 id   | 无（临时 SQLite）            |
| `test_topo_cache.py`       | 拓扑结果缓存：参数指纹、TTL、写方法失效、`db_url` 隔离、浅拷贝返回（含集成场景）        | 无（临时 SQLite）            |
| `test_write_back_and_raw.py` | **读取默认不写库**、显式 `update_output_tables=True` 才写岛表 / 节点表两张输出表、**不写设备表**；`list_islands` 走快照；`get_raw_topology`（不简化、无 `raw_*`/`tp*`、含岛、从不写库、开关口径）；**`get_raw_island`**（结构与 island 一致、不简化、无 `raw_*`/`tp*`、不写库、**独立缓存槽不污染简化快照**） | 无（临时 SQLite）            |
| `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                |

> 当前离线+SQLite 用例合计 **202 passed**（另 24 项因缺 PG / 未启动服务自动 skip）。
> 分目录：`tests/unit` 103 项（全离线）· `tests/integration` 123 项（视环境部分 skip）。

辅助文件：`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/integration/test_real_data.py -q

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

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

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

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