Metadata-Version: 2.4
Name: xyzsdf
Version: 0.1.0
Summary: Align a 3D molecular structure to the viewing direction of its 2D SDF depiction
Author-email: wxyhgk <wxyhgk@qq.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/wxyhgk/xyzsdf
Project-URL: Repository, https://github.com/wxyhgk/xyzsdf
Project-URL: Issues, https://github.com/wxyhgk/xyzsdf/issues
Project-URL: Changelog, https://github.com/wxyhgk/xyzsdf/blob/main/CHANGELOG.md
Keywords: chemistry,molecular-visualization,quantum-chemistry,cube-file,rdkit,molecular-orbital,alignment
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Chemistry
Classifier: Topic :: Scientific/Engineering :: Visualization
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.24
Requires-Dist: rdkit>=2022.09
Provides-Extra: db
Requires-Dist: psycopg2>=2.9; extra == "db"
Provides-Extra: render
Requires-Dist: xyzrender>=0.3; extra == "render"
Dynamic: license-file

# xyzsdf

[![tests](https://github.com/wxyhgk/xyzsdf/actions/workflows/tests.yml/badge.svg)](https://github.com/wxyhgk/xyzsdf/actions/workflows/tests.yml)
[![PyPI](https://img.shields.io/pypi/v/xyzsdf)](https://pypi.org/project/xyzsdf/)
[![Python](https://img.shields.io/pypi/pyversions/xyzsdf)](https://pypi.org/project/xyzsdf/)
[![License](https://img.shields.io/pypi/l/xyzsdf)](LICENSE)

**按二维结构图给三维分子定取向，并把同一个取向复用到该次计算的所有文件上。**

## 解决什么问题

量化程序（Gaussian、ORCA…）输出的几何用的是它自己的标准取向——由惯性主轴决定，和化学家怎么看这个分子毫无关系。于是同一批分子渲染出来，每个朝向都不一样：这个侧着、那个背对着、下一个躺平。作为一套图，读者没法比较。

而 ChemDraw 画的二维结构图**恰恰是人挑好的视角**。它已经决定了"这个分子应该这么看"。

这个包做的就是把后者的取向搬到前者上：求出让三维结构正对二维画法的那个旋转。

它和查看器自带的"自动定向"（主轴、`--orient` 之类）**不是一回事**——自动定向只能猜一个几何上合理的角度，猜不出你想让读者看哪一面。二维结构图携带的正是这个信息。

```python
from xyzsdf import align_structures, formats

alignment = align_structures(open("mol.xyz").read(), open("ref.sdf").read(), "ref.sdf")

formats.apply_transform(open("homo.cube").read(), alignment.transform, "cube")  # 转 cube
alignment.transform.as_matrix4()                                                # 或者当相机用
```

一次计算里的 xyz、cube 本来就在同一个坐标系里，所以**一个变换覆盖它名下所有文件**。

## 核心是变换，不是文件

对齐的产物是一个 `RigidTransform`，不是一个转好的文件。

这不是措辞讲究，而是因为**不同格式"施加旋转"的代价差好几个数量级**：

| 文件 | 施加旋转的代价 |
|---|---|
| XYZ | 坐标乘矩阵 |
| cube | 改 header 里的 origin 和三个网格轴，体数据一字节不动 |
| molden | 坐标 **加上** 全部 MO 系数（按角动量做 Wigner-D 旋转） |
| 查看器 | 把 4×4 矩阵当相机用，什么文件都不用改 |

所以核心算完变换就停手，怎么花由调用方决定。要是你的前端能接受相机矩阵，最省事的做法是**文件一个都别动**——同一次计算的 xyz 和 cube 本来就在同一个坐标系里，转一次相机全都对。

## 安装

```sh
pip install xyzsdf               # 核心：numpy + rdkit
pip install 'xyzsdf[db]'         # + psycopg2，批量驱动
pip install 'xyzsdf[render]'     # + xyzrender，出图

pip install -e .                 # 从源码开发安装
```

需要 Python 3.10+。`rdkit>=2022.09` 不是随手写的下限——键感知用的
`rdDetermineBonds` 正是那一版才引入。

## 命令行

```sh
# 对齐；--apply-to 在同一个进程里把变换施加到别的文件，不产生中间文件
xyzsdf-align mol.xyz ref.sdf -o out.xyz --apply-to homo.cube lumo.cube

# 只有需要跨进程/跨机器传递变换时才导出 JSON
xyzsdf-align mol.xyz ref.sdf --dump-transform t.json
xyzsdf-apply t.json homo.cube

# 批量：从 PostgreSQL 读结构，默认 dry-run（表结构见下方说明）
xyzsdf-align-db --host H --port 5432 --user U --password P \
    --dbname D --verbose --error-file errors.txt [--apply]

# 出图（需要 xyzrender）
./extras/render_mo.sh homo_aligned.cube out.png [iso] [opacity]
```

源码树里 `python3 align_xyz_to_sdf.py …` 等老写法仍然可用，根目录留了同名薄壳。

> **`xyzsdf-align-db` 对表结构只有一个很弱的假设**：两个存文本的列（3D 结构和 2D 描绘）
> 加一个可空的标记列，只处理标记列为 NULL 的行。表名和四个列名都是命令行参数：
> `--table`（必填）、`--id-column`、`--xyz-column`、`--sdf-column`、`--pending-column`。
> 它是从一个内部批处理脚本长出来的，把它当成"怎么接数据库"的可用示例即可——批处理的
> 核心不过是循环里那一句 `align_structures`。

## 库

```python
from pathlib import Path
from xyzsdf import align_structures, formats

xyz_text = Path("mol.xyz").read_text()
alignment = align_structures(xyz_text, Path("ref.sdf").read_text(), "ref.sdf")

print(f"2D RMS = {alignment.xy_rms:.4f}")
if alignment.is_degenerate:
    print(f"取向存疑：次优视角相差 {alignment.rival_angle_deg:.0f}°")

rotated = formats.apply_transform(xyz_text, alignment.transform, "xyz")
camera = alignment.transform.as_matrix4()     # 列向量约定，直接喂查看器
```

核心不做任何文件 IO：字符串进、字符串出，对象进、对象出。一个 `alignment.transform`
可以施加到任意多个文件，全程留在内存里。`to_dict()` 返回的是普通字典——落到 JSON、
数据库列、HTTP 响应，还是不落，由调用方决定。

读几何：

```python
geom = formats.parse(text, "cube")     # 也支持 xyz / sdf
len(geom), len(geom.heavy())

formats.supported_formats("read")      # ['cub','cube','mol','sdf','xyz']
formats.supported_formats("apply")     # ['cub','cube','xyz']
```

## 包结构

```
errors.py        失败分类
units.py         内部统一 Å，各格式在自己边界换算
structure/       一个分子是什么，以及怎么再认出它
  geometry.py      Geometry —— "有哪些原子、在哪"的唯一真相
  fingerprint.py   指纹，用于校验变换是否属于这个文件
transform/       旋转：怎么表示、怎么拟合
  rigid.py         RigidTransform
  fit.py           Kabsch
formats/         文本 <-> Geometry，以及施加变换
─────────── 以上只需 numpy，以下需要 rdkit ───────────
align/           判断什么对应什么
  bonding.py       RDKit 唯一被问的问题：哪些原子成键
  correspondence.py 哪个原子对哪个，以及有多确定
  pipeline.py      编排
```

那条横线是有约束力的，不是装饰。**施加一个存好的变换是纯算术**，只想花变换的下游服务不该被逼着装一整套化学工具链；**判断原子对应关系是图问题**，那才真需要。`tests/test_boundary.py` 双向守住这条线。

`Geometry` 是本包的脊梁：每种格式都解析成它，所有消费者都从它读原子，**RDKit 只被问一个问题——哪些原子成键**。早期版本每个分子被解析两次（自己的 parser 一次、RDKit 一次）再对账，显式氢那个崩溃就住在对账的缝里。

## 几个踩过的坑

每条的详细复盘（症状、证据、根因、现在的防线）在 [docs/pitfalls/](docs/pitfalls/)。

| 坑 | 一句话 |
|---|---|
| [单一真相](docs/pitfalls/single-source-of-truth.md) | 同一份数据解析两遍，迟早分叉——显式氢崩溃的根因 |
| [取向简并](docs/pitfalls/orientation-degeneracy.md) | 二维投影几乎分不清正反面，取向可能是猜的；看 `is_degenerate` |
| [点 vs 方向](docs/pitfalls/points-vs-vectors.md) | `apply_points` 和 `apply_vectors` 不能混用，混了会静默错位 |
| [约定与量纲](docs/pitfalls/conventions-and-units.md) | 行/列向量差一个转置；pivot 有量纲，旋转矩阵没有 |
| [手性](docs/pitfalls/chirality-and-reflection.md) | `det(R)` 掉到 −1 就把分子换成了对映体 |
| [cube 格式](docs/pitfalls/cube-format.md) | 两个符号位、DSET_IDS 折行、转完不再轴对齐 |
| [molden](docs/pitfalls/molden-orbital-rotation.md) | 只转核不转 MO 系数 = 物理上错误的轨道，且不报错 |
| [来源校验](docs/pitfalls/transform-provenance.md) | 套错文件或施加两次，产物照样正常 |
| [xyzrender](docs/pitfalls/xyzrender.md) | `--no-orient` 不能省；默认 `--iso 0.05` 藏掉整个轨道 |
| [工具链](docs/pitfalls/toolchain.md) | zsh 不做词分割、Python 3.12 移除 `find_module` 等 |

## 版本与稳定性

`0.x` 期间公开 API 可能变更，变更记录在 [CHANGELOG.md](CHANGELOG.md)。

## 测试

```sh
pytest                                # 或逐个：for t in tests/test_*.py; do python3 "$t"; done
```

CI 在 Linux 上跑 Python 3.10–3.13、外加一个 macOS，所以 `requires-python`
是**跑出来的事实**而不是声明。另外还会构建 wheel、用 `twine check --strict`
验证 PyPI 元数据、装进干净 venv 跑三个命令，并单独确认"只装 numpy 也能施加变换"。

| 文件 | 守住什么 |
|---|---|
| `test_boundary.py` | 依赖分层双向成立；核心不在模块级 import 任何 extra |
| `test_geometry.py` | Geometry、格式能力声明、错误分类 |
| `test_alignment.py` | 输出逐字节稳定、枚举顺序无关、det=+1、margin 区分明确与简并 |
| `test_provenance.py` | 套错分子抛错、重复施加告警 |

测试数据是两个**公开分子**，由 `tests/fixtures/generate.py` 从 SMILES 可复现地生成
（ETKDG 固定种子 + MMFF）。`aspirin_aligned.xyz` 是逐字节基线，钉住整条流水线。

两个 fixture 刻意卡在取向确信度的两端：

| 分子 | 重原子 | 候选映射 | margin | 竞争视角 |
|---|---|---|---|---|
| 阿司匹林 | 13 | 2 | **0.92** | 3.0° |
| 四苯乙烯 | 26 | 128 | **0** | 180.0° |

四苯乙烯是螺旋桨形、准对称的分子，次优视角就是它的**另一面**、分数完全相同——正是
`is_degenerate` 要拦的那种情况。

## 许可证

[MIT](LICENSE)
