Metadata-Version: 2.5
Name: pm-schedule
Version: 0.1.0
Summary: PM Schedule prediction for semiconductor fab (polars + parquet + echarts)
Author: PM Schedule
License: Proprietary
Requires-Python: >=3.11
Requires-Dist: fastapi>=0.110
Requires-Dist: fastexcel>=0.11
Requires-Dist: numpy>=1.24
Requires-Dist: polars>=1.0
Requires-Dist: uvicorn>=0.29
Requires-Dist: xlsxwriter>=3.2
Provides-Extra: dev
Requires-Dist: openpyxl>=3.1; extra == 'dev'
Description-Content-Type: text/markdown

# PM Schedule 预测

从 **PM Plan / Counter Usage / Move Forecast** 三源推算 Fab 每台机器未来 1.5 年内
**每一次 PM 的发生时点**，产出行级明细 + 进度曲线，并用一个只读网页展示。
后续用来支撑 Y27 的 PM 预算（金额与明细表导出不在本期范围）。

---

## 快速开始

```bash
uv venv && source .venv/bin/activate
uv pip install -r requirements.txt

# 1. 放进三源数据（见下方“数据放置”）
python samples/make_sample.py                 # 没有真实数据时先跑 mock

python run.py check                           # 只校验字段映射，不写文件
# 装成包后以上的 run.py 全部等价于 pm-schedule check/ingest/predict/serve
python run.py ingest                          # xlsx/csv → parquet + 数据体检
python run.py predict --as-of 2026-10-23      # 生成 PM Schedule
python run.py serve --port 8000               # 打开 http://127.0.0.1:8000

python tests/test_engine.py                   # 回归校验
```

`ingest` 会打印一份体检报告：键重复、mean 缺失、forecast 断档、连接覆盖率。
**先看体检，再看预测**——大部分“结果不对”都是数据连接问题。

---

## 数据放置

三个文件放 `data/raw/`，按前缀自动发现（`.xlsx` / `.csv` 均可）：

| 前缀 | 必需 | 说明 |
|---|---|---|
| `pm_plan*` | ✅ | 每行 = 一条 PM 触发规则 |
| `counter_usage*` | ✅ | 每行 = 一台机/Chamber 的 counter 系数（90 天移动平均） |
| `move_forecast*` | ✅ | 每行 = 某机台/Chamber 某天的 moves |
| `tool_master*` | 可选 | EQID → 部门 / 机型（三源里没有这两个字段） |

字段名不用改源代码，全部在 `config/schema.toml` 里配置（见下一节）。

### 字段映射与自检

`config/schema.toml` 是唯一的配置文件。每个数据源可以指定：

```toml
[pm_plan]
file  = "PM_Plan_20261023.xlsx"   # 留空 = 自动找 data/raw/pm_plan*
sheet = 0                         # sheet 名或序号

[pm_plan.columns]
plan_no = "PM_PLAN_ID"            # 直接写生产列名 = 精确指定
eqid    = ["EQID", "EQ ID"]       # 写数组 = 按顺序取第一个命中的
```

匹配时忽略大小写、空格、下划线、连字符、点号，所以 `PM Plan No` 与 `pm_plan_no` 等价。
`strict = true`（默认）时必需字段没匹配上会直接报错，并打印文件里实际的列名。

**进内网第一件事**：

```bash
python run.py check
```

它不写任何文件，只输出「规范字段 ← 生产列名」的对照表、未匹配的必需字段、以及文件里
没被用到的列。看到这样就说明可以跑了：

```
[pm_plan]  PM_Plan_20261023.xlsx   1,842 行 × 13 列
   plan_no           ← Plan No
   trigger_code      ← Trigger Code
   ...
映射完整，可以跑 ingest
```

字段名写错时是这样，`未使用的列` 通常就是答案：

```
   plan_no           ✗ 未匹配（必需，将使用默认值）
   未使用的列: Plan No
有 1 个必需字段未就绪
```

配置也可以放在别处，用 `--schema` 指过去：
`python run.py check --schema config/fab_a.toml`。

### 数据模型

**PM Plan**（一行 = 一条触发规则，`Plan No + Trigger Code + Interval + EQID + SUB_EQID` 唯一）

| 字段 | 含义 |
|---|---|
| Plan No | PM 计划 ID，也是一个 Plan 下多条触发规则的公共键 |
| Trigger Code | 计数器 ID（≈ SVID），同时决定单位与用量来源 |
| EQID / SUB_EQID | 机台 / Chamber；**SUB_EQID 为空 = 整机级**，落成哨兵值 `TOOL` |
| Due Date | 当前这次 PM 的计划到期时点 |
| Postpone + Offset Unit | 人为顺延量及单位 |
| Trigger Interval | 计数目标值 |
| Initial Value | PM 后 counter 被 reset 到的值（不一定为 0） |
| current value | 抓取时刻的 counter 读数 |
| Category | `scheduling` / `condition` / `times` |
| Value Change Way | `Trend Up` / `Trend Down` |
| Package Name | 零件包（行级维度，后续预算挂载点） |

**Counter Usage**：`(EQID, SUB_EQID, Trigger Code) → mean`，`mean` = 每次 wafer move
的 counter 增量。静态系数表，无时间维度。

**Move Forecast**：`(EQID, SUB_EQID, Date) → Moves`，日粒度。整机级的行 `SUB_EQID` 为空。

> ⚠️ `SUB_EQID` 的空值在 ingest 时统一归一成 `TOOL`。**不要**用 `sum(chamber)` 去
> 构造整机 moves，表里已经有整机级那行，自己聚合会口径错位。

---

## 预测规则

每行 PM 轨独立推算（Parts Package 已挂在行上，**不合并**同一 Plan 下的多个 Trigger）。

统一成「已消耗 / 名义容量」：

| Category | 首次到期 | 后续每轮 | 速度来源 |
|---|---|---|---|
| `scheduling` | `Due Date + Postpone` | 每次 `+ Trigger Interval`（单位由 Trigger Code 定，`daily`→天） | 日历，1 天/天 |
| `condition` | counter 走到 `Interval + Postpone` | reset 到 `Initial`，再走 `Interval − Initial` | `mean × 每日 moves` 累计 |
| `times` | 第 `Interval − current` 次 link PM | 每 `Interval − Initial` 次 link PM | 被 link 的 Plan 的发生次数 |

- **Trend Down** 取镜像：`current − target`、`Initial − Interval`，且忽略 Postpone
- **逾期**（首轮时点 < as-of）：在 as-of 记一次，之后按 as-of 重新起算
- **Postpone 单位** = Trigger Code 单位（condition 直接加在首次目标上）

实现上，`condition` 按用户方案逐日推进：算 EOD 值 → 若当日剩余 delta 会 cross target，
日内解出交点 → reset → 用剩余 delta 继续 → 直到得到 EOD 值。全程对**所有行**用 numpy
状态数组并行推进，不是逐行 Python 循环。

---

## PM Progress %

三类共用一条公式：

```
progress(t) = consumed(t) / |nominal_target − start|
```

| 类别 | consumed | 分母 | 100% 落在 |
|---|---|---|---|
| scheduling | 距上次 PM 的天数 | `Trigger Interval`（天） | 上次 PM + Interval |
| condition | counter 累计增量 | `|Interval − Initial|` | counter = Interval |
| times | link PM 次数 | `|Interval − Initial|` | 次数 = Interval |

三个刻意的设计：

1. **分母永远是名义容量，不因 Postpone 放大**。所以延期期间进度自然 >100%，
   「已批准延期」和「真逾期」画在同一根轴上。
2. **reset 到非零值时，分母用 `Interval − Initial`**（如 100↔500 就是 400）。
   若错用 `Interval`，每次 reset 会显示成 20% 而不是 0%。
3. **曲线从 as-of 起画**，首点 = 当前已完成进度。condition/times 没有历史 counter
   曲线（Counter Usage 只有系数），无法回推。

曲线数据 = 每日 EOD 点 + 每次 cross 的交点（同一时间戳两个点：reset 前的 >100% 和
reset 后的 0%），高频 PM 的锯齿靠 ECharts 的 dataZoom 展开看。

---

## 输出

`data/out/` 下四张 parquet：

| 文件 | 内容 |
|---|---|
| `pm_events.parquet` | 事件级明细（一行 = 一次 PM）：时点、部门、机型、机台、Chamber、PM Plan、Trigger、类别、逾期标记、reset 前进度 |
| `pm_progress.parquet` | 进度长表 `(row_id, ts, progress, kind)`，含 `eod` 与 `cross` 两类点 |
| `pm_rows.parquet` | PM 轨清单（含被排除的行及原因） |
| `pm_excluded.parquet` | 被排除的行及原因：`no_usage` / `no_forecast` / `no_due_date` / `degenerate_capacity` / `link_missing` |

这是后续 Y27 预算明细表的原料。网页上的「导出 Excel」就是把它按当前筛选导出。
**金额 / 备件 / 排程优化不在本期范围。**

---

## Web

`python run.py serve` → `http://127.0.0.1:8000`。FastAPI + ECharts，**echarts.min.js 已放在
`web/static/`**，不依赖 CDN，可离线跑在内网。

左右两栏，只留两样东西：

- **左：PM 清单** — 一行一条 PM 轨（机台/Chamber · Plan/Trigger），带当前进度条；
  按进度降序，被排除的行灰显并标原因。点击选中（单选），再点取消。
- **右：Trend chart** — 选中轨的进度曲线，默认全时间轴，100% 虚线，底部可缩放。
  hover 卡片显示：**日期时间、机台/Chamber、Plan/Trigger、进度 %、Counter 读数**；
  正好落在触发点时会额外显示 `reset 到` 和「PM 触发」标记。

右上角 **导出 Excel** 按钮：把当前筛选结果导出成 xlsx，列名已按预算明细表的口径整理
（日期 / 时间 / 部门 / 机型 / 机台 / Chamber / PM Plan / Trigger Code / 类别 / Trend /
Parts Package / 第几次 / 逾期补做 / 完成时进度% / 距 as_of 天数）。也可以直接打
`GET /api/export?category=condition` 之类的接口。

---

## 性能

mock 规模（1000 条 PM 轨 / 33 万行 forecast / 549 天）：**0.35 秒**，产出 9.5k 事件、
57 万行进度点。事件量级 10⁴–10⁵ 时仍是秒级。

---

## 已知边界

- **金额、明细表导出、备件、排程优化、权限、部署**：不在本期。
- `times` 的进度曲线按阶梯实现，属**待定**项，可随时改。
- `mean` 是 90 天移动平均快照，一次运行内视为常数；产能大幅爬坡/转产的机台会有系统性偏差。
- forecast 缺失的日期按 0 moves 处理（不动就不消耗，PM 自然顺延）。
- 同一 Plan 下多个 Trigger 不合并——若预算侧发现次数偏高，需要重新讨论 reset 耦合。
- `as-of` 目前手输；未来改成读三源的时间戳。

---

## 装到内网（pip）

wheel 里**只有 12 个文件，不含任何依赖**（`echarts.min.js` 占 1 MB，压缩后整个包 363 KB）。
依赖是声明式的，由 pip 从内网镜像装：

```bash
# 1. 依赖走内网镜像
pip install "polars>=1.0" "numpy>=1.24" fastexcel xlsxwriter fastapi uvicorn

# 2. 本体走 pages.dev 上的 wheel
pip install --no-deps https://<项目>.pages.dev/pm_schedule-0.1.0-py3-none-any.whl
```

`--no-deps` 是故意的：不让公网地址去解析依赖，依赖只认内网镜像。

装完在**任意空目录**：

```bash
pm-schedule init                       # 生成 config/schema.toml + data/{raw,interim,out}
pm-schedule demo                       # 造 mock 三源，先证明环境和前端都通
pm-schedule all --as-of 2026-10-23
pm-schedule serve                      # http://127.0.0.1:8000
```

工作目录由当前路径决定；也可以 `export PM_SCHEDULE_HOME=/path/to/ws`，
或 `--schema /path/to/schema.toml` 指到别处的配置。

### 迭代怎么走

| 场景 | 做法 |
|---|---|
| 改代码做开发 | `pip install -e .`，改完直接生效 |
| 发新版本 | 改 `pm/__init__.py` 的 `__version__` → `uv build && python tools/make_pages.py` → 重传 `dist/pages/` |
| 内网升级 | `pip install -U --force-reinstall --no-deps <新版本的 URL>` |

---

## 带进内网（不用 pip 的备用方案）

需要搬的只有 **22 个文件**，其中 15 个是纯文本（约 100 KB）：

```
run.py  config/schema.toml  requirements*.txt
pm/*.py (5)  samples/make_sample.py  tests/test_engine.py
web/index.html  README.md  .gitignore
web/static/echarts.min.js      ← 1 MB，唯一的大文件，缺了网页会空白
data/raw/*                      ← mock 数据，可先在陌生环境冒烟
```

**环境要求**：Python ≥ 3.11（用了 `tomllib`）。依赖：`pip install -r requirements.lock.txt`
（`polars 2.0.0 / numpy 2.5.4 / fastapi / uvicorn / fastexcel / xlsxwriter`，内网没有镜像就装不上）。

**传完先校验**：

```bash
shasum -a 256 -c MANIFEST.sha256     # macOS
sha256sum -c MANIFEST.sha256         # Linux
```

然后 `python samples/make_sample.py && python run.py all --as-of 2026-10-23 && python run.py serve`
——用 mock 数据先跑通，再接真实数据。

> 别把 zip/7z 改名成 `.dat`：DLP 看文件头不看扩展名，照拦不误；
> 也别传公网托管（pages.dev 之类），内网访问不到且违反数据治理。

## 目录

```
config/schema.toml   字段映射与全局选项
pm/schema.py         读取、列名归一、类型清洗
pm/health.py         数据体检
pm/engine.py         预测 + 进度（核心）
pm/web.py            FastAPI 接口
web/index.html       单页前端
run.py               CLI：ingest / predict / serve / all
samples/make_sample.py   mock 数据（覆盖 6 类边界）
tests/test_engine.py     回归校验
```
