PySide6+纯函数架构:开源高精度四柱干支历法引擎开发全记录
2026/9/14 22:00:10 网站建设 项目流程

说实话,最开始看到这个题目的时候,我脑子里冒出来的念头是:市面上的排盘工具已经那么多了,为什么还要再写一个?后来真正动手做开源项目才发现,绝大多数现成库把“算法”和“界面”绑得太死,想改个主题、加一个分析模块、甚至换一套历法数据,都得把底层逻辑翻个底朝天。所以这个项目到最后,核心思路就变成了两句话:界面用 PySide6 做,怎么好看怎么来;计算内核用纯函数式架构写,怎么干净怎么来。这篇文章把我从设计、编码到打包发布的全过程,包括踩过的坑,全部摊开来讲。

这个项目的目标很简单:做一个开源的、可复用的传统干支历法计算引擎,外部输入公历日期时间和经纬度,引擎输出年柱、月柱、日柱、时柱以及对应的五行属性统计,再配一个 PySide6 桌面壳子做交互演示。核心库没有任何界面依赖,可以单独 import;界面层只做展示和交互,不掺和任何历法规则。如果你是做桌面应用、或者对历法计算、函数式架构如何落地到 GUI 项目感兴趣的,这篇文章里的思路和坑,应该都能直接用上。

1. 项目定位与技术选型

1.1 项目到底要解决什么问题

先说清楚边界:这个项目不是一个“预测工具”,它做的事情是把传统的干支纪年、节气切换、时辰划分这些规则数字化、工程化。换句话说,它是一个确定性符号计算引擎,输入一个时间点,输出一套符号序列,整个过程可以由测试用例精确校验。

所以才把“高精度”放在标题里。因为干支持续计算最怕的是边界条件,某个时间点在立春前还是立春后,在节气前还是节气后,在子时前还是子时后,结果完全不一样。一个负责人的开源工具,必须把这些边界条件处理得明明白白。

项目最终拆成两层:core负责历法规则和四柱推算,纯函数,无 IO,无全局状态;ui负责 PySide6 界面、异步任务、结果展示。两层之间只通过函数调用和简单的数据类通信,不允许 core 引用任何 Qt 模块。这个约束从一开始就定死,后面所有重构都因此受益。

1.2 PySide6 与 PyQt5 的取舍

组件选型上,我几乎没有犹豫就选了 PySide6。原因很简单,PySide6 是 Qt 官方维护的 Python 绑定,协议是 LGPL,对开源项目和商业项目都相对友好。PyQt5 虽然社区资料多,但 GPL 协议带来的传染性问题,会让很多想拿代码做二次开发的人心里打鼓。

另一个让我下决心的点是技术栈状态。PySide6 跟随 Qt 6 的节奏更新,信号槽、QSS、QML 这些能力都齐全,而且对 Python 类型注解和异步编程支持得比 PyQt5 时代自然很多。如果项目一开始选 PyQt5,后面升级到 Qt 6 等于重构一遍。与其那样,不如直接从新版本起步。

表格对比一下我当时的考虑:

框架协议界面能力包体积我的选择理由
TkinterPython 内置基础控件,视觉老气极小只适合内部小工具,做不了现代化界面
PyQt5GPL/商业很完善较大协议传染性,开源项目慎用
PySide6LGPL很完善,且同步 Qt6较大官方绑定、协议友好、长期可维护
ElectronMIT最强,靠 Web 技术巨大内存占用高,Python 算法集成麻烦

1.3 为什么计算内核敢用纯函数式

这里的“函数式”,不是指把 Python 写出 Haskell 风格,而是把计算函数全部收敛成“无副作用函数”。同样的输入,永远得到同样的输出;函数内部不读全局变量,不碰数据库,不改外部状态,不产生随机数。就这么几条约束,带来的好处在项目后期体现得淋漓尽致。

举个最典型的例子,节气交接时刻。传统做法可能是函数内部直接调用某个历法库去查表,这会让测试非常难写,因为你没法稳定复现“立春前 30 秒”这种场景。纯函数式架构下,核心函数只接收“节气表”参数,至于这张表是查出来的、算出来的、还是测试里手工构造的,函数本身不关心。测试时注入一份精心构造的节气表,一切边界条件都能稳定复现。

用生活类比就是:纯函数像一台计算器,你给它两个数,它永远给你同一个答案,它不会自己去改旁边记账本上的数据。这样的函数可以放心并发调用,可以放心缓存结果,也可以放心让 UI 层在任意时刻调用而不必担心状态泄漏。

2. 核心算法设计与高精度实现

2.1 干支数据建模

第一步是把天干、地支、五行这些符号体系变成结构化的 Python 数据。直接用字符串到处传,后患无穷,因为“甲”和“jia”和 0 号天干三者之间的映射,写散的代码里迟早会出一堆 bug。用 Enum 把这些符号定义成单例对象,每个对象带上静态属性,代码的可读性会好很多。

这里是我的models.py里的一部分建模,缩写但足够说明思路:

from enum import Enum class Tiangan(Enum): JIA = (0, "甲", "木", "阳") YI = (1, "乙", "木", "阴") BING = (2, "丙", "火", "阳") # ... 依次到 GUI (9, "癸", "水", "阴") def __init__(self, idx, chinese, wuxing, yinyang): self.idx = idx self.chinese = chinese self.wuxing = wuxing self.yinyang = yinyang class Dizhi(Enum): ZI = (0, "子", "水", "阳") CHOU = (1, "丑", "土", "阴") YIN = (2, "寅", "木", "阳") # ... 依次到 HAI (11, "亥", "水", "阴") def __init__(self, idx, chinese, wuxing, yinyang): self.idx = idx self.chinese = chinese self.wuxing = wuxing self.yinyang = yinyang

我额外定义了一个不可变的Pillar数据类,表示“一柱”,由天干和地支组成:

from dataclasses import dataclass @dataclass(frozen=True) class Pillar: tian_gan: Tiangan di_zhi: Dizhi @property def wuxing(self) -> tuple: return (self.tian_gan.wuxing, self.di_zhi.wuxing)

frozen=True保证 Pillar 对象创建后不可变。这个细节很重要,配合纯函数的使用方式,数据在多层之间传递时不会出现“有人偷偷改了一个字段”的问题。

2.2 四柱推算的纯函数实现

四柱里最容易讲清楚的是年柱。规则很多人也知道:干支纪年以立春为界,立春之后换年,立春之前沿用上一年的干支。如果已经有“干支年序号”,核心函数其实很简单:

def year_pillar(lunar_year: int, before_lichun: bool) -> Pillar: stem_idx = (lunar_year - 4) % 10 branch_idx = (lunar_year - 4) % 12 if before_lichun: stem_idx = (stem_idx - 1) % 10 branch_idx = (branch_idx - 1) % 12 return Pillar(Tiangan(stem_idx), Dizhi(branch_idx))

这个函数里没有文件读取,没有网络请求,没有时间去获取“现在”,传入before_lichun是什么就是什么。至于before_lichun怎么算出来的,那是历法接口层的事,可以在 UI 层用天文历法库查节气时刻后算好再传进来。

日柱的计算稍微绕一点。我的做法是选一个已知干支的基准日,然后通过儒略日差值取 60 的余数:

KNOWN_JD = 2451545.0 KNOWN_PILLAR = Pillar(Tiangan.WU, Dizhi.WU) # 某个已知基准日 def day_pillar(jd: float) -> Pillar: diff = int(round(jd - KNOWN_JD)) % 60 base = (KNOWN_PILLAR.tian_gan.idx + diff) % 10 branch = (KNOWN_PILLAR.di_zhi.idx + diff) % 12 return Pillar(Tiangan(base), Dizhi(branch))

这里的关键是 KNOWN_JD 和 KNOWN_PILLAR 必须来自权威历法数据,而且一旦写进代码就要用测试锁死。我自己第一次运行出来的日柱和在线资料对不上,差了一位,后来查下来就是基准日选错了。宁可多花十分钟把基准日验证清楚,也不要相信“看着差不多”的推算结果。

2.3 高精度的时间基准

普通干支工具一般输入日期就够了,但项目标题里强调“高精度”,那就必须处理时间基准问题。这个问题的核心在于:传统干支历法的一天,以子时 23:00 为界;而一个时辰的划分又和真太阳时挂钩,并不等于我们手机上的北京时间。

计算流程大致是:用户输入钟表时间、出生地经度,界面层先把钟表时间转换成当地平太阳时,再叠加均时差得到真太阳时。经度修正的思路是一度经度对应 4 分钟:以东经 120 度为标准,当地经度每偏离一度,时间就偏差 4 分钟。而这个均时差不是常数,一年里每天都在变化,需要天文算法提供,不能自己拍脑袋算。

为什么这个细节决定“高精度”?因为两分钟的时间差,可能正好跨过一个时辰边界。比如某地真太阳时是 12:59,北京时间已经 13:15,如果直接按北京时间的 13 点取“未时”,恰好取对了;但如果地点偏东,真太阳时早已跨过 13 点,钟表还停在 12:50,直接看钟表就会把它归到“午时”,干支结果就错了。这个坑在测试数据里极其隐蔽,一定要把“显示时间”和“计算所用时间”分开。

3. PySide6 界面层实战

3.1 内核与 UI 的分层边界

提交第一版界面代码之前,我给自己定了一条硬性规则:core目录下任何文件不得出现from PySide6,一个字符都不行。这条规则不是靠自觉,是靠git提交前的代码检查保证的。为什么这么严格?因为一旦 core 里出现 Qt 类型,比如在信号里直接传 QDateTime,core 就再也没法脱离 UI 独立测试,也没法被其他非 Qt 项目复用了。

实际操作上,UI 层拿到用户输入的普通 Python 对象,在点击按钮的回调里组装成 core 需要的参数,调用 core 函数拿到结果,再用 signal 发回主线程刷新界面。core 层提供的函数不接受任何 Qt 类型,数据全部是intfloatEnumdataclass。这样划分之后,我在写界面的时候只需要关心交互,不需要关心历法规则;在写算法的时候只需要盯着推算逻辑,不需要考虑按钮怎么放。

3.2 用 QSS 做出干净不“辣眼睛”的界面

PySide6 的界面观感很大程度上靠 QSS 撑着。很多 PyQt 老项目的界面丑,不是 Qt 的问题,而是根本没有用样式表做统一设计。这个项目用一个style.qss文件统管全局视觉,按钮、输入框、卡片、列表都有统一的主色、圆角、间距。

一个典型的卡片式按钮样式如下:

QPushButton#CalcButton { background-color: #4A6CF7; color: white; border: none; border-radius: 8px; padding: 10px 20px; font-size: 15px; } QPushButton#CalcButton:hover { background-color: #3A5CD7; } QPushButton#CalcButton:pressed { background-color: #2D49C1; }

配合QFrame#Card设置浅色背景、圆角和阴影效果,整体界面出来之后干干净净。这里我自己的体会是:QSS 不要写到每个控件里,而是集中到样式文件里,方便做主题切换。项目里我预置了“浅色”和“深色”两种主题,切换时只需要重新加载不同的 qss 文件,不用改任何控件代码。

3.3 信号槽与多线程防卡顿

干支推算本身很快,毫秒级,但节气表初始化、真太阳时计算、以及后续扩展的十神分析都是相对耗时的。如果全部放在主线程执行,界面必然出现“拖动窗口都费劲”的卡顿。PySide6 的标准解法是QThreadPoolQRunnable,把耗时任务丢到线程池,完成后通过信号把结果传回主线程。

QRunnable的封装我写了一个通用 Worker:

from PySide6.QtCore import QRunnable, Signal, QObject class CalcSignals(QObject): finished = Signal(object) failed = Signal(str) class CalcWorker(QRunnable): def __init__(self, fn, *args, **kwargs): super().__init__() self.fn = fn self.args = args self.kwargs = kwargs self.signals = CalcSignals() def run(self): try: result = self.fn(*self.args, **self.kwargs) except Exception as exc: self.signals.failed.emit(str(exc)) else: self.signals.finished.emit(result)

按钮回调里只需要worker = CalcWorker(build_bazi, moment),再连上signals.finished,就能安全地把计算结果回填到界面。这里有一条铁律:run()里绝不直接改控件,所有 UI 刷新都要通过信号回到主线程。否则轻则界面闪烁,重则直接崩溃。

3.4 报表预览与打印

项目后期我加了一个“详细报告”面板,能把四柱、五行统计、空亡信息整理成一份格式化报表。这里没有用复杂的绘图组件,而是用QTextDocument生成 HTML 内容,再挂到QPrintPreviewDialog里做打印预览。这个方案的好处是:HTML 排版能力足够表达复杂的表格结构,而且 Qt 的打印支持不用自己处理分页。

核心就三行代码:

doc = QTextDocument() doc.setHtml(generate_html_report(result)) preview = QPrintPreviewDialog(printer, self) preview.paintRequested.connect(doc.print_) preview.exec()

生成 HTML 的部分也在 core 层,接收纯数据返回纯字符串,这样打印样式可以单独写单元测试。实际用下来,从预览到导出 PDF,整套流程非常稳,比我最初想的用 QTableWidget 拼界面再截图打印靠谱得多。

4. 开源工程化:测试、依赖与发布

4.1 项目目录与依赖管理

开源项目不能只有“能跑的代码”,得让陌生人拉下来之后三分钟能跑起来。目录结构上我保持了最简的分层:

project_name/ ├─ core/ │ ├─ __init__.py │ ├─ models.py │ ├─ pillars.py │ └─ almanac.py ├─ ui/ │ ├─ __init__.py │ ├─ main_window.py │ ├─ workers.py │ └─ assets/style.qss ├─ tests/ │ ├─ test_pillars.py │ └─ test_almanac.py ├─ pyproject.toml └─ README.md

依赖管理用的是poetrypyproject.toml里把运行时依赖和开发依赖分开。运行时依赖只有PySide6和一个天文历法库,测试依赖是pytest。这里我建议不要图省事把所有东西塞进requirements.txt就完事,尤其是做开源,清晰的依赖边界本身就是一种文档。

4.2 自动化测试守住历法精度

传统历法计算最容易犯的错误是“某一天对了,但不代表所有边界都对”。所以自动化测试是这个项目的生命线。每个推算函数都要配一组测试用例,数据直接取自权威历法手册,不用“感觉正确”的数据凑数。

测试代码长这样:

def test_year_pillar_before_lichun(): assert year_pillar(lunar_year=2023, before_lichun=True) == Pillar(Tiangan.REN, Dizhi.YIN) def test_day_pillar_known_date(): # 用已知公历日期反推儒略日 jd = julian_day(2024, 2, 10) assert day_pillar(jd).tian_gan == Tiangan.JIA

每个测试函数名里写清楚在测哪个边界,比如test_hour_pillar_2300_start_of_zitest_hour_pillar_0000_still_zi。跑pytest的时候,这些用例就是项目的“数字围栏”,任何一次的改动如果破坏了边界规则,跑一遍测试就能立刻暴露。我自己在重构的时候,靠这批测试至少抓出三个隐藏 bug,都是下午那个时辰边界的问题。

4.3 开源发布与版本维护

发布到 GitHub 之前,我做了三件事:写一份像样的 README,加一个 LICENSE 文件,配一套 GitHub Actions 做持续集成。README 必须有“快速开始”段落,让读者复制两条命令就能跑起来。许可证我选了 LGPL-3.0,核心代码大家可以用,但修改后的核心部分需要开源;UI 示例代码相对宽松。

版本号我采用语义化版本规则,0.1.0表示首个可用的预览版。每个版本发布的时候,顺手在 GitHub Releases 里附上 Windows 和 Linux 的打包产物,这样使用者不需要本地装 Python 环境就能体验。持续集成配置了pytest,每次提交代码自动跑全部测试,省去了人工回归的时间。

5. 常见问题与排坑实录

5.1 23 点之后到底算哪一天

干支历法里,子时一般从 23:00 开始,也就是说 23:00 到 24:00 虽然公历日期还是当天,但干支的“日”已经算作新的一天。这个规则一开始没注意,导致我拿一批晚上 11 点多出生的测试数据去验证时,日柱整体错了一位。

解决方案是把“公历日期”和“干支日序列”彻底解耦。UI 层拿到日期后先判断是否跨入子时,如果已经 23 点以后,内部按“下一干支日”处理,同时把显示保持为用户熟悉的公历日期。调试的时候建议你在输出里把“原始日期”“内部日期”“日柱”三个字段全部打出来,一眼就能看出问题出在哪层。

5.2 节气交接时刻的分钟级误差

年柱和月柱都以节气为界,但节气交接并不是整点,而是精确到分钟甚至秒钟。直接拿库函数查节气表,不同天文算法之间可能存在几分钟偏差,这几分钟恰好卡在边界时,结果就会不一样。

我的处理办法是:引入一个标准的天文历法库作为数据源,同时在 core 层不直接依赖它,而是通过适配器把节气时刻转成一张TermTable,再传给纯函数推算。这样一旦发现某个库的节气数据有问题,只需要替换适配器,不用动任何推算逻辑。对于开源项目来说,这种“数据源可插拔”的设计能给自己留后路。

5.3 PyInstaller 打包瘦身

PySide6 应用打包后体积很大,这是绕不开的痛。我第一次打包出来的目录接近 400MB,对一个纯计算工具来说实在夸张。后来按几个方向做减法:用 PyInstaller 的--exclude-module排除用不到的 Qt 模块,把 QSS 和图片资源打进qrc文件而不是裸目录,最后关掉调试符号。几轮下来体积压到 220MB 左右,虽然还是不小,但已经能接受了。

如果你准备发布 Windows 版本,建议先在干净环境里打包一次,避免本机装满各种 Python 包导致产物异常膨胀。打包完成后,一定要在另一台没有 Python 的机器上跑一遍冒烟测试,界面能起来、计算能出结果、打印预览能打开,才算合格。

5.4 跨线程操作 UI 崩溃

这个问题几乎每个 PySide 新手都会遇到。线程里直接调用QLabel.setText,表面看起来偶尔能成功,但一旦线程执行时机不对,程序直接段错误退出,错误信息还特别难查。Qt 的规则很明确:GUI 操作必须在主线程执行。

我踩过一次之后,把所有 Worker 都改成“只发信号,不摸控件”的模式。信号的对象是主线程的,emit的数据也只是普通 Python 对象,主线程收到信号后统一刷新界面。这个模式写起来多几行代码,但稳定性和可维护性完全不是一个层级。

最后分享一点实际体会

这个项目从第一天写算法到最终发布,最大的收获不是“能算四柱了”,而是确立了一种思考方式:凡是可能出错、需要反复验证的逻辑,都收敛成纯函数;凡是可能变化、需要频繁调整的地方,都放到外层适配。第一次重构 core 层时,前三天几乎没动什么代码,很多精力花在把旧逻辑里的隐式依赖挖出来,但后面越写越顺,测试跑得越来越快。给同样想写开源项目的朋友一个建议:先花时间把数据模型和边界条件列清楚,再动手写界面,这比先把界面做得花里胡哨再回头补算法,要省心得多。

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

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

立即咨询