十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PySide6+纯函数架构:开源高精度四柱干支历法引擎开发全记录

PySide6+纯函数架构:开源高精度四柱干支历法引擎开发全记录 说实话最开始看到这个题目的时候我脑子里冒出来的念头是市面上的排盘工具已经那么多了为什么还要再写一个后来真正动手做开源项目才发现绝大多数现成库把“算法”和“界面”绑得太死想改个主题、加一个分析模块、甚至换一套历法数据都得把底层逻辑翻个底朝天。所以这个项目到最后核心思路就变成了两句话界面用 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(frozenTrue) class Pillar: tian_gan: Tiangan di_zhi: Dizhi property def wuxing(self) - tuple: return (self.tian_gan.wuxing, self.di_zhi.wuxing)用frozenTrue保证 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 类型比如在信号里直接传 QDateTimecore 就再也没法脱离 UI 独立测试也没法被其他非 Qt 项目复用了。实际操作上UI 层拿到用户输入的普通 Python 对象在点击按钮的回调里组装成 core 需要的参数调用 core 函数拿到结果再用 signal 发回主线程刷新界面。core 层提供的函数不接受任何 Qt 类型数据全部是int、float、Enum、dataclass。这样划分之后我在写界面的时候只需要关心交互不需要关心历法规则在写算法的时候只需要盯着推算逻辑不需要考虑按钮怎么放。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 的标准解法是QThreadPool加QRunnable把耗时任务丢到线程池完成后通过信号把结果传回主线程。QRunnable的封装我写了一个通用 Workerfrom 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_year2023, before_lichunTrue) 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_zi、test_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 层时前三天几乎没动什么代码很多精力花在把旧逻辑里的隐式依赖挖出来但后面越写越顺测试跑得越来越快。给同样想写开源项目的朋友一个建议先花时间把数据模型和边界条件列清楚再动手写界面这比先把界面做得花里胡哨再回头补算法要省心得多。
返回列表