电路课程设计-程序设计
这是接续上一篇电路课程设计-模型构建所作的第二部分记录,主要内容是基于之前的的数学模型和算法设计,考虑合适的抽象层次和程序结构,并尝试为各模块间设计合理的数据通信方式。
总体上将电路分析的程序分为数据、元件和电路三个抽象层次,并对元件和电路加以封装。
语言和工具的选择
语言和开发环境
我选择自己相对熟悉的C++语言,按ISO C++11 标准编写(主要是因为C++11对 STL 容器的性能和易用性作了较大的优化,并添加了对 auto 等关键字的支持)。
编译器选择Windows下的mingw-w64
,版本为g++ 8.1.0
。
外部工具和库
因为这是一个课程设计的demo,没有打造成实用工具的计划,加之自己有熟悉算法的意愿,我选择不用第三方库(实际上boost的线性代数库提供了更完善的工具),只用标准库编写。程序中顺便也用了一些STL中的容器。
抽象层次的设计
概述
对于一个线性(动态)电路,所需要存储的信息主要有三类:
- 拓扑信息:结点之间由元件建立的连接关系
- 特征信息:元件的类型及属性
- 状态信息:电路的物理状态,即电压和电流
而电路需要实现的基本操作应当包括:
- 数据功能:如元件的增删、修改
- 分析功能:如求结点电压、等效电路
一般将信息视为数据,而用函数实现操作。对程序设计三个层次:
即:
- 电路层存储拓扑信息,实现数据功能和分析功能
- 元件层不存储信息,只定义元件和实现访问数据层的方法
- 数据层存储特征信息和状态信息
以下按照自底向上的顺序介绍各层的设计。
数据层
整个分析过程中,所有的元件都通过动态内存管理,通过指针访问,数据层即所有实例化的元件对象。访问这些对象的唯一方法是通过指针,任何不通过动态内存申请而试图实例化一个元件对象的行为都是不应该发生的。
这样设计的原因是:根据数学模型和算法设计的要求,在电路层和元件层的多处位置有可能要用到同一个元件的信息。例如电路层中,进行分析时要访问元件的特征信息,而动态元件维护的相关元件列表需要访问元件的状态信息。在这样的情况下,同时在各处都保存这个元件的副本不仅会造成很大的空间浪费,还会带来“一处更新,四处修改”的麻烦,很有可能出现一些副本没有及时根据计算结果进行更新的问题,从而造成计算结果错误。
所以,在上层结构中保存元件对象的副本不如保存指向元件对象的指针。只要表示同一个元件的多个指针指向同一个对象,无论何处由计算发生更新,都可以即时反馈,不存在协同更新的问题。而将元件对象用new
运算符动态申请实例化,每一个实际的元件只实例化一个对象。
原件层
元件层实际上就是对元件的类定义,用派生关系定义了抽象的元件基类和具体的各种元件类型,并且提供了访问该类型对象中各种数据的方法。
元件层是用于实现封装的,即一个元件对象的任何数据成员都不应当对外暴露,只能通过提供的接口进行访问或修改。对于一些具体的元件类型,由于元件的性质不同,这些接口可能具有不同的行为。
电路层
电路层的关键在于电路的拓扑信息,即电路的图。这一层直接存储电路的图,并且用在图上增删点、边的方式实现元件的增删。这一层的分析功能则通过调用元件接口来获取分析所需的数据。
继承关系与多态实现
元件类的设计——继承关系
根据对抽象元件建立的数学模型,可以确定一个抽象的元件基类(abstractbase class)Element
应当具有的属性是两端结点和属性参数。由于不同元件的属性参数也不同,所以属性参数在派生类中定义,但是一个元件应该能够访问其属性参数和元件类型,所以应当具有对应的方法,只是这些方法被定义为纯虚函数,所以Element
类不能被实例化。
另外根据动态电路暂态分析的需要,为Element
类增加了电压和电流两个属性,这是和数学模型不相同的地方。但是这两个属性是不需要外部输入的,来源于电路计算过程中的结果。
在Element
类的基础上,根据各种元件类型的特性,设计各类的继承关系表示如下:(用蓝色表示抽象类,橙色表示具体类)
各个层级的类所提供的接口分别为:
- 所有元件的基类:
Element
类:访问元件两端结点的编号、元件的电流、电压、参数值、类型,修改元件参数值,更新电流、电压
- 次级抽象类:
ControlledSource
类:访问受控源的控制量所在的结点Dynamic
类:访问动态元件当前的状态,向相关元件列表中加入元件,计算下一时刻的状态,设定初始状态
- 具体原件:多数具体元件不增加接口,只实现抽象基类中定义的接口,仅有如下例外:
CurrentSource
和VoltageSource
类:增加开关电源的接口,电源关闭后即置零Capacitor
类:设定标记结点(即所选定的单电容结点)
其中,Resistor
,CurrentSource
,VoltageSource
三个类型之所以直接继承自Element
类,是因为这三种元件都不需要定义额外的接口,所以不再为它们设计次一级的基类了。
电路的实际存储——多态实现
从数据结构的角度看,电路应当以图的形式存储,而为了增删元件方便,选择邻接表比邻接矩阵更好。所以,用一个邻接表存储整个电路。
从实际操作的角度看,邻接表中所存储的数据类型应当一致,但是电路中元件的类型是很多的,并且还可能随着功能的开发和扩展而增加。
所以,这个邻接表中不能存储元件的数据,而应当存储指向元件对象的指针;并且这个指针的类型应当是所有元件的基类(Element*
),这样就可以用同一类型的变量代表(指向)不同类型的元件。这也是设计数据层与电路层相分离的重要原因。
在电路层的分析功能实现中,需要获取一个具体的元件数据,这分为两种情况:
- 具体元件中没有新增的接口或不需要用到这些接口
- 具体元件中新增了接口且需要使用这些接口
对于前者,只需要直接用基类指针访问方法,运行时自然会根据虚函数表调用派生类的方法;对于后者,首先通过基类的getType
接口获取元件的类型,再进行动态类型转换,转换到派生类的指针再调用派生类的方法。(按照C++11之后标准的推荐,这个转换应当使用dynamic_cast
而不是C风格的强制类型转换,以提供编译期的安全检查)
程序控制流程
整体控制
程序整体的控制流程是重复"接受任务-执行-返回"的过程,在 main.cpp 中表现为消息循环中对电路层功能的调用,演示如下:
稳态分析的流程
稳态分析的基本工作就是遍历电路、填写系数矩阵、求解方程组
求结点电压的流程展示如下:(如求戴维宁等效电路,在结点电压法求出端口电压的基础上,只需向指定一端口加一个 1A 的电流源,再将整个电路中其他的电源都关闭之后求端口电压,就可以求出等效电阻)
暂态分析的流程
暂态分析的实际上是一个不断迭代作稳态分析的过程,只是每次的数值不同。暂态分析的流程展示如下:
其他细节设计
元件的行为与参考方向
计算电路的状态时,为了从电压确定电流,就需要确定一个参考方向。从手工计算的角度而言,一般考虑关联或者非关联参考方向,对于程序来说,我采用的设计是无源元件电压取第一个结点为高电势,而电源相反。这样计算时无源元件相当于取关联方向,电源相当于取非关联方向。
动态元件的极性问题
动态元件本来是无源元件,一般也不具有极性,但是在程序的设计却出现了动态元件的“极性”问题。在进行暂态分析时,每个时间点都进行的是稳态分析。而动态元件在此时被视为电源,电源是有极性的,这就使得动态元件表现出了本来不应该有的极性,有时带来错误的解。
例如一阶 RC 电路的零状态情况,按照如下的格式输入:
这时,按照输入的顺序,电容就会以为电压升高的方向,此时电源给电容充电的结果是使得其状态量(电压)增大,但是电容的电压方向却与电源相同,结果是电容器上的电压可以无限制地升高,并且升高得越来越快,这能量都不守恒了(笑)。
为了解决这种问题,就需要先进行一次试计算:在初始状态下,计算电容的端电压(或电感的电流),如果电容的端电压显示它将被充电,那么电容的电压方向应当调整到与充电电压相反,放电情况下则不存在这样的问题。电感的处理与电容是类似的。
总结与反思
思路和设计上的问题在之前已经说过,就不再赘述。
在程序实现的过程中,出现过不少计划的时候完全没有意识到的问题,都是以后值得警醒的。
比如最初定义的Element
类并没有电流和电压属性,后来在做暂态分析时发现:如果更新动态元件的状态时,要全部用 VCR 来实时计算其他元件的电流,就要从元件层向上调用电路层,并且还要调用电路层不应对外暴露的结点电压,这就带来了头文件循环包含和封装破坏的问题。所以最终对Element
类作了修改,增加了这两个属性和对应的几个接口。然后就发现,改了基类之后还需要对派生类作许多修改。这是整个过程中对整个元件类作的最大改动,增加了许多调试量,也造成了后来电压电流参考方向的一系列问题。
再比如动态元件的“极性”问题,这种理论推导和程序实现的不一致造成的问题在设计时被我忽略了,导致了非常荒谬的结果,直到对将近一半的元件层和电路层的实现作了排查才发现。
从技术角度来说,程序还是有不少比较明显的待改进处。比如方程组的求解有更成熟的可用方案、与 Python 的通信做得很粗糙导致结果曲线的展示效果打了折扣等等,如果要构建一个可用的工具,这都是需要仔细打磨的部分。
不过总体而言,这是一次让自己有所收获的实践,这个课设也算没有白做,所得的成果以一个 demo 的定位来衡量的话,也可以让我这个初学者为之小小自得一下了。