直接压片单元模型开发教程(2):环境搭建——Python + numpy/scipy 与 PharmaPy 源码安装的依赖拓扑
版本声明块
- 工具/软件:PharmaPy(Purdue CryPTSys,官方仓库
github.com/CryPTSys/PharmaPy,文档https://pharmapy.readthedocs.io/en/latest/);后续TabletPress为本系列二次开发示例类,非官方 API。- 语言/环境:Python(PharmaPy 的
requirements.txt未显式声明 Python 版本,请以官方安装文档为准);核心依赖 numpy(<2)、cython(❤️)、scipy(<1.14)、assimulo(==3.4.3)、matplotlib、pandas。- 本文目标:把"能不能装上、装完怎么证明装对了"这件事一次性做完,并理解这套依赖为什么必须互相咬合。
一句话结论:PharmaPy必须源码安装(发布目录里带setup.py与安装说明文件install_instructions.txt),官方推荐 conda 环境;其requirements.txt明确钉住numpy<2、cython<3、scipy<1.14、assimulo==3.4.3,因此绝不能把它装进一个已有 numpy 2.x 的通用环境——正确做法是建一个专用 conda 环境,按官方依赖清单顺序装,再用一份"打印各库版本 + 安全探测import PharmaPy"的脚本验收。
〇、本篇要解决的认知问题
环境搭建之所以被单独写成一篇,不是因为它难,而是因为它的失败方式特别隐蔽——报错信息指向的位置,几乎总是错的。先摆出五个问题:
- 为什么 PharmaPy 不能
pip install pharmapy一把梭,而必须拿到源码、看到setup.py才能装? requirements.txt里那四行版本钉(numpy<2、cython<3、scipy<1.14、assimulo==3.4.3)各自在防什么?为什么"上界"和"精确钉"混着用?- 官方推荐 conda 而不是纯 pip + venv,这个偏好背后是什么工程现实?
- 装完之后,用什么样的脚本才能"证明装对了",而不只是"没报错"?
- 当 numpy 已经升到 2.x、或者 assimulo 编译失败时,最短的排错路径是什么?
一、机制解析
1.1 为什么必须源码安装
先看官方给出的安装路径。PharmaPy 的安装页与仓库都指向同一件事:它通过源码安装,仓库里带一个含setup.py的目录,并附一份安装说明文件install_instructions.txt,官方推荐使用 conda 环境。
"源码安装"这四个字在工程上有三层含义,缺一层都会踩坑:
- 第一层:源码是唯一可信的版本源。PyPI 上一个同名的包不等于 CryPTSys 官方发布物。拿到
github.com/CryPTSys/PharmaPy的源码,你才知道自己装的是哪一次提交、哪一份requirements.txt。 - 第二层:
setup.py意味着安装过程要执行构建逻辑,而不只是拷贝纯 Python 文件。只要构建链路上有任何 C/Cython 扩展,构建环境(编译器、头文件)就必须在装之前准备好,而不是装到一半才发现。 - 第三层:
install_instructions.txt是官方为"顺序与前置条件"写下的唯一权威说明。依赖清单告诉你"装什么",安装说明告诉你"按什么顺序、在什么环境里装"。只读前者、不读后者,是依赖冲突的头号来源。
所以本篇给出的流程是一个骨架,具体命令以官方安装说明与安装页为准:
# 以下为骨架示意,具体步骤以官方 install_instructions.txt 与安装页为准 conda create -n pharmapy python=3.9 # 建专用环境(Python 版本以官方文档为准) conda activate pharmapy git clone https://github.com/CryPTSys/PharmaPy.git cd PharmaPy pip install -r requirements.txt # 先解决依赖 python setup.py install # 再装本包(或官方说明中的等价命令)有人会问:既然pip自带pip check,为什么还要自己写体检器?答案是两者回答的问题不同。pip check只能告诉你"已安装包的元数据之间是否冲突",它看不见"官方清单要求 numpy<2,而当前环境是 2.x"这类与外部基准的偏差,也不会去探测某个包能否被真实导入,更不会跑一次数值冒烟测试。换句话说,pip check检查的是环境内部自洽,而本篇要检查的是环境是否符合 PharmaPy 的官方要求且可复现。在本系列这种"结果要拿去支撑放大决策"的场景里,后者才是验收标准。这也解释了为什么体检脚本必须把"官方约束"作为一等公民写在代码里——约束的来源是文档,不是当前环境自身。
1.2 依赖拓扑:四行版本钉分别在防什么
requirements.txt的依赖是:numpy<2、cython<3、scipy<1.14、assimulo==3.4.3、matplotlib、pandas。把它摊开看,会发现它刻意混用了两种钉法——“上界"与"精确等号”——这不是随手写的:
| 依赖 | 钉法 | 从依赖清单读出的工程含义 |
|---|---|---|
| numpy | <2 | 防 NumPy 2.x 的 ABI/API 断代。凡有二进制扩展的包,跨大版本升级最容易在"能 import 但一算就崩"的形态下翻车 |
| cython | <3 | 钉住构建期工具链。Cython 3 是行为变化较大的换代,构建期版本失控会让源码构建产生难以定位的编译错误 |
| scipy | <1.14 | 与 numpy 组合约束,保证科学计算栈内部一致;第 17 篇要用的scipy.optimize.curve_fit属于这一栈 |
| assimulo | ==3.4.3 | 精确钉。这类"等号"通常出现在 API 敏感、需要与求解器数值行为严格对齐的组件上;把它钉死,是为了让官方验证过的求解路径可复现 |
| matplotlib | 无钉 | 画图后端,接口稳定,不需要锁 |
| pandas | 无钉 | 表格处理,接口稳定,不需要锁 |
把这张表读成一句话:"上界"防的是大版本断代,"等号"防的是数值行为漂移。前者关心"能不能跑",后者关心"跑出来的数是不是官方验证过的那个数"。这条区分对做仿真的人是刚需——你做的是 GMP 语境下的模型,运行结果必须可复现、可审计。
再看一眼"谁带了钉、谁没带钉"的分布,还有一层信息:带了上界或等号的,全都是处在数值计算链路上的组件(数值库、算法库、求解器、构建工具);而matplotlib与pandas两条都不带钉——它们处在输入输出链路上,只负责画图与读表,即便版本有差异,也不会改变模型的数值结论。这条"数值链路严管、IO 链路放松"的经验,值得搬到你自己写的项目里:给变结果的依赖上钉,给只呈现结果的依赖松绑,才能真正把审计负担降到最低。
需要强调:上表中"防 NumPy 2 ABI 断代""assimulo 数值行为对齐"属于从依赖清单结构读出的工程推断,PharmaPy 内部为何做此选择,请以官方仓库与文档为准。
1.3 为什么是 conda,而不是 pip + venv
官方推荐 conda 环境,这个偏好可以从依赖清单反推出来:
assimulo==3.4.3是精确钉,而不是区间。精确钉的组件往往对"它所链接的底层数值库"敏感;conda 的优势之一,是能把"包 + 二进制依赖"作为一套整体来解析,而不是让 pip 只对纯 Python 元数据做解析。把精确钉放在 pip 环境里,出现"元数据满足、二进制对不上"的概率更高。cython<3说明安装链路要过构建期。构建期依赖(编译器、头文件、构建脚本)需要一套隔离、可复现的环境来承载;专用 conda 环境比"在系统 Python 里就地装"要稳得多。- 你还需要一个不复用系统环境的理由。第 1 篇已经确定:PharmaPy 是原料药仿真库,你的机器上大概率还装着别的科学计算项目。若它们已经把 numpy 顶到 2.x,那么
numpy<2这条钉就会与全局环境直接冲突——专用环境是唯一的干净解法。
把这条逻辑记住:numpy<2的存在,等价于"禁止把 PharmaPy 装进通用科学计算环境"。这不是洁癖,是硬约束。
1.4 "装对了"的判定标准是什么
新手验收环境的方式是"没报错就算过",这在仿真项目里不够。真正的验收标准是三条:
- 版本账本对得上。每个关键库的实际版本要打印出来,逐条与官方依赖清单比对,而不是凭记忆。
- 可导入性被探测过,而不是被假设。
import PharmaPy必须被真实执行一次,并把结果(成功/失败/原因)显式记录下来。 - 生态能算数。至少要跑一个小到微秒级的数值动作(例如一次线性拟合),证明"依赖装齐了、而且联动正常",而不只是"模块能被找到"。
下面这份校验脚本就是按这三条写的。它有一个刻意的设计:对未满足的约束只告警、不中断,因为它在两种场景下都要能用——在专用环境里做验收(应全部通过),以及在通用环境里做体检(应给出原因清晰的告警,告诉你为什么不能在这里装)。
1.5 依赖拓扑图:谁在压着谁
把依赖关系画出来,很多"报错指向奇怪位置"的现象会立刻变得可解释。PharmaPy 这套栈的拓扑大致是这样的:
┌───────────────────────────┐ │ PharmaPy │ │ (源码安装 setup.py) │ └───────────┬───────────────┘ ┌───────────────┬───────┴────────┬───────────────┐ ▼ ▼ ▼ ▼ ┌─────────┐ ┌──────────┐ ┌───────────┐ ┌───────────┐ │ numpy<2 │◄───│ scipy<1.14│ │assimulo │ │ matplotlib│ │ (数值底) │ │ (算法层) │ │==3.4.3 │ │ pandas │ └────┬────┘ └──────────┘ │ (求解器) │ │ (无钉) │ │ └───────────┘ └───────────┘ ▲ ┌────┴────┐ │ cython<3│ ← 构建期工具链,只在“安装时”参与 └─────────┘三条要读出来的结论:
- numpy 是全栈的地基。scipy 依赖它,求解器的二进制链路也依赖它。所以
numpy<2这一条被违反时,报错往往不会出现在 numpy 的导入处,而是出现在某个下游库"一算就崩"的地方——这就是"报错位置骗人"的机理。 - cython 只在安装那一瞬参与。它不在运行期用,所以"运行报错"永远不该去怀疑 cython;反过来,"安装阶段报错"时它是头号嫌疑人之一。
- assimulo 是孤岛式的精确钉。它不通过 numpy 被间接约束,而是被官方单独钉死到
3.4.3;因此它既是"最容易装不上"的一环,也是"装上了但数不对"的一环。
1.6 依赖冲突的三种形态:装不上、跑不了、数不对
做仿真的人和做业务系统的人,对"环境坏了"的理解不一样。业务系统只有一种坏法——起不来;仿真栈有三种,而且后两种最贵:
| 形态 | 表面现象 | 真实原因 | 代价 |
|---|---|---|---|
| 装不上 | pip install解析失败或构建报错 | 上界/精确钉与全局环境冲突,或构建期工具链缺失 | 时间成本,暴露即时 |
| 跑不了 | 装得干净利落,import却报二进制不兼容 | NumPy 2.x 之类的 ABI 断代(numpy<2要防的正是它) | 隐性,容易误判为"这个库有 bug" |
| 数不对 | 一切都跑得通,曲线也画得出来,但结果与官方教程对不上 | 求解器数值行为漂移(assimulo==3.4.3要防的正是它) | 最贵:错误结果是"看起来正确的",直到做放大决策时才反噬 |
第三种形态是本系列最在意的。它解释了为什么一个 20 年固体制剂背景的工程师会对"环境版本"如此执拗:在一套用于放大决策的模型里,一个能跑出漂亮曲线但不复现官方数值的环境,比一个根本跑不起来的环境危险得多。前者会让你带着错误的信心去过工艺评审。
因此本篇验收脚本的第三条(冒烟测试)不是形式主义——它存在的意义,是把"数不对"这类隐患拦在项目开始之前。
二、完整代码与逐行剖析
# -*- coding: utf-8 -*-""" 第 2 篇配套脚本:PharmaPy 环境体检器。 功能: 1) 打印关键库的实际版本,并与官方 requirements.txt 的约束逐条比对; 2) 安全探测 PharmaPy 是否可导入(不因失败而崩溃); 3) 做一次最小科学计算冒烟测试,证明 numpy/scipy 联动正常。 依赖:仅 numpy、scipy(均为 PharmaPy 必装项),不依赖 PharmaPy 本体。 """importsysimportplatformimportimportlibimportimportlib.metadataasmdimportnumpyasnpfromscipy.optimizeimportcurve_fit# 官方 requirements.txt 的关键约束(以官方仓库当时的清单为准)REQUIREMENTS={"numpy":"<2","cython":"<3","scipy":"<1.14","assimulo":"==3.4.3","matplotlib":"*","pandas":"*",}defparse_version(text):"""把 '1.2.3rc1' 之类解析成可比较的整数元组;无法解析的部分忽略。"""parts=[]fortokeninstr(text).replace("-",".").split("."):num=""forchintoken:ifch.isdigit():num+=chelse:breakparts.append(int(num)ifnumelse0)returntuple(parts)defsatisfies(version,spec):"""极简约束判断:仅支持 'X<Y'、'X<Y.Z' 与 '==X.Y.Z' 三种写法。"""ifspec=="*":returnTrue,"无约束"ifspec.startswith("=="):target=parse_version(spec[2:])got=parse_version(version)returngot[:len(target)]==target,f"需{spec}"ifspec.startswith("<"):target=parse_version(spec[1:])returnparse_version(version)<target,f"需{spec}"returnTrue,"未识别约束(忽略)"defget_installed_version(pkg):"""返回已安装版本字符串;未安装返回 None。优先 metadata,失败再尝试导入。"""try:returnmd.version(pkg)exceptmd.PackageNotFoundError:returnNoneexceptException:try:mod=importlib.import_module(pkg)returngetattr(mod,"__version__","unknown")exceptException:returnNonedefprobe_pharmapy():"""安全探测 PharmaPy;返回 (状态, 版本或原因)。"""try:mod=importlib.import_module("PharmaPy")return"OK",getattr(mod,"__version__","unknown")exceptModuleNotFoundErrorase:return"MISSING",str(e)exceptExceptionase:return"ERROR",f"{type(e).__name__}:{e}"defsmoke_test():"""最小科学计算冒烟测试:线性模型拟合,确认 numpy/scipy 联动正常。"""rng=np.random.default_rng(0)x=np.linspace(0.0,1.0,50)y_true=3.0*x+1.0y=y_true+rng.normal(0.0,0.02,size=x.size)defmodel(xx,k,b):returnk*xx+b popt,_=curve_fit(model,x,y,p0=[1.0,0.0])returnpoptdefmain():print("="*62)print(f"Python{platform.python_version()}({platform.system()}{platform.machine()})")print(f"解释器:{sys.executable}")print("="*62)print("\n[1] 关键依赖版本账本")print(f"{'包名':<12}{'实际版本':<14}{'官方约束':<12}{'判定'}")print("-"*52)warn=0forpkg,specinREQUIREMENTS.items():ver=get_installed_version(pkg)ifverisNone:judge="未安装"ifspec!="*":judge+="(必装项,请补装)"warn+=1else:ok,hint=satisfies(ver,spec)judge="通过"ifokelsef"不满足({hint})"ifnotok:warn+=1print(f"{pkg:<12}{str(ver):<14}{spec:<12}{judge}")print("\n[2] PharmaPy 可导入性探测")status,info=probe_pharmapy()print(f" import PharmaPy ->{status}:{info}")ifstatus!="OK":print(" 提示:PharmaPy 需源码安装,且本环境须满足 numpy<2 等约束;")print(" 请另建专用 conda 环境,步骤以官方 installation 文档为准。")print("\n[3] numpy/scipy 冒烟测试(线性拟合)")popt=smoke_test()print(f" 拟合斜率 k ={popt[0]:.4f}(真值 3.0),截距 b ={popt[1]:.4f}(真值 1.0)")print("\n"+"="*62)print(f"体检完成:{warn}项需要关注。")print("="*62)if__name__=="__main__":main()逐段剖析:
- 为什么用
importlib.metadata而不是pkg.__version__?因为"模块能 import"和"包已正确安装"是两件事。importlib.metadata读的是已安装分发包的元数据,能抓到assimulo这类可能以已安装状态存在、但其模块名与包名不一致的情况;脚本里对 metadata 失败做了兜底,但主路径走 metadata。 parse_version为什么不用packaging.version?因为packaging不在这份依赖清单里。校验脚本的第一原则是"不能用被校验对象之外的依赖把自己撑起来",否则又引入一条新的冲突面。这里的极简解析足以处理1.2.3、1.2.3rc1这类真实版本串。satisfies只支持三种写法是刻意的。官方清单里出现的约束形态就是上界(<)与精确钉(==),把它写成通用约束求解器,反而是过度工程,且容易在校验器里藏 bug。probe_pharmapy把三种失败分开:MISSING(没装)、ERROR(装了但导入时炸)、OK。这三者的排错方向完全不同——MISSING去装,ERROR去查依赖冲突,OK才继续。把它压成一个 try/except 打印import failed,会把最有价值的信息丢掉。- 冒烟测试的阈值。用固定随机种子(
default_rng(0)),拟合出的k、b才可复现。这条设计直接服务于本系列的"可审计"目标:同一份输入,任何时候跑出的数都应该一样。第 17 篇做curve_fit参数估计时,这条纪律会再次出现。
反直觉的默认值提醒:NumPy 的旧式全局随机数np.random.seed()与新的np.random.default_rng()是两套体系。前者是全局状态,跨模块相互污染;后者是显式实例,可复现性可控。做可审计仿真,一律用default_rng。
三、常见报错与排查
1)现象:pip install完毕,import PharmaPy报ImportError: numpy.core.multiarray failed to import或类似的二进制不兼容信息。
根因:环境里是 numpy 2.x,而 PharmaPy 钉的是numpy<2;有二进制扩展的组件在跨大版本后,常在运行时才暴露。
解法:不要试图"就地降级"你正在用的通用环境。新建专用 conda 环境,在其中安装numpy<2,再按官方说明装 PharmaPy。用本脚本的 [1] 段先确认numpy一行显示"通过"。
2)现象:assimulo==3.4.3安装阶段失败,报编译错误或找不到编译器。
根因:精确钉 + 需要构建期环节,意味着这台机器上缺少构建所必需的工具链,或构建环境不干净。
解法:优先在 conda 环境中解决(官方即推荐 conda);确保构建工具链可用;如仍失败,参照官方install_instructions.txt与安装页给出的前置条件。具体前置依赖清单以官方文档为准。
3)现象:装的时候一切正常,但一进 PharmaPy 的教程 notebook 就报求解器相关的错。
根因:动态仿真的求解链路(DAE 求解器)与数值库版本组合不匹配——这正是assimulo==3.4.3与scipy<1.14要防的组合失配。
解法:用脚本 [1] 段逐条核对;尤其确认scipy未越过 1.14、assimulo恰为 3.4.3。
4)现象:ModuleNotFoundError: No module named 'PharmaPy',但pip list里明明有相关条目。
根因:装在了另一个 Python 解释器/环境里。用 conda 时最常见的姿势错误是"建了环境但没激活"。
解法:看脚本打印的解释器:行——它告诉你当前脚本实际跑在哪个python.exe上。把它和你以为的环境路径比对,通常问题当场现形。
5)现象:校验脚本 [1] 段大量"未安装",但你确认装过。
根因:包名与导入名不一致(例如某些包的分发包名与模块名不同),或装在用户级 site-packages 而当前解释器未包含。
解法:脚本已对 metadata 失败做了导入兜底;若两边都拿不到版本,先pip list | Select-String <包名>确认分发包名,再回到解释器路径问题上。
6)现象:昨天还好好的环境,今天import numpy突然变成 2.x。
根因:在同一个 conda 环境里混用了conda install与pip install,后者的解析器不认识前者的约束,把numpy<2顶了上去;或者某个新装的第三方包把 numpy 作为无条件依赖拉高了。
解法:一是养成"一个环境只用一种包管理器"的纪律;二是在装上任何新包之后,重新跑一遍本篇的体检脚本。这正是把校验脚本当"回归测试"来用的价值——版本约束不是一次性的配置,而是需要持续守住的边界。
四、动手练习
练习 1(基础):跑一次环境体检并读懂结论。
把脚本存为env_check.py并在你的工作环境运行。成功标准(量化):终端输出三段(版本账本、导入探测、冒烟测试)齐全;[3] 段拟合结果的k落在 2.95–3.05、b落在 0.95–1.05 区间。
练习 2(进阶):让体检器输出机器可读的结论。
给脚本加一个--json开关,把版本账本与 PharmaPy 探测结果序列化为 JSON 打印。成功标准(量化):python env_check.py --json输出的 JSON 能被json.loads解析且包含 6 个包的版本字段与一个pharmapy_status字段;不给开关时行为与原来完全一致。
练习 3(挑战):把"未满足约束"从告警升级为可解释的诊断。
实现diagnose(pkg, ver, spec),对每个不满足的约束返回一句"为什么这条钉会咬人"和一句"下一步该做什么"。成功标准(量化):在 numpy 2.x 环境下,numpy一行输出包含"numpy<2"与"专用环境"两个关键词;对全部满足的环境,本函数不产生任何输出。
五、小结与下一篇预告
本篇把环境这件事从"玄学"拉回工程:PharmaPy必须源码安装(含setup.py与install_instructions.txt),官方推荐 conda;requirements.txt用numpy<2、cython<3、scipy<1.14三条上界防大版本断代,用assimulo==3.4.3一条精确钉防数值行为漂移;验收标准必须是"版本账本 + 可导入性探测 + 冒烟测试"三条一起过。
还有一条必须记住的硬约束:numpy<2等价于"禁止装进通用科学计算环境"。这也是为什么第 5 篇的自研TabletPress类会被写成"不强依赖 PharmaPy 本体也能跑"的形态——这样即使某台机器上 PharmaPy 环境还没配好,压缩循环的建模工作也不至于停摆。关于 PharmaPy 究竟支持哪些 Python 版本,官方requirements.txt未显式声明,以官方安装文档为准;本系列因此不锁死 Python 小版本,只锁依赖约束。
下一篇(第 3 篇)进入硬件与机理:压片机结构与压缩循环——冲头/冲模/预压辊/主压辊/转台/加料器各司其职,压缩事件五阶段(充填 → 预压 → 主压 → 解压 → 脱模)到底发生了什么,上辊驱动上冲、下辊驱动下冲为什么被称为对称压缩,并以 matplotlib 画出五阶段的时间轴与压力/位移双轴示意图。第 4 篇将用 numpy 生成三类位移-时间曲线,第 5 篇将落地TabletPress类,第 7 篇将详述驻留时间。
本篇认知问题回显(FAQ)
Q1:为什么 PharmaPy 不能pip install pharmapy一把梭,而必须源码安装?
A:官方安装路径为源码安装——仓库含setup.py目录与安装说明文件install_instructions.txt,推荐 conda 环境;源码是唯一可信的版本源,且setup.py说明安装要过构建环节,install_instructions.txt是顺序与前置条件的唯一权威说明。
Q2:numpy<2、cython<3、scipy<1.14、assimulo==3.4.3各在防什么?为什么上界与精确钉混用?
A:三条上界防大版本断代(保证"能不能跑"),assimulo==3.4.3这条精确钉防数值行为漂移(保证"跑出来的数可复现");matplotlib、pandas无钉,因其接口稳定。
Q3:官方为什么推荐 conda 而不是 pip + venv?
A:因为存在assimulo==3.4.3这类精确钉(需把包与其二进制依赖作为整体解析)与cython<3暗示的构建期环节,且numpy<2要求与通用环境隔离;专用 conda 环境是唯一干净解。
Q4:用什么脚本才能"证明装对了"?
A:三条一起过——打印关键库实际版本并与官方约束逐条比对;真实执行一次import PharmaPy并区分 MISSING/ERROR/OK;跑一次可复现的 numpy/scipy 冒烟测试(固定随机种子的线性拟合)。
Q5:numpy 已升到 2.x、或 assimulo 编译失败时,最短排错路径是什么?
A:numpy>=2 不要就地降级,改新建专用 conda 环境装numpy<2;assimulo 编译失败优先在 conda 环境内解决构建工具链,前置依赖清单以官方install_instructions.txt与安装页为准。