☰
电网拓扑数据持久化:基于JSON的序列化与反序列化设计与实现
2026/10/6 9:24:07 网站建设 项目流程

写课设的时候,有个细节挺有喜感:我截稿前的文档标题写的是“电网拓补的序列化和反序列化”,后面才意识到“拓补”应该是“拓扑”。不过说真的,这两个字在课程设计文档里出现频率高到我已经默认了,后文我还是统一用“拓扑”,标题里那个就让它留着吧,实在改不动了。智能电网模拟这个课设做到第三期,前两期把拓扑模型和潮流求解器搭完了,但一直有个问题卡着——程序一关,电网拓扑数据全丢。这期就把这个问题解决掉,核心任务就是电网拓扑的序列化和反序列化:把内存里的网络模型落到文件里,再完整无歧义地读回来,顺便把加载后的拓扑合法性校验也做了。这篇文章我会先讲为什么课设阶段必须自研一套简单的序列化方案,再讲数据模型怎么定型、JSON格式怎么设计、两阶段反序列化怎么做,最后把我在实际开发中踩过的几个坑原原本本列出来。适合正在做类似课设、或者想自己写一个带存档功能的小型仿真系统的同学参考。

1. 为什么非得自己搞一套序列化

1.1 直接存表?先看为什么不能抄数据库作业

最容易被问的问题是:电网拓扑数据为什么不直接塞进 SQLite 或者 MySQL?课设里很多同学确实这么干,节点一张表、支路一张表,外键关联,查询还很方便。我不反对,但我的场景是单机仿真程序,拓扑数据在内存里是一棵图结构,运行中的潮流计算需要频繁访问邻接关系,每次算完都要从数据库重新组装图对象,绕了一圈反而更慢。更麻烦的是,数据库方案在你换机器、交作业、给助教演示的时候,还得带着一份数据库文件和建表脚本,环境一变说不定连驱动都装不上。文本文件就没有这个问题,一个.grid文件拷过去,程序解析完就能跑。

我也试过直接用编程语言自带的序列化机制,比如 Java 的 ObjectOutputStream、C++ 里直接把结构体内存写到文件。前者的对象图序列化强依赖类版本,稍改一个成员变量,老存档就读不了;后者听着快,但字节序、结构体对齐、指针字段全是坑。我在早期版本图省事,直接把指针地址写进文件,加载的时候读回来当指针用,结果第二次启动程序直接崩溃。这个错犯得特别经典,网上随便搜都能看到一堆人栽过。指针是进程私有的地址,换个进程、换个时间点,地址完全不可复用,这种方案天生就不该用。

1.2 参考行业里的“模型交换”是怎么做的

电力系统领域其实很早就有模型交换的需求。真实电网的仿真计算软件里,BPA 的卡片格式、PSASP 的工程文件,本质都是把拓扑和参数写成有规则的文本块;再往上就是 IEC 61970 标准里的 CIM 模型,用 RDF/XML 描述对象和对象之间的引用关系,调度自动化系统导入模型就是标准的反序列化过程。CIM 里每个设备有个全局唯一的 mRID,其他对象通过 mRID 引用它,这和我最后在课设里用字符串 ID 索引节点、边的方式,思想完全一致。CIM 当然太重了,完整实现它得引入一整套 UML 类和命名空间,不是一个课设该干的事,但“用稳定ID做引用、用文本做载体”这个思路是通用的,值得借鉴。

所以结论就清楚了:课设阶段的合理路线,是自己定一个足够简单、可读、好调试的文本格式,把内存里的拓扑图序列化成文件,再写一个反序列化器把文件恢复成图对象。重点不是技术多高级,而是格式要稳定、解析要可靠、校验要到位。

2. 数据模型怎么定:先有图数据结构,后有文件格式

2.1 节点和边:一张图的最小描述

序列化格式不是凭空想出来的,它是内存数据模型的投影。所以在讨论文件格式之前,得先把拓扑图在内存里长什么样说清楚。我的模型设计得很朴素:一个电网拓扑就是一张无向图,节点代表母线、开关、负荷、发电机等设备端口,边代表线路和变压器支路。节点有类型、名字、电压等级,边有类型、首末端节点ID和电气参数。

数据结构用 C++ 写出来大致是这样:

enum class NodeType { BUS, SWITCH, LOAD, GENERATOR, TRANSFORMER_WINDING }; struct TopologyNode { std::string id; std::string name; NodeType type; double voltage_kv = 0.0; // 额定电压 bool switch_closed = true; // 仅 SWITCH 节点有效 }; enum class EdgeType { LINE, TRANSFORMER }; struct TopologyEdge { std::string id; EdgeType type; std::string from_node; std::string to_node; double r_pu = 0.0; // 电阻标幺值 double x_pu = 0.0; // 电抗标幺值 double b_pu = 0.0; // 对地导纳标幺值 int tap_position = 0; // 变压器分接头档位 bool switchable = false; bool switch_closed = true; }; struct TopologyGraph { std::map<std::string, TopologyNode> nodes; std::vector<TopologyEdge> edges; };

这里有个关键点:边里存的是节点 ID 字符串,不是指向节点的指针。图对象真正在内存里运行时,可以根据 ID 到nodes里查指针,但存储层永远只碰 ID。这句话是整篇序列化设计的灵魂。

2.2 用ID索引代替指针:序列化的第一步

用 ID 而不是指针,带来的好处非常直接。首先,序列化器只需要把 ID 当成字符串写进文件,不需要知道对象地址;反序列化器也一样,读完 ID 再去 map 里找对应的节点对象,两头都是确定性操作。其次,节点的增删不会影响边对象的存储内容,只影响 map 里的条目。再有就是可读性,你在文件里看到"from": "BUS_110A"一眼就能看出连的是哪条母线,调试的时候打开 JSON 文件就能人工核对。

我用一个表格对比过几种引用方案,最终才定下来:

引用方式存储体积可读性插入删除稳定性跨进程可用性
内存指针最小极差差不可用
数组下标较小差差,元素移动后索引失效可用但不稳定
字符串 ID较大好好,ID与存储位置无关稳定可用

课设里电网规模不大,几百条边的场景,多出来的那点字符串体积根本不在话下。为了稳定性和调试体验,字符串 ID 是明显值得的。

3. JSON保存格式详解:把存档文件当合同来看

3.1 顶层结构:元信息、节点区、支路区

格式载体我在 JSON、XML、自定义文本块之间犹豫了一阵。XML 被 CIM 标准带得很正统,但手写序列化器时标签太啰嗦;自定义文本块像 BPA 卡片那样一行一块,解析起来要自己写状态机,字段转义很烦;JSON 有现成库,嵌套结构清晰,调试时还能用 Python 读出来分析,所以最后选了 JSON。

我把整个存档文件当成一份“合同”:凡是写进文件的字段,反序列化器都必须给出明确解释;凡是新版本要加的字段,老版本读到也必须能平稳跳过。顶层结构分了四块,很朴素:

{ "schema_version": 1, "base_mva": 100.0, "nodes": [], "edges": [] }

schema_version是给格式本身做版本管理的,base_mva是标幺值系统的基准容量,节点区放所有节点对象,支路区放所有边对象。没有把边嵌进节点内部,是因为图结构里边的引用是交叉的,强行嵌套会导致同一段信息在多个节点里重复,既膨胀体积,又容易不一致。

3.2 一个看得懂的完整样例

空谈结构不好理解,直接上一段我测试用的样例文件。这个例子包含两条母线、一个开关节点、一个负荷节点,以及一条线路和一台变压器:

{ "schema_version": 1, "base_mva": 100.0, "nodes": [ { "id": "BUS_110A", "name": "110kV母线A", "type": "BUS", "voltage_kv": 110.0 }, { "id": "BUS_110B", "name": "110kV母线B", "type": "BUS", "voltage_kv": 110.0 }, { "id": "SW_110A", "name": "110kV进线开关", "type": "SWITCH", "voltage_kv": 110.0, "switch_closed": true }, { "id": "LOAD_A", "name": "负荷A", "type": "LOAD", "voltage_kv": 10.5 } ], "edges": [ { "id": "LINE_HA", "type": "LINE", "from": "BUS_110A", "to": "BUS_110B", "r_pu": 0.0032, "x_pu": 0.0121, "b_pu": 0.0002 }, { "id": "T_HA", "type": "TRANSFORMER", "from": "BUS_110A", "to": "LOAD_A", "r_pu": 0.0015, "x_pu": 0.105, "b_pu": 0.0, "tap_position": 3 } ] }

注意到开关和线路的闭合状态我用了两种写法:开关节点自带switch_closed,线路边里有switchable和switch_closed。这是为了表达“开关作为节点”和“带开关的线路作为边”两种不同建模方式,序列化格式需要照顾到这两种语义。

3.3 复数参数怎么表示

电网潮流计算里的线路参数是复数阻抗,JSON 没有原生的复数类型。我的方案是用一个二元数组[real, imag]表示,比如电抗和电阻都是实数,导纳的虚部也是实数,但如果你将来要保存导纳矩阵这种复数对象,可以统一成:

"admittance": [0.0032, -0.0121]

这种表示比字符串"0.0032 - j0.0121"好,因为不用写解析逻辑,反序列化时直接读两个元素就行;也比拆成r和i两个字段更紧凑。课设里我暂时只在内部计算用复数,存档格式仍然以实部、虚部分开字段为主,这里只是给个思路,别等到要存复矩阵的时候才想表示法。

4. 反序列化:两阶段重建与加载时的拓扑校验

4.1 反序列化的顺序问题:为什么必须先建节点

反序列化最核心的顺序约束是:必须先创建所有节点对象,再创建所有边对象。因为边的from和to引用的是节点 ID,如果节点还没建完就去解析边,必然查不到目标。这个道理说起来简单,但我在第一版实现里就犯过蠢:打算用递归函数一个个重建对象,边遇到缺失的节点就跳到后面,折腾了一晚上,最后老实改成两阶段。

第一阶段遍历 JSON 里的nodes数组,逐个构造节点对象,放进TopologyGraph::nodes这个 map 里。第二阶段遍历edges数组,从 map 里取出from_node和to_node对应的节点,构造边对象。如果取不到,立刻返回错误,并且在错误信息里带上边 ID 和它引用的节点 ID。

用代码表达就是这个样子:

bool deserializeTopology(const json& j, TopologyGraph& graph, std::string& err) { // Phase 1: 先建节点 for (size_t i = 0; i < j.at("nodes").size(); ++i) { const auto& item = j.at("nodes")[i]; TopologyNode node; node.id = item.at("id").get<std::string>(); node.name = item.value("name", ""); node.type = parseNodeType(item.at("type").get<std::string>()); node.voltage_kv = item.value("voltage_kv", 0.0); node.switch_closed = item.value("switch_closed", true); auto [it, inserted] = graph.nodes.emplace(node.id, node); if (!inserted) { err = "duplicate node id: " + node.id; return false; } } // Phase 2: 再建边 for (size_t i = 0; i < j.at("edges").size(); ++i) { const auto& item = j.at("edges")[i]; TopologyEdge edge; edge.id = item.at("id").get<std::string>(); edge.type = parseEdgeType(item.at("type").get<std::string>()); edge.from_node = item.at("from").get<std::string>(); edge.to_node = item.at("to").get<std::string>(); edge.r_pu = item.value("r_pu", 0.0); edge.x_pu = item.value("x_pu", 0.0); edge.b_pu = item.value("b_pu", 0.0); edge.tap_position = item.value("tap_position", 0); edge.switchable = item.value("switchable", false); edge.switch_closed = item.value("switch_closed", true); if (graph.nodes.find(edge.from_node) == graph.nodes.end() || graph.nodes.find(edge.to_node) == graph.nodes.end()) { err = "edge " + edge.id + " references missing node"; return false; } graph.edges.push_back(edge); } return true; }

两阶段法天然避开了“因为对象还没建好所以引用不完整”的问题,整个重建过程是线性的,没有递归,也没有循环依赖的烦恼。就算网络结构再怎么绕,节点表和边表拆开之后,图的嵌套关系就消失得一干二净了。

4.2 加载完不是终点:拓扑校验清单

反序列化把数据灌进内存,只代表文件被解析成功了,不等于数据是合法电网拓扑。我在加载函数里专门加了一条校验链,每一条都是实际遇到过或者明显会炸的场景:

  • 节点 ID 非空且全局唯一,这是两阶段建图能正确运行的前提;
  • 边的from和to必须指向已存在的节点,指针引用不存在就是悬垂引用;
  • 边不能自己连自己,也就是from不等于to,否则后续遍历邻接表时会出现自环,潮流计算直接原地爆炸;
  • 所有标幺值必须是有限数(std::isfinite),文件里如果出现NaN或者Inf,加载时就得拦下来;
  • 开关闭合标志必须是合法的布尔值,JSON 里写成字符串"true"都会被拒绝,避免类型混乱;
  • 至少有两条母线边,节点数至少是 2,否则一个光杆节点没有任何拓扑意义。

校验失败的错误信息要具体到设备和字段,比如"edge LINE_02 uses invalid r_pu value"。我一开始错误信息写得很笼统,调试的时候还得手动打开文件猜哪里有问题,后来改成只报 ID,程序崩溃原因一般十秒内就能定位。

4.3 永不信任输入:手写解析的安全底裤

反序列化还有一个绕不开的话题:安全性。现在提到反序列化,很多人第一反应就是漏洞,fastjson 那几波历史问题谁都记得。我的处理方式是:一不用通用对象映射库,二不用 eval 或动态求值,三只做显式字段映射。每一个字段用item.at("xxx")取出来,类型不对直接抛异常。字符串字段还要检查长度和字符集合,避免有人塞一个超长 ID 进来把内存吃光。这个安全底线不是防御黑客,而是防御课设阶段最现实的风险:手工编辑存档文件时手滑,把"type": "BUS"改成"type": "BUSX",或者把数组括号删了一个。手写解析器提供的是可控性和明确报错,这比引入一大堆黑魔法依赖要稳得多。

5. 实操中反复踩的五个细节:id、精度、编码与版本兼容

5.1 字符串ID的坑:空格、大小写和重复

第一个坑是字符串 ID 的规范性。我最初写节点 ID 全凭心情,同样一条母线,在节点表里叫BUS_110A,在边表里手一抖写成BUS_110a,程序加载时告诉我找不到节点。排查了半天,最后发现是大小写不一致。后来我统一了 ID 命名规范:全大写、下划线分隔、不带空格。序列化器在写入节点 ID 时做一次 trim,去掉首尾空白;但边引用的 ID 我不做任何自动纠正,原样读取。原因很简单:自动纠正会掩盖文件里的真实错误,宁可加载失败,也不能让程序带着潜在脏数据跑起来。

第二个坑是重复 ID。节点表里两个节点用了同一个 ID,map 插值的时候后者会把前者覆盖掉,BFS 遍历时明明有两条母线,实际只有一条,潮流收敛结果奇奇怪怪。所以我在 Phase 1 的emplace返回值里检查inserted,重复 ID 直接报错。这个检查成本极低,但能省掉太多毫无头绪的调试时间。

5.2 浮点精度与NaN/Inf:JSON格式的隐藏要求

第二个细节是浮点数的精度和合法性。JSON 标准本身不要求解析器保留完整 double 精度,有的库在输出浮点数时默认只写 6 位小数,读回来电阻就差了几个百分点,潮流结果直接偏掉。我在序列化器里对每个浮点字段都指定了足够的小数位,状态统一用std::setprecision(17)输出,保证 double 的二进制值在文本转换后能无损往返。

另外一件更隐蔽的事:C++ 的std::ostringstream默认会把NaN输出成字符串,但 JSON 标准没有这个字面量,反序列化器解析时直接失败。所以序列化前必须检查std::isfinite(r_pu),遇到非有限值直接拒绝保存,而不是等到读档的时候才发现。实际项目里,潮流计算不收敛时确实会产生 NaN 导纳值,把这个检查放在保存入口,比我事后拿着文件到处找 NaN 要高效太多。

5.3 编码与BOM:中文名字引发的连锁反应

第三个坑是文件编码。电网模型里节点名几乎全是中文,“110kV母线A”这种字符串在 JSON 里出现很正常。用 UTF-8 保存本来没有问题,但我在 Windows 上写代码,记事本存出来的文件经常带一个 UTF-8 BOM 头,也就是前面多了三个字节EF BB BF。JSON 解析器遇到这个头,有的会报“非法字符”,有的会把它当成空字符串。我的解决方式是在读文件后、解析 JSON 前,检查前三个字节,如果是 BOM 就跳过。这个处理几行就够,但没做的话,你会在“为什么我的存档读不了”这种问题上白费一个晚上。

顺带一提,C++ 里的std::string存 UTF-8 中文没问题,但如果你用了 Qt 的QString,从 JSON 里读出来的std::string要转成QString时,必须用QString::fromUtf8(byteArray),千万别用fromLocal8Bit,否则中文名在界面上显示就是乱码。这个坑跨了好几个项目,每次都有人踩。

5.4 schema_version:向前兼容不是口号

第四个坑是版本演进。课程设计写到后面,我突然觉得边结构里应该加一个thermal_limit字段,存线路热稳定极限。加完之后,旧存档文件里没有这个字段,我的反序列化器怎么处理?两个选择:报错,或者给默认值。报错会让老存档全部作废,太粗暴;给默认值则需要明确默认值是多少,并且要在文档里写清楚。

我采用了schema_version加上“字段缺失给默认值”的策略。新版本写文件时永远带schema_version,反序列化时检查版本号:版本过高说明这个文件来自更新版本的程序,拒绝加载并提示用户升级;版本相同或更低,则正常解析。新增字段一律走item.value("thermal_limit", 1000.0)这种带默认值的读取方式。这样,新程序能读旧文件,旧程序读新文件时最多漏掉新字段,不会崩。把这一套做出来,存档格式的“合同感”才算真正落地。

5.5 循环引用和递归:图不是树

最后一个容易忽略的细节:拓扑是图,不是树,反序列化时不能像解析树形结构那样用递归逐层重建。树的天然结构是父子关系,递归没问题;图里两个节点之间的引用关系却可能形成环,比如一条线路从 A 到 B,另一条线路从 B 到 A。如果递归解析器在遇到 “A 引用 B、B 引用 A” 时无限递归下去,栈溢出直接崩溃。两阶段法之所以稳妥,就是因为它把这种交叉引用彻底拍扁了:先建立所有节点的“壳”,再填充边,环状引用完全不影响构建顺序。这也是我在第 4 节反复强调两阶段的原因——它不是优化,是必须。

6. 加载链路终于打通:从netload文件到电气岛划分

6.1 完整加载函数的骨架

当你把前面所有细节合在一起,一个完整可用的加载函数就成型了。这里用 nlohmann/json 库给出骨架,Qt 环境里换成QJsonDocument也一样:

bool loadTopologyFromFile(const std::string& path, TopologyGraph& graph, std::string& err) { std::ifstream fs(path, std::ios::binary); if (!fs) { err = "cannot open file: " + path; return false; } std::string content((std::istreambuf_iterator<char>(fs)), std::istreambuf_iterator<char>()); // 跳过 UTF-8 BOM if (content.size() >= 3 && static_cast<unsigned char>(content[0]) == 0xEF && static_cast<unsigned char>(content[1]) == 0xBB && static_cast<unsigned char>(content[2]) == 0xBF) { content = content.substr(3); } json j = json::parse(content, nullptr, false); if (j.is_discarded()) { err = "JSON parse error"; return false; } int version = j.value("schema_version", 0); if (version > kCurrentSchemaVersion) { err = "file version too new"; return false; } double base_mva = j.value("base_mva", 100.0); if (base_mva <= 0.0) { err = "invalid base_mva"; return false; } graph.base_mva = base_mva; if (!deserializeTopology(j, graph, err)) { return false; } if (!validateTopology(graph, err)) { return false; } return true; }

这套代码看起来不长,但每一段都对应一个真实的故障案例。BOM 跳过解决编码问题,版本号检查解决格式演进问题,base_mva合法性检查避免后面潮流计算除零,deserializeTopology解决两阶段建图,validateTopology解决拓扑合法性。整个加载链路从文件系统到内存对象,再到业务逻辑层,每一层都守住了自己的边界。

6.2 用电气岛划分验证加载结果

把文件读回来之后,怎么确认拓扑真的加载对了?我的验证手段是跑一次连通性分析,也就是电气岛划分。规则很简单:所有闭合开关和线路构成无向图,从任意节点出发做 BFS,能到达的节点都属于同一个电气岛;开关断开的支路视为不存在。下面是一个极简版本:

std::vector<int> assignElectricalIslands(const TopologyGraph& graph) { std::map<std::string, int> island; std::vector<int> result; int island_count = 0; for (const auto& [id, node] : graph.nodes) { if (island.count(id)) continue; std::queue<std::string> q; q.push(id); island[id] = island_count; while (!q.empty()) { std::string cur = q.front(); q.pop(); for (const auto& edge : graph.edges) { if (edge.switchable && !edge.switch_closed) continue; std::string next; if (edge.from_node == cur) next = edge.to_node; else if (edge.to_node == cur) next = edge.from_node; else continue; if (!island.count(next)) { island[next] = island_count; q.push(next); } } } ++island_count; } result.resize(graph.nodes.size()); for (const auto& [id, idx] : island) { size_t i = 0; // 实际代码中用节点顺序映射 id -> index result[i] = idx; } return result; }

这一段跑完,加载结果对不对一目了然:一个正常的电网,闭合开关状态下应该是一个连通岛,断开的开关会把电网切成多个岛,孤岛的数量和断点位置在加载前就能手算出来。只要输出结果和手算一致,重建的图基本就是可信的。

6.3 保存再读回:一致性闭环

最让我安心的是做了一轮“保存再读回”的闭环测试。流程是:程序启动时生成一个测试电网,序列化保存到.grid文件;然后清空内存里的图,调用loadTopologyFromFile重新加载;最后对比加载前后的节点数、边数、每条边的from、to、参数。我跑了一个 23 节点、29 条支路的测试网络,文件大小约 180KB,加载耗时在普通笔记本上不到 10 毫秒,参数逐项对比完全一致。

这个闭环测试的价值不只是证明反序列化器没问题,它还变成了我后续所有功能的回归基准。每次改了节点结构或者加了字段,我都会先跑一遍保存、清空、加载、对比的流程。从此我再也不用担心某个新字段忘了写进序列化器,因为对比测试会第一时间报警。

7. 做完这期课设,我对存档设计的三点体会

7.1 存档系统应该早点进场,别等架构全写完

最开始我把存档放在了课设计划表的最后,觉得它就是个收尾工作。实际做下来发现,存档是调试效率的基础设施。有了存档,我可以在程序里把电网改成一个复杂的故障状态,保存下来,第二天启动直接加载,不用重新点半天界面。早做存档,相当于给后面所有功能提前修了一条高速公路。

7.2 文件即测试资产,改完代码先跑一轮回归

把序列化和反序列化做完之后,那些.grid文件对我而言不只是存档,它们变成了测试资产。我手里有一个正常电网、一个断开多开关的多岛电网、一个含变压器分接头的电网,还有一个故意写坏掉的 JSON 文件。每次改动解析逻辑,我就拿这批文件跑一遍。正常的要能加载,多岛的要能算出正确的岛数,坏文件的错误信息要清晰可读。这套资产比任何临时写的单元测试都贴近真实使用场景。

7.3 别过度设计,但校验不能砍

最后一点是取舍。我没有做增量存储、没有做二进制压缩、没有搞自定义二进制格式,因为课设规模下这些都是纯增加复杂度。但有两件事我不建议省:一是schema_version,哪怕将来用不上,它只占一个字段;二是校验逻辑,加载失败时的一次明确报错,能省掉未来几十次“数据看着没问题但算出来就是不对”的排查。数据文件一旦能被人手编辑,它就不再是可信任的,校验前置是序列化设计里最值得做的一笔投资。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询