附錄 B — 跨週共用工具

資料夾:tools/
tools/check_duplicates.py
# -*- coding: utf-8 -*-
r"""盯著跨目錄的同名檔不要靜默分岔。

## 為什麼需要這一支

`fireye_volcano_python.py` 在 `week04_orbit/` 與 `week13_automation/`
**逐位元相同**。改一邊、另一邊就默默變成舊的,而兩邊看起來都正常。

原本打算照 `storage_vs_time.py` 的前例收進 `tools/`(那一支本來 week07 與
week12 各一份),但**這一支不能那樣搬**:

- 它沒有 `__main__` 保護,`import` 就會接上 STK 並建出整個場景 ——
  `week13_verify.py` 正是靠這個(`load_lab()` 之後直接用 `m.root`)
- 所以它是「週次的實驗腳本」,不是 helper;`tools/` 放的是 helper
- 而 Week 4 的實驗單把它列為該週的現成檔案,學生在 `week04_orbit/`
  裡就該看得到

刪掉其中一份會拿掉可用性;**真正要消除的是「靜默」,不是「重複」**。
所以這一支把分岔變成大聲的失敗,而重複本身必須在下面具名並寫明理由。

## 判準

| 情況 | 結果 |
|---|---|
| 同名、內容不同 | **FAIL** —— 已經分岔了 |
| 同名、內容相同、在白名單裡 | OK |
| 同名、內容相同、不在白名單 | **FAIL** —— 新的重複要先想清楚 |

    python tools/check_duplicates.py
    離開碼 0 = 沒有未預期的重複、也沒有分岔
"""
import hashlib
import io
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))

EXTS = (".py", ".txt", ".m", ".inp")

# 刻意保留的重複:檔名 -> (允許出現的目錄集合, 理由)
ALLOWED = {
    "fireye_volcano_python.py": (
        {"week04_orbit", "week13_automation"},
        "Week 13 是擁有者(week13_verify.py 由 HERE 載入它,而它 import 時"
        "就會建出場景);Week 4 的實驗單把它列為該週現成檔案,所以兩邊都留。"
        "兩份必須逐位元相同。"),
}


def scan():
    """{檔名: [(目錄, md5, 大小), ...]},只回傳出現在兩個以上目錄的。"""
    out = {}
    dirs = sorted(d for d in os.listdir(REPO)
                  if (re.match(r"^week\d{2}_", d) or d == "tools")
                  and os.path.isdir(os.path.join(REPO, d)))
    for d in dirs:
        p = os.path.join(REPO, d)
        for f in sorted(os.listdir(p)):
            fp = os.path.join(p, f)
            if not os.path.isfile(fp) or not f.endswith(EXTS):
                continue
            out.setdefault(f, []).append(
                (d, hashlib.md5(io.open(fp, "rb").read()).hexdigest(),
                 os.path.getsize(fp)))
    return {k: v for k, v in out.items() if len(v) > 1}


def main():
    dups = scan()
    bad = []
    print("=" * 74)
    print("  跨目錄的同名檔")
    print("=" * 74)
    if not dups:
        print("  沒有任何同名檔出現在兩個以上的目錄。")
        return 0

    for name, lst in sorted(dups.items()):
        hashes = {h for _, h, _ in lst}
        where = {d for d, _, _ in lst}
        identical = len(hashes) == 1
        allow = ALLOWED.get(name)
        if not identical:
            bad.append("%s 已經分岔(%s)"
                       % (name, "、".join("%s:%s" % (d, h[:8]) for d, h, _ in lst)))
            tag = "**已分岔**"
        elif allow is None:
            bad.append("%s 是未預期的重複(%s)" % (name, "、".join(sorted(where))))
            tag = "**未列入白名單**"
        elif where != allow[0]:
            bad.append("%s 出現的目錄 %s 與白名單 %s 不符"
                       % (name, sorted(where), sorted(allow[0])))
            tag = "**目錄與白名單不符**"
        else:
            tag = "OK(刻意保留)"
        print("  %-34s %s" % (name, tag))
        for d, h, s in lst:
            print("      %-24s %7d bytes  %s" % (d, s, h[:10]))
        if allow and identical and where == allow[0]:
            print("      理由:%s" % allow[1])

    print()
    if bad:
        print("  **%d 處有問題**:" % len(bad))
        for b in bad:
            print("    -", b)
        return 1
    print("  %d 組同名檔都在白名單裡而且逐位元相同。" % len(dups))
    return 0


if __name__ == "__main__":
    sys.exit(main())
tools/check_handbook.py
# -*- coding: utf-8 -*-
r"""盯著手冊與磁碟上的驗證段是否一致。

## 為什麼需要這一支

手冊第三之二整節在講「驗證段:ACK 不等於生效」,那是這套教材最有價值的
設計。但交付檔案表原本只列了 4 週(01、02、10、11)的 `weekNN_verify.py`
—— 其餘 11 週的驗證段在手冊裡等於不存在,而且 Week 15 那一列還把
`week15_launch_deploy.py` 標成「(驗證)」(它其實是 Python 解析 lab)。

那是 grep 抓到的,不是測試抓到的。這一支就是把它變成測試抓得到。

## 查三件事

1. 磁碟上每一支 `weekNN_verify.py` 都要出現在手冊裡,而且要在**它自己那一列**
2. 手冊裡提到的每一支 `weekNN_verify.py` 都要真的存在(抓改名與筆誤)
3. 週次表裡不准留問號式的佔位(原本有一個「基本(SOLIS?)」)

    python tools/check_handbook.py
    離開碼 0 = 一致,1 = 有出入
"""
import io
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
DOC = os.path.join(REPO, "handbook", "CubeSat_Course_Handbook.md")

ROW = re.compile(r"^\| (\d{1,2}) \|(.*)\|$")


def verify_scripts():
    """磁碟上真的存在的驗證段 -> {週次: (檔名, 相對路徑)}。

    **不要用 os.listdir()[0] 去猜目錄** —— 庫根目錄散落著
    `week10_deltav.png` 這種 lab 寫到 CWD 留下的產出,
    它排在 `week10_propulsion` 前面,那樣猜會整個漏掉 Week 10。
    """
    out = {}
    for name in sorted(os.listdir(REPO)):
        d = os.path.join(REPO, name)
        if not os.path.isdir(d):
            continue
        m = re.match(r"^week(\d{2})_", name)
        if not m:
            continue
        w = int(m.group(1))
        f = "week%02d_verify.py" % w
        if os.path.isfile(os.path.join(d, f)):
            out[w] = (f, name + "/" + f)
    return out


def main():
    doc = io.open(DOC, encoding="utf-8").read()
    rows = {}
    for ln in doc.split("\n"):
        m = ROW.match(ln)
        if m:
            rows[int(m.group(1))] = m.group(2)

    have = verify_scripts()
    bad = []
    print("=" * 74)
    print("  手冊 vs 磁碟:驗證段一致性")
    print("=" * 74)
    print("  磁碟上有 %d 支驗證段" % len(have))

    # (1) 每一支都要在手冊裡,而且在自己那一列
    for w in sorted(have):
        f, rel = have[w]
        in_doc = f in doc
        in_row = f in rows.get(w, "")
        tag = "OK" if (in_doc and in_row) else "**缺**"
        if not in_doc:
            bad.append("%s 整份手冊都沒提到" % f)
        elif not in_row:
            bad.append("%s 有提到,但不在週次表第 %d 列" % (f, w))
        print("    %-22s %s" % (rel, tag))

    # (2) 手冊提到的每一支都要存在
    named = set(re.findall(r"week\d{2}_verify\.py", doc))
    on_disk = {f for f, _ in have.values()}
    for f in sorted(named - on_disk):
        bad.append("手冊提到 %s,但磁碟上沒有" % f)
        print("    手冊提到但不存在:%s" % f)

    # (3) 週次表裡不准留問號式的佔位
    for w, cells in sorted(rows.items()):
        if "?" in cells or "?" in cells:
            bad.append("週次表第 %d 列留著問號:%s" % (w, cells.strip()[:50]))
            print("    第 %d 列有問號" % w)

    print()
    if bad:
        print("  **不一致 %d 處**:" % len(bad))
        for b in bad:
            print("    -", b)
        return 1
    print("  一致:%d 支驗證段,每一支都列在自己那一列;沒有問號佔位。" % len(have))
    return 0


if __name__ == "__main__":
    sys.exit(main())
tools/check_notebook_drift.py
# -*- coding: utf-8 -*-
r"""盯著 notebook 裡嵌的 lab 程式碼有沒有與 `.py` 漂開。

## 為什麼需要這一支

每一本 notebook 都把該週 `.py` 的程式碼嵌成程式碼格。**沒有任何東西在對。**
改 `.py` 的時候很容易忘了 notebook,而學生看到、執行的是 notebook。

這件事真的會發生:各週 lab 的產出路徑從裸檔名改成 `os.path.join(HERE, ...)`
之後,**六本 notebook 全部還留著舊寫法**,而且只有 grep 看得出來。
更麻煙的是第二層 —— notebook 是**逐格**跑的,而 `.py` 裡 `HERE` 落在常數
之後(模組執行沒問題,因為用到它的是函式),所以有三本的 lab import 格
還得額外補上 `HERE`,不然學生逐格執行會拿到 `NameError`。

## 怎麼分「漂移」與「膠水」

notebook 的程式碼格有兩種,不能用同一把尺:

- **膠水格**:notebook 自己的東西,沒有 `.py` 對應 —— 每本開頭設 `WEEK_DIR`
  的 bootstrap、STK 週次那個 `app = STKDesktop.AttachToApplication()` 的
  連線格。這些格子的行本來就不在任何 `.py` 裡。
- **鏡像格**:某一支 `.py` 的片段,應該逐行一致。

判準用**比例**:一格裡對得上的行佔多少。實測膠水格是 0.09–0.22,
乾淨的鏡像格是 1.00,中間是空的 —— 所以門檻取 0.7 很安全。
比例 >= 0.7 卻還有對不上的行,就是鏡像格漂掉了。

## 查兩件事

1. 鏡像格逐行一致
2. `HERE` 要先定義再使用(跨格,而且同一格內也看順序)

    python tools/check_notebook_drift.py
    python tools/check_notebook_drift.py <某個目錄>   # 拿備份驗這支自己
    離開碼 0 = 乾淨,1 = 有漂移
"""
import io
import json
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))

# 對得上的行達這個比例就當成鏡像格。門檻是**量出來的**,不是猜的:
#   真實漂移(E2 那批,git 裡查得到)   90% - 98%,最低 week08 的 9/10
#   膠水格(STK 連線、bootstrap)       9% - 74%,最高是獨立章節的連線格
#     —— 那一格在週次裡只有 9-22%,但獨立章節的 .py 自己也有 connect()
#     幫手,重疊的行變多,一路衝到 74%。門檻 0.7 因此誤報。
# 取 0.85:兩邊的實測區間中間空著,而且偏向不漏抓真漂移那一側。
MIRROR_MIN = 0.85

# Jupyter 的 magic 只能是 %x / %%x / !x,而且 x 是字母。
# **`% (a, b)` 這種接續行是取模運算子,不是 magic** ——
# 用 `startswith("%")` 清空的話,week15 有一個好格子會被誤判成語法錯誤。
MAGIC = re.compile(r"^\s*(%{1,2}[A-Za-z]|!)")


def _defs(src):
    """{函式名: {正規化後的本體, ...}}。用 AST 正規化,所以排版與註解不算差異。"""
    import ast
    out = {}
    try:
        tree = ast.parse(src)
    except SyntaxError:
        return out
    for n in tree.body:
        if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)):
            try:
                out.setdefault(n.name, set()).add(
                    ast.dump(ast.parse(ast.unparse(n))))
            except Exception:                              # noqa: BLE001
                pass
    return out


def def_drift(nb_path, py_paths):
    """**函式級**比對:同名函式的本體要一致。回傳 [(函式名, ...), ...]。

    逐行比對有一個真實的盲區:某一格在 `.py` 裡被**整段換掉**時,
    對得上的比例會掉到門檻以下,於是被當成「膠水格」豁免。
    week10 的 `drag_makeup_dv_per_year` 換成物理模型之後就是這樣 ——
    10 行對不上 4 行、比例 0.60,逐行檢查回報「乾淨」。

    函式級比對沒有這個問題,而且不吃排版:
    實測全庫 20 本,行級首行判準報 12 格(多數是誤報)、
    連續判準報 4 格,函式級只報 3 個而且都是真的。
    """
    nb = json.load(io.open(nb_path, encoding="utf-8"))
    pyd = {}
    for p in py_paths:
        for k, v in _defs(io.open(p, encoding="utf-8").read()).items():
            pyd.setdefault(k, set()).update(v)

    # **膠水格裡定義的函式不比對。** 判準:那一格還定義了某個在任何 .py
    # 裡都不存在的函式 —— 那就是 notebook 自己的幫手,不是 .py 的鏡像。
    # eoir 的 close_scenario 正是這種:與 .py 同名但本體不同,
    # 而同一格還定義了只有 notebook 才有的 run_connect。
    nbd = {}
    for c in nb["cells"]:
        if c["cell_type"] != "code":
            continue
        src = "\n".join("" if MAGIC.match(l) else l
                        for l in "".join(c["source"]).split("\n"))
        d = _defs(src)
        if any(k not in pyd for k in d):
            continue                       # 膠水格,跳過
        for k, v in d.items():
            nbd.setdefault(k, set()).update(v)

    # 只比「兩邊都有」的名字 —— notebook 不一定收錄 .py 的每一個函式。
    return sorted(k for k in (set(pyd) & set(nbd)) if not (pyd[k] & nbd[k]))


def compile_errors(nb_path):
    """每一格都要編譯得過。回傳 [(格號, 錯誤類別, 訊息), ...]。

    這一項抓到過六本 notebook 的同一個錯:機械轉檔把
    `if __name__ == "__main__":` 首行拿掉、卻留著縮排的本體,
    那一格語法上不合法 —— 也就是**從來沒有跑過**。
    """
    nb = json.load(io.open(nb_path, encoding="utf-8"))
    out = []
    for i, c in enumerate(nb["cells"]):
        if c["cell_type"] != "code":
            continue
        src = "".join(c["source"])
        clean = "\n".join("" if MAGIC.match(l) else l for l in src.split("\n"))
        try:
            compile(clean, "cell%d" % i, "exec")
        except SyntaxError as e:
            out.append((i, type(e).__name__, e.msg))
    return out


def code_lines(text):
    """一格裡要比對的行(去空白、去註解、去 magic)。"""
    out = []
    for line in text.split("\n"):
        t = line.strip()
        if not t or t.startswith(("%", "!", "#")):
            continue
        out.append(t)
    return out


def check_notebook(nb_path, sources):
    """回傳 (漂移清單, 統計)。漂移清單 = [(格號, 對上行數, [漂掉的行]), ...]。"""
    nb = json.load(io.open(nb_path, encoding="utf-8"))
    drift = []
    glue = mirror = loose = 0
    for ci, c in enumerate(nb["cells"]):
        if c["cell_type"] != "code":
            continue
        lines = code_lines("".join(c["source"]))
        if not lines:
            continue
        miss = [t for t in lines if not any(t in s for s in sources)]
        hit = len(lines) - len(miss)
        if hit == 0:
            glue += 1
        elif not miss:
            mirror += 1
        elif hit / len(lines) >= MIRROR_MIN:
            drift.append((ci, hit, miss))
        else:
            loose += 1
    # HERE 要先定義再使用
    order_bad = None
    defined = False
    for ci, c in enumerate(nb["cells"]):
        if c["cell_type"] != "code":
            continue
        t = "".join(c["source"])
        hd, us = t.find("HERE = ("), t.find("os.path.join(HERE")
        if us >= 0 and not defined and not (0 <= hd < us):
            order_bad = ci
            break
        if hd >= 0:
            defined = True
    return drift, {"glue": glue, "mirror": mirror, "loose": loose,
                   "order_bad": order_bad}


def main():
    root = sys.argv[1] if len(sys.argv) > 1 else REPO
    # **不能只掃 week*。** 六個獨立章節也有 notebook,而它們一直不在
    # 這支守門的範圍內。那個缺口的表現是:守門回報「16 本都乾淨」,
    # 而新加的那一本根本沒被看過。
    # 判準改成「這個目錄同時有 .ipynb 與 .py」,不綁命名。
    def has_pair(d):
        p = os.path.join(root, d)
        if not os.path.isdir(p) or d.startswith((".", "_")):
            return False
        fs = os.listdir(p)
        return (any(f.endswith(".ipynb") for f in fs)
                and any(f.endswith(".py") for f in fs))

    weeks = sorted(d for d in os.listdir(root) if has_pair(d))
    if weeks:
        targets = []
        for w in weeks:
            d = os.path.join(root, w)
            pypaths = [os.path.join(d, f) for f in sorted(os.listdir(d))
                       if f.endswith(".py")]
            pys = [io.open(x, encoding="utf-8").read() for x in pypaths]
            for nbp in sorted(x for x in os.listdir(d) if x.endswith(".ipynb")):
                targets.append((w, os.path.join(d, nbp), pys, pypaths))
    else:
        # 拿備份驗這支自己:notebook 在指定目錄、.py 仍用庫裡現行的
        targets = []
        for nbp in sorted(x for x in os.listdir(root) if x.endswith(".ipynb")):
            w = nbp.replace(".ipynb", "")
            wd = next((d for d in os.listdir(REPO)
                       if d.startswith(w.split("_")[0])
                       and os.path.isdir(os.path.join(REPO, d))), None)
            if wd is None:
                continue
            d = os.path.join(REPO, wd)
            pypaths = [os.path.join(d, f) for f in sorted(os.listdir(d))
                       if f.endswith(".py")]
            pys = [io.open(x, encoding="utf-8").read() for x in pypaths]
            targets.append((wd, os.path.join(root, nbp), pys, pypaths))

    print("=" * 74)
    print("  notebook 嵌的 lab 程式碼 vs .py")
    print("=" * 74)
    bad = 0
    for w, nbp, pys, pypaths in targets:
        drift, st = check_notebook(nbp, pys)
        syn = compile_errors(nbp)
        dd = def_drift(nbp, pypaths)
        tag = "OK"
        if drift or st["order_bad"] is not None or syn or dd:
            tag = "**有問題**"
            bad += 1
        print("  %-22s %-30s 膠水%3d 鏡像%3d 鬆散%3d  %s"
              % (w, os.path.basename(nbp), st["glue"], st["mirror"],
                 st["loose"], tag))
        for ci, hit, miss in drift:
            print("      格%3d 對上 %d 行但有 %d 行漂掉:" % (ci, hit, len(miss)))
            for t in miss[:4]:
                print("           %s" % t[:70])
            if len(miss) > 4:
                print("           ... 另外 %d 行" % (len(miss) - 4))
        if st["order_bad"] is not None:
            print("      格%3d 用到還沒定義的 HERE(notebook 是逐格跑的)"
                  % st["order_bad"])
        for ci, kind, msg in syn:
            print("      格%3d %s: %s  —— 這一格語法不合法,不可能跑過"
                  % (ci, kind, msg))
        for name in dd:
            print("      函式 %s() 的本體與 .py 不一致(逐行比對看不出來)"
                  % name)
    print()
    if bad:
        print("  **%d 本有問題**。改 .py 之後要把對應的程式碼格重切。" % bad)
        return 1
    print("  %d 本都乾淨:鏡像格逐行一致、HERE 先定義再使用、每一格都編譯得過。"
          % len(targets))
    return 0


if __name__ == "__main__":
    sys.exit(main())
tools/check_output_paths.py
# -*- coding: utf-8 -*-
r"""盯著各週 lab 不要把產出寫到 process 的工作目錄。

## 為什麼需要這一支

每一支 `weekNN_verify.py` 開頭都有這個慣用法,而且註明了原因:

    HERE = (os.path.dirname(os.path.abspath(__file__)) if "__file__" in globals()
            else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))
    # 啟動 STK Engine 會改變 process 的工作目錄

**但 lab 那一側先前沒有套用。** 知識已經在庫裡,只是沒有推過去。
後果不是假設性的:庫根目錄散落著九個產出、其中七個被 git 追蹤,
而根目錄那份 `week15_launch.png` 是改圖之前的舊版 ——
庫裡有一張會誤導人的過期圖正被追蹤著。

那是 grep 抓到的,不是測試抓到的。這一支把它變成測試抓得到。

## 查什麼

用 AST 找寫檔的呼叫(`savefig` / `to_csv` / `to_excel` / `open(..., "w")`),
如果第一個引數是**字面字串而且看起來是檔名**(有副檔名、沒有路徑分隔),
就是寫到 CWD。`os.path.join(HERE, ...)` 這種算過的路徑不會被抓。

    python tools/check_output_paths.py
    離開碼 0 = 乾淨,1 = 有裸相對路徑
"""
import ast
import io
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))

WRITERS = {"savefig", "to_csv", "to_excel", "to_json", "write_stl", "imsave"}
# 有副檔名、而且不含任何路徑分隔 -> 一個裸檔名
BARE = re.compile(r"^[\w\-. ]+\.[A-Za-z0-9]{1,5}$")


def offenders(path):
    """回傳 [(行號, 呼叫, 檔名), ...]。"""
    src = io.open(path, encoding="utf-8").read()
    try:
        tree = ast.parse(src)
    except SyntaxError as e:
        return [(e.lineno or 0, "SyntaxError", str(e))]
    out = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call) or not node.args:
            continue
        fn = node.func
        name = getattr(fn, "attr", None) or getattr(fn, "id", None)
        if name is None:
            continue
        first = node.args[0]
        if not (isinstance(first, ast.Constant) and isinstance(first.value, str)):
            continue
        val = first.value
        if not BARE.match(val):
            continue
        if name in WRITERS:
            out.append((node.lineno, name, val))
        elif name == "open":
            # 只抓寫入模式;讀取用相對路徑是另一件事(目前沒有)
            mode = ""
            if len(node.args) > 1 and isinstance(node.args[1], ast.Constant):
                mode = str(node.args[1].value)
            for kw in node.keywords:
                if kw.arg == "mode" and isinstance(kw.value, ast.Constant):
                    mode = str(kw.value.value)
            if any(c in mode for c in "wax"):
                out.append((node.lineno, "open(%r)" % mode, val))
    return out


# 庫根目錄不該出現的副檔名 —— 全部都是 lab 的產出。
# 先前這裡躺著九個(七個被 git 追蹤),其中 week15_launch.png 還是過期的。
ARTEFACT_EXT = (".png", ".csv", ".a", ".inp", ".e", ".frd", ".dat")
ROOT_ALLOW = set()          # 目前沒有任何例外


def root_artefacts():
    """庫根目錄殘留的產出。"""
    out = []
    for f in sorted(os.listdir(REPO)):
        if not os.path.isfile(os.path.join(REPO, f)):
            continue
        if f in ROOT_ALLOW:
            continue
        if f.lower().endswith(ARTEFACT_EXT):
            out.append(f)
    return out


def main():
    weeks = sorted(d for d in os.listdir(REPO)
                   if re.match(r"^week\d{2}_", d)
                   and os.path.isdir(os.path.join(REPO, d)))
    total, checked = 0, 0
    print("=" * 74)
    print("  各週 lab 的產出路徑")
    print("=" * 74)
    for w in weeks:
        d = os.path.join(REPO, w)
        for f in sorted(x for x in os.listdir(d) if x.endswith(".py")):
            p = os.path.join(d, f)
            checked += 1
            bad = offenders(p)
            if bad:
                total += len(bad)
                print("  %s/%s" % (w, f))
                for ln, call, val in bad:
                    print("      第 %d 行  %s -> \"%s\"  **裸相對路徑**"
                          % (ln, call, val))
    stray = root_artefacts()
    if stray:
        print("  庫根目錄殘留的產出:")
        for f in stray:
            print("      %-30s **應該在週次目錄裡**" % f)

    print()
    print("  掃了 %d 個 .py;庫根目錄殘留 %d 個產出" % (checked, len(stray)))
    total += len(stray)
    if total:
        print("  **%d 處把產出寫到 process 的工作目錄**。" % total)
        print("  改法:在模組頂層(第一個 import 區塊之後、任何使用它的常數之前)加")
        print('    HERE = (os.path.dirname(os.path.abspath(__file__))')
        print('            if "__file__" in globals()')
        print('            else globals().get("WEEK_DIR") or os.path.abspath(os.getcwd()))')
        print("  再把路徑包成 os.path.join(HERE, \"...\")。")
        return 1
    print("  乾淨:沒有任何 lab 把產出寫到工作目錄。")
    return 0


if __name__ == "__main__":
    sys.exit(main())
tools/check_stk_report_paths.py
# -*- coding: utf-8 -*-
r"""把 STK 報告的**寫入方與讀取方**綁在一起。

## 為什麼需要這一支

報告有兩側,而且用的是兩種東西:

    寫入方  Connect 腳本裡的 `ReportCreate ... File "C:\STK_Reports\xxx.txt"`
            —— 靜態文字,送給 STK,展不開環境變數
    讀取方  Python 的 `stk_paths.report("xxx.txt")`

**先前兩側沒有任何東西在對。** 這件事危險的地方不是難查,是查了也看不出來:
Connect 腳本裡的檔名一改,讀取方就找不到檔,而它的反應是
「找不到 STK 報告,改用離線樣本」—— 那句話與「學生還沒跑 Week 4」
**長得一模一樣**。壞掉的教材會偽裝成正常的教學流程。

## 查四件事

1. 讀取方要的每一個檔,都有某一份 Connect 腳本會寫出來
2. Connect 腳本的報告路徑共用同一個目錄,而且等於
   `stk_paths.DEFAULT_REPORTS_DIR`
3. 沒有任何 `.py` 還把報告路徑寫死
4. 報告目錄存不存在(手冊寫著「寫檔失敗為靜默失敗」,所以這一項只提示)

    python tools/check_stk_report_paths.py
    離開碼 0 = 兩側一致
"""
import io
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
sys.path.insert(0, HERE)

import stk_paths                                    # noqa: E402

WRITE = re.compile(r'ReportCreate\b[^\n]*?File\s+"([^"]+)"')
READ = re.compile(r'stk_paths\.report\(\s*["\']([^"\']+)["\']\s*\)')
HARD = re.compile(r'(?<!#)[A-Za-z]:\\+STK_Reports')


def week_dirs():
    return sorted(d for d in os.listdir(REPO)
                  if re.match(r"^week\d{2}_", d)
                  and os.path.isdir(os.path.join(REPO, d)))


def scan():
    writes, reads, hard = {}, {}, []
    for w in week_dirs():
        d = os.path.join(REPO, w)
        for f in sorted(os.listdir(d)):
            p = os.path.join(d, f)
            if not os.path.isfile(p):
                continue
            if f.endswith(".txt"):
                s = io.open(p, encoding="utf-8", errors="ignore").read()
                for m in WRITE.finditer(s):
                    writes.setdefault(m.group(1), []).append("%s/%s" % (w, f))
            elif f.endswith(".py"):
                s = io.open(p, encoding="utf-8").read()
                for m in READ.finditer(s):
                    reads.setdefault(m.group(1), []).append("%s/%s" % (w, f))
                for i, line in enumerate(s.split("\n"), 1):
                    if line.lstrip().startswith("#"):
                        continue
                    if HARD.search(line):
                        hard.append("%s/%s:%d" % (w, f, i))
    return writes, reads, hard


def main():
    writes, reads, hard = scan()
    bad = []
    print("=" * 76)
    print("  STK 報告:寫入方(Connect)vs 讀取方(Python)")
    print("=" * 76)

    written_names = {}
    dirs = set()
    for path, srcs in sorted(writes.items()):
        dirs.add(os.path.dirname(path))
        written_names[os.path.basename(path)] = srcs
    print("  Connect 寫出 %d 個報告:" % len(writes))
    for name in sorted(written_names):
        who = "、".join(sorted(set(reads.get(name, []))))
        print("    %-30s <- %s   %s"
              % (name, written_names[name][0],
                 "讀取方:" + who if who else "(沒有人讀)"))

    # (1) 讀取方要的都有人寫
    for name, srcs in sorted(reads.items()):
        if name not in written_names:
            bad.append("讀取方要 %s(%s),但沒有 Connect 腳本會寫它"
                       % (name, srcs[0]))

    # (2) 目錄一致
    if len(dirs) > 1:
        bad.append("Connect 腳本用了不只一個報告目錄:%s" % sorted(dirs))
    elif dirs:
        only = dirs.pop()
        if only.rstrip("\\/").lower() != stk_paths.DEFAULT_REPORTS_DIR.rstrip("\\/").lower():
            bad.append("Connect 腳本寫到 %s,而 stk_paths.DEFAULT_REPORTS_DIR 是 %s"
                       % (only, stk_paths.DEFAULT_REPORTS_DIR))

    # (3) 不准再有硬寫
    for h in hard:
        bad.append("%s 還把報告路徑寫死" % h)

    print()
    # (4) 目錄存不存在 —— 只提示
    d = stk_paths.reports_dir()
    print("  報告目錄 %s  %s" % (d, "存在" if os.path.isdir(d) else "**不存在**"))
    if not os.path.isdir(d):
        print("    STK 的 ReportCreate 寫不進去**不會吭聲**,學生會拿到空的分析。")
        print("    先建好:python -c \"import sys;sys.path.insert(0,'tools');"
              "import stk_paths;print(stk_paths.ensure_reports_dir())\"")
    if stk_paths.is_overridden():
        print("    注意:%s 已被環境變數覆寫,而 Connect 腳本仍寫到 %s。"
              % (stk_paths.ENV_VAR, stk_paths.DEFAULT_REPORTS_DIR))

    print()
    if bad:
        print("  **不一致 %d 處**:" % len(bad))
        for b in bad:
            print("    -", b)
        return 1
    print("  一致:讀取方要的 %d 個報告都有 Connect 腳本會寫,目錄單一,"
          "沒有硬寫。" % len(reads))
    return 0


if __name__ == "__main__":
    sys.exit(main())
tools/check_verify_counts.py
# -*- coding: utf-8 -*-
"""閘門:教材裡引用的「驗證 N 項」要與驗證段**實際跑出來**的數字一致。

## 為什麼需要這一道

`grpc_deck.py` 曾經把 66 / 17 印在對外簡報上,而當時實際是 136 / 27。
沒有人會發現 —— 那兩個數字只出現在投影片的一行字裡,而簡報是從
Python 產生的,所以連「檔案被改過」都看不出來。手寫的統計數字會飄,
而且飄掉時完全無聲。

## 它怎麼判斷

**不比對手寫常數** —— 那只會把錯誤搬到另一個地方。它真的去執行
從輸出抓「合計 N 項」,再拿這個 N 回頭檢查全庫每一處引用。

STK 那一段沒辦法在沒有授權的機器上跑,所以只檢查**彼此一致**:
所有地方引用的數字要相同。那擋不住「全部一起錯」,但擋得住漂移,
而漂移正是實際發生過的那種。

注意 STK 段的總數**會變**:地形那組有 DEM 時 6 項、沒有時併成 1 項
SKIP。教材引用的是有 DEM 的 27,這是刻意的 —— 引用能力的上限,
而腳本自己印出來的永遠是這次真的跑了幾項。
"""
import re
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
FREE = ROOT / "grpc_link" / "grpc_link_verify.py"

# (檔案, 抓數字的樣式) —— 樣式只留一個 (\d+) 群組
FREE_CITES = [
    ("grpc_link/grpc_deck.py",             r"驗證段 (\d+) 項(?:(?!STK)[^\n])*不需 STK"),
    ("grpc_link/grpc_link_stk_verify.py",  r"不需要 STK 的那 (\d+) 項"),
    ("grpc_link/aero_de_deck.py",          r"本章 (\d+) 項\*\*完全不需要 STK"),
    ("handbook/CubeSat_Course_Handbook.md", r"\| (\d+) 項;另 STK 驗證段"),
    ("handbook/CubeSat_Course_Handbook.md", r"它的 (\d+) 項"),
]
STK_CITES = [
    ("grpc_link/grpc_deck.py",             r"STK 驗證段 (\d+) 項"),
    ("handbook/CubeSat_Course_Handbook.md", r"另 STK 驗證段 (\d+) 項"),
    ("handbook/CubeSat_Course_Handbook.md", r"grpc_link_stk_verify\.py`\((\d+) 項\)"),
    ("grpc_link/aero_de_deck.py",          r"另有 (\d+) 項\*\*需要 STK"),
]


def cited(cites):
    """把每一處引用抓出來。抓不到就是壞掉 —— 樣式跟著被引用的文字一起改了。"""
    out, missing = [], []
    for rel, pat in cites:
        p = ROOT / rel
        if not p.is_file():
            missing.append("%s 不存在" % rel)
            continue
        m = re.search(pat, p.read_text(encoding="utf-8"))
        if m is None:
            missing.append("%s 找不到樣式 %s" % (rel, pat))
        else:
            out.append((rel, int(m.group(1))))
    return out, missing


def actual_free():
    """真的去跑不需 STK 的那一段,回傳它自己印出來的總數。"""
    r = subprocess.run([sys.executable, FREE.name], cwd=str(FREE.parent),
                       capture_output=True, text=True, encoding="utf-8",
                       errors="replace", timeout=1800)
    m = re.search(r"合計 (\d+) 項:PASS (\d+) / FAIL (\d+)", r.stdout or "")
    if m is None:
        return None, "跑得起來但抓不到「合計 N 項」那一行(輸出格式改了?)"
    total, npass, nfail = (int(x) for x in m.groups())
    if nfail:
        return None, "驗證段有 %d 項 FAIL —— 先修那個,數字對不對是其次" % nfail
    if npass != total:
        return None, "PASS %d != 合計 %d" % (npass, total)
    return total, None


def main():
    bad = []

    free_cites, missing = cited(FREE_CITES)
    stk_cites, missing2 = cited(STK_CITES)
    bad += missing + missing2

    truth, err = actual_free()
    if err:
        bad.append("無法取得實際項數:%s" % err)
    else:
        print("  grpc_link_verify.py 實際跑出 %d 項" % truth)
        for rel, n in free_cites:
            if n != truth:
                bad.append("%s 寫 %d 項,實際 %d 項" % (rel, n, truth))

    # STK 段只能查彼此一致 —— 沒有授權就跑不起來,誠實講出這個限制
    if stk_cites:
        vals = {n for _, n in stk_cites}
        if len(vals) > 1:
            bad.append("STK 驗證段的引用彼此不一致:%s"
                       % ", ".join("%s=%d" % c for c in stk_cites))
        else:
                  % vals.pop())

    if bad:
        print("\n  FAIL")
        for b in bad:
            print("    - %s" % b)
        return 1
    print("  PASS  引用的項數與實際一致(%d 處)"
          % (len(free_cites) + len(stk_cites)))
    return 0


if __name__ == "__main__":
    sys.exit(main())
tools/cjk.py
# -*- coding: utf-8 -*-
"""讓 matplotlib 畫得出中文。

為什麼要這一支:matplotlib 預設的 DejaVu Sans 沒有 CJK 字符,中文字會變成
空框(俗稱豆腐字),而且**不會報錯,只會在 stderr 印一行 UserWarning**
—— 圖照樣存檔、照樣看起來像成功。本教材已經在章節 4 的分析圖上踩過一次
(圖例寫「(關閉)」,存出來是兩個空框)。

用法:
    from cjk import use_cjk
    use_cjk()          # 在任何 plt 呼叫之前

沒有可用的中文字型時回傳 False,呼叫端應該退回英文標籤 ——
**不要假裝設定成功**,那正是本教材一路在講的那種靜默失敗。
"""

import matplotlib
import matplotlib.font_manager as fm

# 依偏好排序。前兩個是 Windows 繁中/簡中系統字型,後面是開源的。
CANDIDATES = [
    "Microsoft JhengHei",     # 微軟正黑體(繁體,Windows 內建)
    "Noto Sans TC",
    "Noto Sans CJK TC",
    "Microsoft YaHei",        # 微軟雅黑(簡體)
    "PingFang TC",
    "Heiti TC",
    "SimHei",
]


def available():
    """回傳這台機器上實際裝了的候選字型。"""
    have = {f.name for f in fm.fontManager.ttflist}
    return [n for n in CANDIDATES if n in have]


def use_cjk(verbose=False):
    """設定 matplotlib 的字型。回傳實際採用的字型名稱,沒有就回 None。"""
    got = available()
    if not got:
        if verbose:
            print("    找不到中文字型 —— 圖上的中文會變成空框,請改用英文標籤")
        return None
    name = got[0]
    matplotlib.rcParams["font.sans-serif"] = [name] + CANDIDATES
    matplotlib.rcParams["font.family"] = "sans-serif"
    # 中文字型多半沒有 U+2212(真正的減號),不關掉會讓負號變成空框。
    matplotlib.rcParams["axes.unicode_minus"] = False
    if verbose:
        print("    中文字型:%s" % name)
    return name


if __name__ == "__main__":
    print("可用:", available())
    print("採用:", use_cjk(verbose=True))
tools/make_course_pdf.py
# -*- coding: utf-8 -*-
r"""把整個教材庫組成一本 PDF。

## 為什麼要重寫這一支

`CubeSat_Course_Complete.pdf` 是 2026-08-31 用 headless Edge 把一份 HTML
列印出來的(387 頁、26 節),但**那份 HTML 與產生它的腳本都沒有留在庫裡**,
而 PDF 的產出日比整個 git 歷史還早。所以內容停在那一天:
spaceport_suborbital 那一章、各週驗證段的現況、六支守門,全都不在裡面。

## 作法:逐節產生 HTML,各自列印,再合併

不做成一份超大 HTML,理由有三:

1. 五個獨立章節本來就是**各自完整的 HTML**(有自己的 CSS 與 canvas),
   塞進別人的文件裡會互相污染;各自列印則是原樣渲染
2. 單節出問題可以只重跑那一節
3. 合併之後再統一蓋頁碼與書籤,頁碼才會連續

## 對外文件的三條規則(使用者要求,不可放寬)

- **個人資料包括姓名要隱藏** —— 舊版 p.169 露出 AGI 原作者的 email
- 學校名稱可以顯示;業務上可強調的價值不發佈

過濾在 `scrub()` 一處集中處理,並由 `--audit` 對產出的 PDF 回頭驗一次。

    python tools/make_course_pdf.py --list          # 只列出會收哪些節
    python tools/make_course_pdf.py --only week06   # 只做一節(試版面)
    python tools/make_course_pdf.py                 # 全本
    python tools/make_course_pdf.py --audit         # 驗產出的 PDF 有沒有漏網
"""
import html as _html
import io
import os
import re
import shutil
import subprocess
import sys
import tempfile

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
OUT_PDF = os.path.join(REPO, "CubeSat_Course_Complete.pdf")
BUILD = os.path.join(REPO, "_book_build")

BROWSERS = [
    r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
    r"C:\Program Files\Google\Chrome\Application\chrome.exe",
    r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
]

# ---------------------------------------------------------------- 遮蔽規則
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
# 授權相關:整行濾掉
LICENSE_LINE = re.compile(
# 姓名:AGI 原版參考檔的作者標頭
AUTHOR_LINE = re.compile(r"Created by\s*:|Author\s*:|作者\s*[::]")


def scrub(text):
    """套用對外文件的三條規則。回傳 (處理後的文字, 動過的行數)。"""
    out, touched = [], 0
    for line in text.split("\n"):
        if LICENSE_LINE.search(line) or AUTHOR_LINE.search(line):
            touched += 1
            continue                      # 整行不收
        if EMAIL.search(line):
            line = EMAIL.sub("(已遮蔽)", line)
            touched += 1
        out.append(line)
    return "\n".join(out), touched


# ---------------------------------------------------------------- 版面
CSS = """
@page { size: A4; margin: 18mm 15mm 20mm 15mm; }
html { -webkit-print-color-adjust: exact; }
body { font-family: "Microsoft JhengHei", "Noto Sans CJK TC", "PingFang TC",
       sans-serif; font-size: 10.5pt; line-height: 1.65; color: #14181d; margin: 0; }
h1 { font-size: 20pt; border-bottom: 2px solid #2c5f8a; padding-bottom: 6px;
     margin: 0 0 14px; page-break-before: always; }
h1:first-of-type { page-break-before: avoid; }
h2 { font-size: 14pt; margin: 20px 0 8px; color: #21486b;
     border-left: 4px solid #2c5f8a; padding-left: 8px; }
h3 { font-size: 12pt; margin: 14px 0 6px; color: #2c5f8a; }
h4 { font-size: 11pt; margin: 12px 0 4px; }
p, li { orphans: 2; widows: 2; }
table { border-collapse: collapse; width: 100%; margin: 10px 0;
        font-size: 9.5pt; page-break-inside: avoid; }
th, td { border: 1px solid #b9c4cf; padding: 4px 7px; text-align: left;
         vertical-align: top; }
th { background: #eaf1f7; }
code { font-family: Consolas, "Courier New", monospace; font-size: 9pt;
       background: #f2f4f7; padding: 1px 3px; border-radius: 3px; }
pre { background: #f7f9fb; border: 1px solid #dde4ec; border-left: 3px solid #2c5f8a;
      padding: 8px 10px; font-size: 8.4pt; line-height: 1.42; overflow: visible;
      white-space: pre-wrap; word-break: break-all; page-break-inside: auto; }
pre code { background: none; padding: 0; font-size: inherit; }
blockquote { border-left: 3px solid #c8d4e0; margin: 10px 0; padding: 2px 12px;
             color: #3a4653; background: #fafcfe; }
img { max-width: 100%; page-break-inside: avoid; }
.filehead { background: #21486b; color: #fff; font-family: Consolas, monospace;
            font-size: 9pt; padding: 3px 8px; margin: 14px 0 0;
            border-radius: 3px 3px 0 0; page-break-after: avoid; }
.filehead + pre { margin-top: 0; border-top: none; border-radius: 0 0 3px 3px; }
.sub { color: #5a6876; font-size: 10pt; margin: -8px 0 16px; }
.cover { text-align: center; padding-top: 70mm; }
.cover h1 { border: none; font-size: 30pt; page-break-before: avoid; }
.cover .t2 { font-size: 14pt; color: #21486b; margin: 6px 0; }
.cover .t3 { font-size: 11pt; color: #5a6876; margin-top: 26px; line-height: 2; }
"""


def md_to_html(text, demote=1, base_dir=None):
    """markdown -> HTML。

    demote:把來源的 `#` 往下壓幾級。**這一步不能省** —— 每一節的標題才是
    `<h1>`(而 `<h1>` 會強制分頁),來源 markdown 裡的 `#` 若原樣變成 h1,
    內容就會被擠到下一頁,整節開頭變成一張幾乎空白的紙。
    base_dir:圖片的相對路徑要轉成絕對 file:/// —— HTML 是寫到
    _book_build/ 再列印的,相對路徑會指到錯的地方。
    """
    import markdown
    if demote:
        out = []
        fence = False
        for line in text.split("\n"):
            if line.lstrip().startswith("```"):
                fence = not fence
            elif not fence:
                m = re.match(r"^(#{1,6})(\s)", line)
                if m:
                    line = ("#" * min(6, len(m.group(1)) + demote)
                            + m.group(2) + line[m.end():])
            out.append(line)
        text = "\n".join(out)
    h = markdown.markdown(
        text, extensions=["fenced_code", "tables", "sane_lists", "nl2br"])
    if base_dir:
        h = re.sub(r'(<img[^>]+src=")(?!https?:|file:|data:)([^"]+)(")',
                   lambda m: m.group(1) + as_url(os.path.join(base_dir, m.group(2)))
                   + m.group(3), h)
    return h


def as_url(path):
    return "file:///" + os.path.abspath(path).replace("\\", "/")


def code_block(path, rel=None):
    """一個檔案的程式碼區塊(含檔名條)。"""
    src, _ = scrub(io.open(path, encoding="utf-8", errors="replace").read())
    name = rel or os.path.relpath(path, REPO).replace("\\", "/")
    return ('<div class="filehead">%s</div><pre><code>%s</code></pre>'
            % (_html.escape(name), _html.escape(src)))


def md_block(path, demote=1):
    src, _ = scrub(io.open(path, encoding="utf-8", errors="replace").read())
    return md_to_html(src, demote=demote, base_dir=os.path.dirname(path))


def page(title, body):
    return ("<!doctype html><html lang=\"zh-Hant\"><head><meta charset=\"utf-8\">"
            "<title>%s</title><style>%s</style></head><body>%s</body></html>"
            % (_html.escape(title), CSS, body))


# ---------------------------------------------------------------- 章節組成
def week_dirs():
    out = []
    for d in sorted(os.listdir(REPO)):
        m = re.match(r"^week(\d{2})_", d)
        if m and os.path.isdir(os.path.join(REPO, d)):
            out.append((int(m.group(1)), d))
    return out


def week_titles():
    """由手冊的週次表取中文主題。手冊是唯一來源,標題不另外寫一份。"""
    p = os.path.join(REPO, "handbook", "CubeSat_Course_Handbook.md")
    out = {}
    for ln in io.open(p, encoding="utf-8").read().split("\n"):
        m = re.match(r"^\|\s*(\d{1,2})\s*\|\s*([^|]+?)\s*\|", ln)
        if m:
            out[int(m.group(1))] = m.group(2).strip()
    return out


WEEK_TITLE = None


def week_title(num, wd):
    global WEEK_TITLE
    if WEEK_TITLE is None:
        WEEK_TITLE = week_titles()
    return WEEK_TITLE.get(num) or wd.split("_", 1)[1]


CHAPTERS = [
    ("radar_bistatic", "chapter_bistatic_radar.html",
     "獨立章節 1 — 地面雙基地雷達:干擾、雜波與航跡穩定度"),
    ("eoir_earlywarning", "chapter_eoir_earlywarning.html",
     "獨立章節 2 — 地面望遠鏡追蹤探空火箭尾焰(EOIR)"),
    ("launch_conjunction", "chapter_launch_conjunction.html",
     "獨立章節 3 — 雙節運載火箭入軌與交會迴避(AdvCAT)"),
    ("tirem_coastal", "chapter_tirem_coastal.html",
     "獨立章節 4 — 岸際雷達:TIREM 地形傳播與四種靜默失敗"),
    ("spaceport_suborbital", "web/suborbital_workbench.html",
     "獨立章節 5 — 台美太空港次軌道飛行(Aviator + 自製滑翔解)"),
    ("moon_transfer", "",
     "獨立章節 6 — 地月轉移與繞月進入(相位迴圈)"),
    ("grpc_link", "",
     "獨立章節 7 — STK 與 gRPC 通聯測試(含 Simulink / MAVLink 整合)"),
]


def sections():
    """[(key, 書籤標題, 產生 HTML 的函式 或 ('file', 路徑)), ...]"""
    import datetime
    today = datetime.date.today().isoformat()
    s = []

    def cover():
        return page("立方衛星設計與實作", (
            '<div class="cover"><h1>立方衛星設計與實作</h1>'
            '<div class="t2">STK / Python 課程教材 · 全 16 週</div>'
            '<div class="t2">以單一 FIREYE 火山觀測 CubeSat 任務貫穿全學期</div>'
            '<div class="t3">本冊含課程手冊、各週基礎導論與實驗單、<br>'
            'Connect 與 Python 全部程式碼、驗證段、五個獨立章節<br><br>'
            '產出日期:%s</div></div>' % today))
    s.append(("cover", "封面", cover))

    s.append(("overview", "課程總覽",
              lambda: page("課程總覽",
                           "<h1>課程總覽</h1>" + md_block(os.path.join(REPO, "README.md")))))
    s.append(("handbook", "課程手冊",
              lambda: page("課程手冊",
                           "<h1>課程手冊</h1>" + md_block(
                               os.path.join(REPO, "handbook",
                                            "CubeSat_Course_Handbook.md")))))
    s.append(("primers", "基礎知識導論總覽",
              lambda: page("基礎知識導論總覽",
                           "<h1>基礎知識導論總覽</h1>" + md_block(
                               os.path.join(REPO, "primers", "README.md")))))

    for num, wd in week_dirs():
        s.append(("week%02d" % num, "Week %d — %s" % (num, week_title(num, wd)),
                  (lambda n=num, d=wd: week_page(n, d))))

    for d, _f, title in CHAPTERS:
        if os.path.isdir(os.path.join(REPO, d)):
            s.append(("chap_" + d, title,
                      (lambda dd=d, tt=title: dir_page(tt, dd))))

    ref = os.path.join(REPO, "reference", "AGIPCEXP_Connect_Reference.md")
    if os.path.isfile(ref):
        s.append(("appA", "附錄 A — Connect 指令速查",
                  lambda: page("附錄 A", "<h1>附錄 A — Connect 指令速查</h1>"
                               + md_block(ref))))
    s.append(("appB", "附錄 B — 跨週共用工具", tools_page))
    return s


def tracked():
    """git 追蹤中的檔(相對路徑)。**只收版控裡的東西** ——
    week11_linkbudget.txt 是 STK 產生的 667 KB 報告、沒進版控,
    把它當程式碼收進去的話,那一週會從 8 頁爆成 125 頁。"""
    import subprocess
    r = subprocess.run(["git", "ls-files"], cwd=REPO, capture_output=True,
                       text=True, encoding="utf-8", errors="replace")
    return {x.strip().replace("\\", "/") for x in (r.stdout or "").splitlines()
            if x.strip()}


TRACKED = None


def is_tracked(path):
    global TRACKED
    if TRACKED is None:
        TRACKED = tracked()
    return os.path.relpath(path, REPO).replace("\\", "/") in TRACKED


CODE_EXT = (".py", ".txt", ".m", ".inp")
IMG_EXT = (".png", ".gif", ".jpg")


def dir_page(title, folder, primer=None):
    """一個資料夾組成的一節:標頭 + 導論 + 該資料夾的 .md + 圖 + 程式碼。

    週次與獨立章節用同一套。獨立章節**不能**直接列印那份 `chapter_*.html`:
    那樣只拿得到十幾頁,而且不是印壞了 —— 是**那份 HTML 的可見文字
    本來就只有約 11 KB**(473 KB 幾乎都是 CSS 與內嵌資料)。
    要拿到 30–75 頁,收的必須是該章的 markdown 與程式碼。
    """
    d = os.path.join(REPO, folder)
    parts = ['<h1>%s</h1>' % _html.escape(title),
             '<div class="sub">資料夾:<code>%s/</code></div>' % folder]
    if primer and os.path.isfile(primer):
        parts.append("<h2>基礎知識導論</h2>" + md_block(primer))

    files = []
    for root, _dirs, fs in os.walk(d):
        if "__pycache__" in root or ".git" in root:
            continue
        for f in sorted(fs):
            fp = os.path.join(root, f)
            if is_tracked(fp):
                files.append(fp)
    files.sort(key=lambda x: (os.path.dirname(x), os.path.basename(x)))

    mds = [f for f in files if f.lower().endswith(".md")]
    for f in mds:
        parts.append("<h2>%s</h2>" % _html.escape(os.path.basename(f))
                     + md_block(f))

    imgs = [f for f in files if f.lower().endswith(IMG_EXT)]
    if imgs:
        parts.append("<h2>圖</h2>")
        for f in imgs:
            rel = os.path.relpath(f, REPO).replace("\\", "/")
            parts.append('<p><img src="%s"><br><code>%s</code></p>'
                         % (_html.escape(as_url(f)), _html.escape(rel)))

    codes = [f for f in files if f.lower().endswith(CODE_EXT)]
    if codes:
        parts.append("<h2>程式碼</h2>")
        for f in codes:
            parts.append(code_block(f))
    return page(title, "".join(parts))


def week_page(num, wd):
    return dir_page("Week %d — %s" % (num, week_title(num, wd)), wd,
                    os.path.join(REPO, "primers", "week%02d_primer.md" % num))


def tools_page():
    d = os.path.join(REPO, "tools")
    parts = ["<h1>附錄 B — 跨週共用工具</h1>",
             '<div class="sub">資料夾:<code>tools/</code></div>']
    for f in sorted(os.listdir(d)):
        if f.endswith(".py"):
            parts.append(code_block(os.path.join(d, f), "tools/" + f))
    return page("附錄 B", "".join(parts))


# ---------------------------------------------------------------- 列印
def find_browser():
    for b in BROWSERS:
        if os.path.isfile(b):
            return b
    return None


def print_pdf(html_path, pdf_path, browser):
    prof = tempfile.mkdtemp(prefix="bookprof_")
    cmd = [browser, "--headless=new", "--disable-gpu", "--no-sandbox",
           "--no-pdf-header-footer", "--user-data-dir=" + prof,
           "--virtual-time-budget=12000",
           "--print-to-pdf=" + pdf_path, html_path]
    try:
        r = subprocess.run(cmd, capture_output=True, text=True,
                           encoding="utf-8", errors="replace", timeout=300)
    finally:
        shutil.rmtree(prof, ignore_errors=True)
    if not os.path.isfile(pdf_path):
        return False, (r.stderr or r.stdout or "")[-300:]
    return True, ""


def audit(pdf_path=OUT_PDF):
    """回頭驗產出的 PDF。**過濾寫在 scrub() 一處,這裡獨立再查一次** ——
    「已經過濾了」不是證據,要對成品驗。"""
    import fitz
    if not os.path.isfile(pdf_path):
        print("  找不到 %s" % pdf_path)
        return 1
    d = fitz.open(pdf_path)
    pats = [("授權相關", LICENSE_LINE),
            ("個人 email", EMAIL),
            ("作者/姓名標頭", AUTHOR_LINE)]
    hits = {k: [] for k, _ in pats}
    for i in range(len(d)):
        t = d[i].get_text()
        for k, rx in pats:
            for m in rx.finditer(t):
                ctx = t[max(0, m.start() - 35):m.end() + 35].replace("\n", " ")
                hits[k].append((i + 1, ctx.strip()))
    print("=" * 74)
    print("  對外文件稽核:%s(%d 頁)" % (os.path.basename(pdf_path), len(d)))
    print("=" * 74)
    bad = 0
    for k, _ in pats:
        v = hits[k]
        bad += len(v)
        print("  %-14s %d 處" % (k, len(v)))
        for pg, ctx in v[:6]:
            print("      p.%-4d %s" % (pg, ctx[:90]))
        if len(v) > 6:
            print("      ... 另外 %d 處" % (len(v) - 6))
    print()
    if bad:
        print("  **%d 處漏網**。scrub() 是逐行過濾,跨行的表格或被拆行的字串會漏掉。"
              % bad)
        return 1
    print("  乾淨:沒有授權資訊、沒有個人 email、沒有作者標頭。")
    return 0


def main():
    argv = sys.argv[1:]
    if "--audit" in argv:
        return audit()
    secs = sections()

    if "--list" in argv:
        print("=" * 74)
        print("  會收進這一本的章節")
        print("=" * 74)
        for k, title, _ in secs:
            print("  %-22s %s" % (k, title))
        print("\n  共 %d 節" % len(secs))
        return 0

    only = None
    if "--only" in argv:
        only = argv[argv.index("--only") + 1]
        secs = [s for s in secs if only in s[0]]
        if not secs:
            print("  找不到 --only %s" % only)
            return 1

    browser = find_browser()
    if browser is None:
        print("  找不到 Edge / Chrome,無法列印")
        return 1
    os.makedirs(BUILD, exist_ok=True)
    print("=" * 74)
    print("  產生 PDF(%d 節,瀏覽器 %s)" % (len(secs), os.path.basename(browser)))
    print("=" * 74)

    pdfs = []
    for key, title, src in secs:
        hp = os.path.join(BUILD, key + ".html")
        pp = os.path.join(BUILD, key + ".pdf")
        if isinstance(src, tuple) and src[0] == "file":
            hp = src[1]                       # 章節用它自己的 HTML,原樣列印
        else:
            io.open(hp, "w", encoding="utf-8").write(src())
        ok, err = print_pdf(hp, pp, browser)
        if not ok:
            print("  %-22s **列印失敗** %s" % (key, err[:80]))
            return 1
        import fitz
        n = len(fitz.open(pp))
        pdfs.append((key, title, pp, n))
        print("  %-22s %3d 頁   %s" % (key, n, title[:40]))

    if only:
        print("\n  單節模式,沒有合併。產物在 %s" % BUILD)
        return 0
    return merge(pdfs)


def merge(pdfs):
    import fitz
    book = fitz.open()
    toc = []
    for key, title, pp, n in pdfs:
        start = len(book) + 1
        book.insert_pdf(fitz.open(pp))
        if key != "cover":
            toc.append([1, title, start])
    # 頁碼:封面不蓋
    for i in range(1, len(book)):
        pg = book[i]
        r = pg.rect
        pg.insert_text((r.width / 2 - 10, r.height - 22), str(i + 1),
                       fontsize=9, fontname="helv", color=(0.35, 0.4, 0.45))
    book.set_toc(toc)
    book.set_metadata({"title": "立方衛星設計與實作 — STK 課程教材",
                       "author": "", "subject": "STK / Python 課程教材 · 全 16 週",
                       "creator": "tools/make_course_pdf.py"})
    book.save(OUT_PDF, deflate=True, garbage=3)
    print("\n  已寫出 %s(%d 頁、%.1f MB、書籤 %d 條)"
          % (os.path.basename(OUT_PDF), len(book),
             os.path.getsize(OUT_PDF) / 1048576, len(toc)))
    return 0


if __name__ == "__main__":
    sys.exit(main())
tools/make_pptx.py
# -*- coding: utf-8 -*-
# ==============================================================================
# 獨立章節的簡報產生器
#
# 每一章提供一份 `*_deck.py`,裡面只有一個 `DECK` 常數(純資料,沒有副作用、
# 不 import STK)。本檔把它變成九頁的 .pptx。
#
# 為什麼是「純資料 + 共用產生器」而不是每章各寫一支:
#   各章的投影片要長得一樣,學生才不必每次重新學怎麼讀。而且格式改一次
#   全部一起改 —— 這一輪我們已經被「同一個東西在不同地方各寫一份、
#   改了其中一份」咬過好幾次了。
#
# 為什麼動畫是 GIF:本機沒有 ffmpeg,matplotlib 只有 pillow 與 html 兩個
# writer。PowerPoint 本來就會播 GIF,投影播放時會自動循環。
#
# 執行:
#   python tools/make_pptx.py                # 產生全部章節
#   python tools/make_pptx.py tirem_coastal  # 只產生一章
# ==============================================================================

import importlib.util
import io
import os
import re
import zipfile
import sys

from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.util import Emu, Inches, Pt

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, os.pardir))

# 章節資料夾 -> deck 模組檔名
CHAPTERS = [
    ("radar_bistatic", "bistatic_deck.py"),
    ("eoir_earlywarning", "eoir_deck.py"),
    ("launch_conjunction", "lc_deck.py"),
    ("tirem_coastal", "tirem_deck.py"),
    ("grpc_link", "grpc_deck.py"),
    # 第三個欄位是輸出檔名。**同一章可以有多份簡報** ——
    # 章節簡報給修課的人看,對外那一份給不熟這套工具的人看,
    # 兩者的取捨完全不同,硬塞成同一份只會兩邊都不對。
    ("grpc_link", "uav_de_deck.py", "UAV_DigitalEngineering.pptx"),
    ("grpc_link", "aero_de_deck.py", "Aero_SpaceToUAV_DigitalEngineering.pptx"),
]

# 16:9,PowerPoint 預設的寬螢幕
SLIDE_W, SLIDE_H = Inches(13.333), Inches(7.5)

INK = RGBColor(0x14, 0x1A, 0x1C)
MUTED = RGBColor(0x5D, 0x68, 0x6C)
RULE = RGBColor(0xDE, 0xE3, 0xE0)
PASS_C = RGBColor(0x2C, 0x7A, 0x4B)
FAIL_C = RGBColor(0xAC, 0x34, 0x27)
SKIP_C = RGBColor(0x7A, 0x6E, 0x52)

# 每章一個訊號色 —— 與 HTML 展示頁同一套,四章各有身分
ACCENT = {
    "radar_bistatic": RGBColor(0x1E, 0x6F, 0x6B),
    "eoir_earlywarning": RGBColor(0xA8, 0x6A, 0x1F),
    "launch_conjunction": RGBColor(0x9A, 0x3D, 0x2A),
    "tirem_coastal": RGBColor(0x4A, 0x6B, 0x34),
}

BODY_FONT = "Noto Sans TC"
MONO_FONT = "Consolas"


# ------------------------------------------------------------------------------
def load_deck(folder, modfile):
    path = os.path.join(ROOT, folder, modfile)
    if not os.path.exists(path):
        return None
    spec = importlib.util.spec_from_file_location(
        "deck_" + folder, path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return getattr(mod, "DECK", None)


def _txt(frame, text, size=18, bold=False, color=INK, mono=False,
         align=PP_ALIGN.LEFT, space_after=6):
    p = frame.paragraphs[0] if not frame.paragraphs[0].runs else frame.add_paragraph()
    p.alignment = align
    p.space_after = Pt(space_after)
    r = p.add_run()
    r.text = text
    r.font.size = Pt(size)
    r.font.bold = bold
    r.font.color.rgb = color
    r.font.name = MONO_FONT if mono else BODY_FONT
    return p


def _blank(prs):
    return prs.slides.add_slide(prs.slide_layouts[6])   # 全空白版面


def _header(slide, text, accent, sub=None):
    box = slide.shapes.add_textbox(Inches(0.6), Inches(0.35),
                                   SLIDE_W - Inches(1.2), Inches(0.9))
    tf = box.text_frame
    tf.word_wrap = True
    _txt(tf, text, size=30, bold=True, color=accent, space_after=2)
    if sub:
        _txt(tf, sub, size=14, color=MUTED)
    line = slide.shapes.add_shape(1, Inches(0.6), Inches(1.35),
                                  SLIDE_W - Inches(1.2), Emu(9525))
    line.fill.solid()
    line.fill.fore_color.rgb = accent
    line.line.fill.background()
    line.shadow.inherit = False
    return box


def _fit_picture(slide, path, top, max_h, left=None, max_w=None):
    """把圖等比例放進指定的框裡,置中。"""
    from PIL import Image
    with Image.open(path) as im:
        iw, ih = im.size
    max_w = max_w or (SLIDE_W - Inches(1.2))
    scale = min(float(max_w) / iw, float(max_h) / ih)
    w, h = int(iw * scale), int(ih * scale)
    left = left if left is not None else int((SLIDE_W - w) / 2)
    return slide.shapes.add_picture(path, left, top, width=w, height=h)


def _table(slide, rows, left, top, width, height, accent,
           header=True, font=12):
    n_r, n_c = len(rows), max(len(r) for r in rows)
    shape = slide.shapes.add_table(n_r, n_c, left, top, width, height)
    tbl = shape.table
    for i, row in enumerate(rows):
        for j in range(n_c):
            cell = tbl.cell(i, j)
            cell.text = str(row[j]) if j < len(row) else ""
            for p in cell.text_frame.paragraphs:
                for r in p.runs:
                    r.font.size = Pt(font)
                    r.font.name = BODY_FONT
                    r.font.bold = bool(header and i == 0)
                    r.font.color.rgb = INK
    return tbl


# ------------------------------------------------------------------------------
def build(folder, deck, out=None):
    accent = ACCENT.get(folder, INK)
    d = os.path.join(ROOT, folder)
    prs = Presentation()
    prs.slide_width, prs.slide_height = SLIDE_W, SLIDE_H

    # ---- 1. 標題 ----
    s = _blank(prs)
    box = s.shapes.add_textbox(Inches(0.9), Inches(2.1),
                               SLIDE_W - Inches(1.8), Inches(3.2))
    tf = box.text_frame
    tf.word_wrap = True
    _txt(tf, deck.get("eyebrow", "獨立章節"), size=15, color=MUTED, space_after=10)
    _txt(tf, deck["title"], size=44, bold=True, color=accent, space_after=6)
    if deck.get("subtitle"):
        _txt(tf, deck["subtitle"], size=24, color=INK, space_after=16)
    if deck.get("thesis"):
        _txt(tf, deck["thesis"], size=16, color=MUTED)
    foot = s.shapes.add_textbox(Inches(0.9), SLIDE_H - Inches(1.0),
                                SLIDE_W - Inches(1.8), Inches(0.5))
    _txt(foot.text_frame,
         "清華大學太空科學與工程研究所「立方衛星設計與實作」· "
         "所有數值為 STK 12.10.0 實測", size=11, color=MUTED)

    # ---- 2. 場景設定 ----
    if deck.get("scenario"):
        s = _blank(prs)
        _header(s, "場景設定", accent)
        rows = [("項目", "設定")] + [tuple(x) for x in deck["scenario"]]
        _table(s, rows, Inches(0.6), Inches(1.7),
               SLIDE_W - Inches(1.2), Inches(0.4) * len(rows), accent)

    # ---- 3. 模擬重點 ----
    if deck.get("focus"):
        s = _blank(prs)
        _header(s, "模擬重點", accent)
        box = s.shapes.add_textbox(Inches(0.8), Inches(1.9),
                                   SLIDE_W - Inches(1.6), Inches(4.8))
        tf = box.text_frame
        tf.word_wrap = True
        for i, line in enumerate(deck["focus"]):
            _txt(tf, "%d.  %s" % (i + 1, line), size=19, space_after=16)

    # ---- 4. 動畫 ----
    anim = deck.get("animation")
    if anim and os.path.exists(os.path.join(d, anim)):
        s = _blank(prs)
        _header(s, deck.get("animation_title", "動畫"), accent,
                deck.get("animation_caption"))
        _fit_picture(s, os.path.join(d, anim), Inches(1.75), Inches(5.2))
        note = s.shapes.add_textbox(Inches(0.6), SLIDE_H - Inches(0.62),
                                    SLIDE_W - Inches(1.2), Inches(0.4))
        _txt(note.text_frame, "GIF 動畫 — 放映模式下自動播放", size=10,
             color=MUTED, align=PP_ALIGN.RIGHT)

    # ---- 5. 主要結果圖(每張一頁)----
    for fname, cap in deck.get("figures", []):
        p = os.path.join(d, fname)
        if not os.path.exists(p):
            continue
        s = _blank(prs)
        _header(s, cap.split("——")[0].strip() or fname, accent,
                cap if "——" in cap else None)
        _fit_picture(s, p, Inches(1.7), Inches(5.3))

    # ---- 6. 關鍵數字 ----
    if deck.get("results"):
        s = _blank(prs)
        _header(s, "關鍵數字", accent, deck.get("results_caption"))
        rows = [tuple(x) for x in deck["results"]]
        _table(s, rows, Inches(0.6), Inches(1.8),
               SLIDE_W - Inches(1.2),
               min(Inches(0.38) * len(rows), Inches(5.0)), accent)

    # ---- 7. 招牌發現 ----
    if deck.get("findings"):
        s = _blank(prs)
        _header(s, "招牌發現", accent)
        box = s.shapes.add_textbox(Inches(0.8), Inches(1.9),
                                   SLIDE_W - Inches(1.6), Inches(4.9))
        tf = box.text_frame
        tf.word_wrap = True
        for line in deck["findings"]:
            _txt(tf, "▪  " + line, size=17, space_after=15)

    # ---- 8. 驗證段 ----
    if deck.get("verify"):
        npass, nfail, nskip = deck["verify"]
        s = _blank(prs)
        _header(s, "驗證段:ACK 不等於生效", accent)
        box = s.shapes.add_textbox(Inches(0.8), Inches(2.0),
                                   SLIDE_W - Inches(1.6), Inches(1.6))
        tf = box.text_frame
        p = tf.paragraphs[0]
        for label, val, col in (("PASS", npass, PASS_C),
                                ("FAIL", nfail, FAIL_C),
                                ("SKIP", nskip, SKIP_C)):
            r = p.add_run()
            r.text = "  %s %d   " % (label, val)
            r.font.size = Pt(40)
            r.font.bold = True
            r.font.color.rgb = col
            r.font.name = MONO_FONT
        if deck.get("verify_note"):
            nb = s.shapes.add_textbox(Inches(0.8), Inches(3.5),
                                      SLIDE_W - Inches(1.6), Inches(3.0))
            ntf = nb.text_frame
            ntf.word_wrap = True
            for line in deck["verify_note"]:
                _txt(ntf, line, size=15, color=INK, space_after=10)

    # ---- 9. 檔案與執行 ----
    if deck.get("files"):
        s = _blank(prs)
        _header(s, "檔案與執行", accent)
        if deck.get("run"):
            box = s.shapes.add_textbox(Inches(0.6), Inches(1.7),
                                       SLIDE_W - Inches(1.2), Inches(1.2))
            tf = box.text_frame
            tf.word_wrap = True
            for line in deck["run"]:
                _txt(tf, line, size=13, mono=True, space_after=3)
        rows = [("檔案", "內容")] + [tuple(x) for x in deck["files"]]
        _table(s, rows, Inches(0.6), Inches(3.0),
               SLIDE_W - Inches(1.2),
               min(Inches(0.34) * len(rows), Inches(4.0)), accent, font=11)

    out = out or os.path.join(d, folder + ".pptx")
    prs.save(out)
    return out, len(prs.slides._sldIdLst)


def _shape(path):
    """數出一個 .pptx 的頁數與媒體數。讀不到就回 None(不擋)。"""
    try:
        names = zipfile.ZipFile(path).namelist()
    except Exception:                                              # noqa: BLE001
        return None
    return (len([n for n in names
                 if re.match(r"ppt/slides/slide\d+\.xml$", n)]),
            len([n for n in names if n.startswith("ppt/media/")]))


def _hand_added(existing, fresh):
    """既有檔案裡有沒有**不是這支腳本放進去的**東西?

    簡報產生出來之後,人會在 PowerPoint 裡繼續加東西 —— 補一張截圖、
    插一段錄影、多做兩頁。那些內容只存在於 .pptx 裡,沒有任何原始碼,
    重跑產生器就沒了,而且**除了檔案大小以外沒有任何徵兆**。

    這件事真的發生過:一次全量重出把 `eoir_earlywarning.pptx` 裡兩支
    合計 43 MB 的 mp4 連同一張投影片蓋掉,`radar_bistatic.pptx` 掉了
    10 張圖和兩頁。Git 只顯示 `Bin 45542702 -> 563988 bytes`,
    而那一行很容易被當成「圖片壓縮方式改了」。

    比對的是**實際產生出來的暫存檔**,不是預測的頁數 —— 預測會跟著
    版面邏輯一起飄,而飄掉的時候它會安靜地放行。

    既有檔案比新檔**多**頁或多媒體就擋下來;反過來(比較少)是正常的,
    代表產生器變豐富了。
    """
    a, b = _shape(existing), _shape(fresh)
    if a is None or b is None:
        return None
    why = []
    if a[0] > b[0]:
        why.append("%d 頁 > 重出的 %d 頁" % (a[0], b[0]))
    if a[1] > b[1]:
        why.append("%d 個媒體 > 重出的 %d 個" % (a[1], b[1]))
    return ";".join(why) if why else None



def main(argv=None):
    argv = argv or sys.argv[1:]
    force = "--force" in argv
    want = set(a for a in argv if not a.startswith("-")) or None
    made = skipped = 0
    for entry in CHAPTERS:
        folder, modfile = entry[0], entry[1]
        outname = entry[2] if len(entry) > 2 else None
        if want and folder not in want:
            continue
        deck = load_deck(folder, modfile)
        if deck is None:
            print("  %-22s 沒有 %s,跳過" % (folder, modfile))
            continue
        target = (os.path.join(ROOT, folder, outname) if outname
                  else os.path.join(ROOT, folder, folder + ".pptx"))
        # 先產生到暫存檔,拿**實際結果**去比,再決定要不要蓋過去
        tmp = target + ".new"
        out, n = build(folder, deck, tmp)
        extra = _hand_added(target, tmp) if not force else None
        if extra:
            os.remove(tmp)
            print("  %-22s **跳過**:既有檔案有手動加的內容(%s)"
                  % (folder, extra))
            print("  %-22s   重出會蓋掉它。確定要覆蓋就加 --force。" % "")
            skipped += 1
            continue
        os.replace(tmp, target)
        out = target
        print("  %-22s %2d 頁  ->  %s"
              % (folder, n, os.path.relpath(out, ROOT)))
        made += 1
    if skipped:
        print()
        print("  %d 份因為含有手動加入的內容而沒有重出。" % skipped)
        print("  那些東西只存在於 .pptx 裡,沒有原始碼,蓋掉就沒了。")
    if not made:
        print("沒有產生任何簡報 —— 先替各章寫 *_deck.py")
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
tools/run_notebooks.py
# -*- coding: utf-8 -*-
r"""執行各週的 notebook 並把輸出存回去。

## 為什麼需要這一支

16 本 notebook、182 個程式碼格,**沒有一格有輸出**。兩個後果:

1. 學生打開看到的是空白 —— 他得自己全部跑一遍才知道長什麼樣
2. **沒有任何東西證明 notebook 跑得起來。** `.py` 跑得起來不等於 notebook
   跑得起來:bootstrap cell、`WEEK_DIR` 後備、`%matplotlib`、逐格執行的
   順序(`HERE` 要先定義再使用)都只存在於 notebook 那條路徑上。
   上一輪就實際踩到:`.py` 全部正常,而六本 notebook 還留著舊的裸路徑。

## 兩類 notebook,不能一起跑

    不連 STK  week03 / 05 / 06 / 08 / 09 / 12 / 14
              頂層只呼叫該週 lab 的 main(),純 Python,可以直接跑

    連 STK    week01 / 02 / 04 / 07 / 10 / 11 / 13 / 15 / 16
              會 AttachToApplication 之後 close_scenario() 或 NewScenario ——
              **跑下去會關掉當下載入的場景**。預設跳過,要跑得加 --stk,
              而且要先確認 STK 裡沒有別的場景在用。

    python tools/run_notebooks.py                # 只跑不連 STK 的
    python tools/run_notebooks.py --stk          # 連 STK 的也跑(會關掉當下場景)
    python tools/run_notebooks.py --check        # 只回報哪幾本沒有輸出,不執行
    python tools/run_notebooks.py week03_budgets # 只跑指定的幾週

離開碼 0 = 全部成功(或 --check 下全部都有輸出)。
"""
import io
import json
import os
import re
import sys
import time

HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))

# 會連 STK、而且會關掉當下場景的目錄。
# **六個獨立章節全部都連 STK**,而且它們原本不在掃描範圍內 ——
# check_notebook_drift 有同一個缺口,它的表現是:
# 守門回報「16 本都乾淨」,而新加的那一本根本沒被看過。
STK_WEEKS = {"week01", "week02", "week04", "week07", "week10",
             "week11", "week13", "week15", "week16",
             "radar_bistatic", "eoir_earlywarning", "launch_conjunction",
             "tirem_coastal", "moon_transfer"}
CELL_TIMEOUT = 900          # 秒。week08 的消旋積分是最慢的一本


def notebooks():
    """[(週次目錄, notebook 路徑, 是否連 STK), ...]。"""
    out = []
    for d in sorted(os.listdir(REPO)):
        p = os.path.join(REPO, d)
        if not os.path.isdir(p) or d.startswith((".", "_")):
            continue
        nbs = [x for x in sorted(os.listdir(p)) if x.endswith(".ipynb")]
        if not nbs:
            continue
        key = d.split("_")[0] if re.match(r"^week\d{2}_", d) else d
        for f in nbs:
            out.append((d, os.path.join(p, f), key in STK_WEEKS))
    return out


def output_stats(path):
    """(程式碼格數, 已執行的格數, 有輸出的格數)。

    「有輸出」不是好判準 —— 大多數格子是函式定義,本來就沒有輸出。
    真正的訊號是 `execution_count`:跑過的格子才有編號。
    """
    nb = json.load(io.open(path, encoding="utf-8"))
    code = [c for c in nb["cells"] if c["cell_type"] == "code"]
    execed = sum(1 for c in code if c.get("execution_count") is not None)
    with_out = sum(1 for c in code if c.get("outputs"))
    return len(code), execed, with_out


def run_one(nb_path, week_dir):
    """就地執行並存回。回傳 (成功, 訊息, 秒數)。"""
    import nbformat
    from nbclient import NotebookClient
    from nbclient.exceptions import CellExecutionError

    t0 = time.time()
    nb = nbformat.read(nb_path, as_version=4)
    client = NotebookClient(nb, timeout=CELL_TIMEOUT, kernel_name="python3",
                            resources={"metadata": {"path": week_dir}},
                            allow_errors=False)
    try:
        client.execute()
    except CellExecutionError as e:
        # 失敗也把已經跑到的輸出留著,方便看卡在哪一格
        nbformat.write(nb, nb_path)
        msg = str(e).strip().split("\n")[-1][:140]
        return False, msg, time.time() - t0
    except Exception as e:                            # noqa: BLE001
        return False, "%s: %s" % (type(e).__name__, str(e)[:120]), time.time() - t0
    nbformat.write(nb, nb_path)
    return True, "", time.time() - t0


def main():
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    do_stk = "--stk" in sys.argv
    check_only = "--check" in sys.argv

    targets = notebooks()
    if args:
        targets = [t for t in targets if t[0] in args]
        if not targets:
            print("  找不到指定的週次:%s" % args)
            return 1

    print("=" * 78)
    print("  notebook 執行" + ("(只檢查)" if check_only else ""))
    print("=" * 78)

    if check_only:
        bad = 0
        for d, p, is_stk in targets:
            total, execed, with_out = output_stats(p)
            if execed == total and total:
                tag = "OK"
            elif is_stk:
                tag = "未執行(需 --stk,會關掉當下場景)"
            else:
                tag = "**%d 格沒跑過**" % (total - execed)
                bad += 1
            print("  %-22s 程式碼格 %3d / 已執行 %3d / 有輸出 %3d  %s"
                  % (d, total, execed, with_out, tag))
        print()
        print("  不連 STK 而仍有沒跑過的格子:%d 本" % bad)
        return 1 if bad else 0

    ok = fail = skipped = 0
    for d, p, is_stk in targets:
        if is_stk and not do_stk:
            print("  %-22s 跳過 —— 會 close_scenario()/NewScenario,"
                  "加 --stk 才跑" % d)
            skipped += 1
            continue
        before = output_stats(p)
        good, msg, secs = run_one(p, os.path.dirname(p))
        after = output_stats(p)
        if good:
            ok += 1
            print("  %-22s OK    %6.1f s   已執行的格子 %d -> %d(有輸出 %d)"
                  % (d, secs, before[1], after[1], after[2]))
        else:
            fail += 1
            print("  %-22s FAIL  %6.1f s   %s" % (d, secs, msg))
    print()
    print("  成功 %d / 失敗 %d / 跳過 %d" % (ok, fail, skipped))
    if skipped:
        print("  跳過的是連 STK 的那幾本;跑之前要先確認 STK 裡沒有別的場景在用。")
    return 1 if fail else 0


if __name__ == "__main__":
    sys.exit(main())
tools/stk_paths.py
# -*- coding: utf-8 -*-
r"""STK 報告輸出目錄的**單一來源**。

## 為什麼需要這一支

`C:\STK_Reports\` 原本寫死在三個 Python 檔裡(week04 的驗證段、
week12 的資料預算、week16 的星座權衡),再加上三份 Connect 腳本、
手冊、README 與兩份實驗單。換一台不能寫 `C:\` 的機器,學生得自己去翻
七八個地方。

## 為什麼 Connect 腳本那一側不動

`ReportCreate ... File "..."` 是**靜態文字**,直接送給 STK,展不開環境變數;
而相對路徑會相對於 STK 自己的工作目錄(不可預測)—— 那就是原本選絕對路徑
的理由。所以寫入方(Connect)保持字面值,讀取方(Python)從這裡取,
再由 `tools/check_stk_report_paths.py` 把兩側綁在一起。

## 覆寫

    set SMALLSAT_STK_REPORTS=D:\my_reports

**覆寫之後 Connect 腳本不會跟著變** —— 它們仍寫到 `C:\STK_Reports\`。
所以覆寫只適用於「報告已經用別的方式放好」的情況。
`check_stk_report_paths.py` 在偵測到覆寫時會明講這件事。
"""
import os

ENV_VAR = "SMALLSAT_STK_REPORTS"

# Connect 腳本裡字面寫的那個目錄。改這裡的話,三份 Connect 腳本也要一起改
# ——`check_stk_report_paths.py` 會盯著兩側一致。
DEFAULT_REPORTS_DIR = r"C:\STK_Reports"


def reports_dir():
    """報告目錄。環境變數優先。"""
    return os.environ.get(ENV_VAR) or DEFAULT_REPORTS_DIR


def is_overridden():
    return bool(os.environ.get(ENV_VAR))


def report(name):
    """報告檔的完整路徑。"""
    return os.path.join(reports_dir(), name)


def ensure_reports_dir():
    """把目錄建起來。回傳 (路徑, 這次才建的嗎)。

    手冊與 README 都寫著「先手動建立,**寫檔失敗為靜默失敗**」——
    STK 的 `ReportCreate` 寫不進去不會吭聲,學生會拿到一個空的分析。
    跑 Connect 腳本之前呼叫這一支就不必靠記性。
    """
    d = reports_dir()
    if os.path.isdir(d):
        return d, False
    os.makedirs(d, exist_ok=True)
    return d, True
tools/stk_verify.py
# -*- coding: utf-8 -*-
# ================================================================
# stk_verify.py — 「ACK 不等於生效」的驗證工具(跨週共用)
#
# 為什麼需要這個:
#   STK Connect 對絕大多數指令只回「收到」。本教材實測 396 條指令,
#   全部 ACK,但真正能證明生效的只有 277 條。學生若養成
#   「送指令 → 沒報錯 → 相信成功了」的習慣,在工程上是危險的。
#
#   本課程實際踩到的靜默失敗:
#     - Model.DesignFrequency 這個屬性根本不存在(要寫到 AntennaControl.Antenna 底下)
#     - Model.GOverT 是線性比值不是 dB,給 -10 會被拒但整支腳本照跑
#     - HPOP Force/Drag 是延後提交,STK 12 沒有 Propagate 就等於沒設定
#     - ppigrf 呼叫失敗被 except: pass 吞掉,結果一直在用 fallback 模型
#
# 用法(見 week11_comm/week11_verify.py 的完整範例):
#
#     from stk_verify import Verifier, run_script
#     run_script(root, "...Connect.txt")
#     v = Verifier(root)
#     v.auto_setvalues("...Connect.txt")          # 自動回讀腳本設過的每個值
#     v.object_exists("*/Facility/AGI_HQ")        # 手動的結構檢查
#     v.position("*/Facility/AGI_HQ", 40.0416, -75.5983)
#     raise SystemExit(0 if v.report() else 1)
# ================================================================

import math
import os
import re

SETVAL = re.compile(r"^(Transmitter|Receiver|Astrogator|RFEnvironment|Radar|RCS)"
                    r"\s+(\S+)\s+SetValue\s+(\S+)\s+(.*)$")
RM = {"Transmitter": "Transmitter_RM", "Receiver": "Receiver_RM",
      "Astrogator": "Astrogator_RM", "RFEnvironment": "RFEnvironment_RM",
      "Radar": "Radar_RM", "RCS": "RCS_RM"}

# STK 回讀時可能已換算成場景單位
_CONV = {("degC", "K"): lambda v: v + 273.15,
         ("km", "m"):   lambda v: v * 1000.0,
         ("deg", "rad"): lambda v: math.radians(v),
         # 雷達與通訊:腳本習慣寫 GHz/MHz,STK 一律回讀 Hz
         ("GHz", "Hz"): lambda v: v * 1e9,
         ("MHz", "Hz"): lambda v: v * 1e6,
         ("kHz", "Hz"): lambda v: v * 1e3,
         ("kW", "W"):   lambda v: v * 1e3,
         ("usec", "sec"): lambda v: v * 1e-6,
         ("nmi", "m"):  lambda v: v * 1852.0,
         ("ft", "m"):   lambda v: v * 0.3048}


def _num(tok):
    try:
        return float(tok)
    except (TypeError, ValueError):
        return None


def compare(want, got):
    """比較腳本要求的值與 STK 讀回的值,容許單位換算。
    回傳 (ok, 說明)。"""
    if got is None:
        return None, "沒有回讀路徑"
    w, g = str(want).strip().strip('"'), str(got).strip().strip('"')
    if not g:
        return None, "回讀為空"
    wt, gt = w.split(), g.split()
    wv, gv = (_num(wt[0]) if wt else None), (_num(gt[0]) if gt else None)
    if wv is not None and gv is not None:
        wu = wt[1] if len(wt) > 1 else None
        gu = gt[1] if len(gt) > 1 else None
        cands = [wv]
        if wu and gu and wu != gu and (wu, gu) in _CONV:
            cands.append(_CONV[(wu, gu)](wv))
        if wu is None and gu:
            cands += [f(wv) for (a, b), f in _CONV.items() if b == gu]
        for c in cands:
            if abs(gv - c) <= max(abs(c) * 1e-6, 1e-9):
                return True, ""
        return False, f"讀回 {g},腳本要求 {w}"
    if g.lower().replace(" ", "_") == w.lower().replace(" ", "_"):
        return True, ""
    return False, f"讀回 {g},腳本要求 {w}"


def commands(path):
    """取出腳本中的實際指令(略過註解與空行)。"""
    with open(path, encoding="utf-8", errors="replace") as f:
        return [(i, l.strip()) for i, l in enumerate(f, 1)
                if l.strip() and not l.strip().startswith("#")]


def run_script(root, path, echo_fail=True):
    """逐行送出 Connect 腳本。回傳 (ack, nack)。
    注意:ack 只代表 STK 收下指令,不代表設定生效 —— 這正是要驗證的原因。"""
    ok = fail = 0
    for lineno, cmd in commands(path):
        try:
            root.ExecuteCommand(cmd)
            ok += 1
        except Exception:
            fail += 1
            if echo_fail:
                print(f"  NACK  L{lineno:<4} {cmd[:96]}")
    print(f"{os.path.basename(path)}:{ok} 條 ACK,{fail} 條 NACK")
    return ok, fail


def load_lab(path, name=None):
    """把某週的實驗腳本當成模組載入,好直接斷言它算出來的值。

    比解析主控台輸出穩健得多 —— 輸出格式一改,解析就壞了。
    各實驗腳本的 main() 都有 __main__ 保護,所以載入時不會執行繪圖。

    **一定要繞過 __pycache__。** Python 用「秒」的精度比對 .pyc 記的來源
    時間戳;失敗注入如果在同一秒內改完又還原,原始碼看起來是乾淨的、
    md5 也對得上,但**載進來的還是注入版**。地月轉移章實際踩到:
    月球重力參數的注入還原之後,驗證段仍然用著被改壞的值在跑
    (原始碼 22:14:59.111、.pyc 22:14:59.102,同一秒)。
    那會讓失敗注入變成假通過 —— 而這個教材庫的信心正是建立在注入上。
    所以直接讀原始碼 compile,不碰磁碟上的位元碼。
    """
    import importlib.util
    import io as _io
    mod_name = name or os.path.splitext(os.path.basename(path))[0]
    spec = importlib.util.spec_from_file_location(mod_name, path)
    m = importlib.util.module_from_spec(spec)
    src = _io.open(path, encoding="utf-8").read()
    exec(compile(src, path, "exec"), m.__dict__)      # noqa: S102
    return m


class Verifier:
    """收集檢查結果並印成一張表。

    三種結果:
      PASS  讀回值符合腳本意圖
      FAIL  讀回值不符 —— 指令 ACK 了但沒生效,或值被 STK 改寫
      SKIP  沒有回讀路徑(Connect 查不到),需要另外用物件模型或 GUI 確認
    """

    def __init__(self, root):
        self.root = root
        self.rows = []

    # ---- 內部 ----
    def _add(self, ok, label, detail=""):
        # numpy.bool_ 不是 Python 的 True/False singleton,報表會誤判成 SKIP
        ok = None if ok is None else bool(ok)
        self.rows.append((ok, label, detail))
        return ok

    def _obj(self, path):
        try:
            return self.root.GetObjectFromPath(path)
        except Exception:
            return None

    def _getvalue(self, verb, obj, prop):
        """*_RM GetValue 會一次吐很多行,要挑出剛好等於該屬性的那一行。"""
        try:
            r = self.root.ExecuteCommand(f"{RM[verb]} {obj} GetValue {prop}")
            lines = [r.Item(i) for i in range(r.Count)]
        except Exception:
            return None
        for l in lines:
            if "=" in l and l.split("=", 1)[0].strip() == prop:
                return l.split("=", 1)[1].strip()
        if len(lines) == 1 and "=" in lines[0]:
            return lines[0].split("=", 1)[1].strip()
        return None

    # ---- 自動:回讀腳本設過的每一個值 ----
    def auto_setvalues(self, path):
        for lineno, cmd in commands(path):
            m = SETVAL.match(cmd)
            if not m:
                continue
            verb, obj, prop, want = m.groups()
            if prop.endswith("ComponentName"):     # 改名:舊路徑已不存在,略過
                continue
            got = self._getvalue(verb, obj, prop)
            if got is not None and got.strip() == "Unknown":
                # 清單型屬性(StoppingConditions / Results)Connect 一律回 Unknown,
                # 要用底下的 stopping_condition() / dc_constraint() 專屬檢查
                self._add(None, f"L{lineno} {prop}", "清單型屬性,見專屬檢查")
                continue
            ok, detail = compare(want.strip(), got)
            self._add(ok, f"L{lineno} {prop}", detail)
        return self

    # ---- 手動:結構與幾何 ----
    def object_exists(self, path):
        return self._add(self._obj(path) is not None, f"物件存在 {path}")

    def position(self, path, lat, lon, tol=1e-4):
        o = self._obj(path)
        if o is None:
            return self._add(False, f"位置 {path}", "物件不存在")
        try:
            g = o.Position.QueryPlanetodetic()
        except Exception:
            return self._add(None, f"位置 {path}", "無法回讀")
        ok = abs(g[0] - lat) < tol and abs(g[1] - lon) < tol
        return self._add(ok, f"位置 {path}", "" if ok else f"讀回 {g[0]:.4f},{g[1]:.4f}")

    def elevation_constraint(self, path, minimum):
        o = self._obj(path)
        if o is None:
            return self._add(False, f"仰角約束 {path}", "物件不存在")
        try:
            from agi.stk12.stkobjects import AgEAccessConstraints as E
            c = o.AccessConstraints.GetActiveConstraint(E.eCstrElevationAngle)
        except Exception:
            return self._add(None, f"仰角約束 {path}", "無法回讀")
        ok = abs(c.Min - minimum) < 1e-6
        return self._add(ok, f"仰角約束 {path}", "" if ok else f"讀回 {c.Min}")

    def cone_angle(self, path, half_angle):
        o = self._obj(path)
        try:
            got = o.Pattern.ConeAngle
        except Exception:
            return self._add(None, f"錐角 {path}", "無法回讀")
        ok = abs(got - half_angle) < 1e-6
        return self._add(ok, f"錐角 {path}", "" if ok else f"讀回 {got}")

    def propagator_is(self, path, name):
        o = self._obj(path)
        try:
            got = type(o.Propagator).__name__
        except Exception:
            return self._add(None, f"傳播器 {path}", "無法回讀")
        alias = {"astrogator": ("astrogator", "drivermcs")}
        wanted = alias.get(name.lower(), (name.lower(),))
        ok = any(w in got.lower() for w in wanted)
        return self._add(ok, f"傳播器 {path} = {name}", "" if ok else f"讀回 {got}")

    # ---- Astrogator:結構與收斂 ----
    # 這一組專門補 Connect 的死角 —— InsertSegment / AddMCSSegmentControl /
    # SetMCSControlValue 這類結構指令沒有任何 GetValue 可以回讀,
    # 只能靠物件模型確認「MCS 真的長成腳本描述的樣子」。

    def _mcs(self, sat_path):
        try:
            return self._obj(sat_path).Propagator.MainSequence
        except Exception:
            return None

    def _ts(self, sat_path, name):
        ms = self._mcs(sat_path)
        try:
            return ms.Item(name)
        except Exception:
            return None

    def mcs_sequence(self, sat_path, expected):
        """MCS 主序列的段名與順序是否正好等於 expected。"""
        ms = self._mcs(sat_path)
        if ms is None:
            return self._add(False, "MCS 主序列", "讀不到傳播器")
        got = [ms.Item(i).Name for i in range(ms.Count) if ms.Item(i).Name != "-"]
        ok = got == list(expected)
        return self._add(ok, "MCS 主序列順序", "" if ok else f"讀回 {got}")

    def mcs_subsegments(self, sat_path, ts_name, expected):
        ts = self._ts(sat_path, ts_name)
        if ts is None:
            return self._add(False, f"{ts_name} 子段", "段不存在")
        try:
            got = [ts.Segments.Item(i).Name for i in range(ts.Segments.Count)
                   if ts.Segments.Item(i).Name != "-"]
        except Exception:
            return self._add(None, f"{ts_name} 子段", "無法回讀")
        ok = got == list(expected)
        return self._add(ok, f"{ts_name} 子段", "" if ok else f"讀回 {got}")

    def stopping_condition(self, sat_path, seg_name, expected):
        seg = self._ts(sat_path, seg_name)
        try:
            sc = seg.StoppingConditions
            got = [sc.Item(i).Name for i in range(sc.Count)]
        except Exception:
            return self._add(None, f"{seg_name} 停止條件", "無法回讀")
        ok = expected in got
        return self._add(ok, f"{seg_name} 停止條件 {expected}", "" if ok else f"讀回 {got}")

    def dc_converged(self, sat_path, ts_name):
        """差分修正器真的收斂了嗎?這是先前 delta-V 算出 0、
        以及收斂到錯誤分支(1946 m/s)時唯一能抓到的檢查。"""
        ts = self._ts(sat_path, ts_name)
        try:
            dc = ts.Profiles.Item(0)
            status = str(dc.Status)
            r = dc.Results.Item(0)
            within = abs(r.Difference) <= r.Tolerance
        except Exception as e:
            return self._add(None, f"{ts_name} 修正器收斂", str(e)[:50])
        ok = ("converged" in status.lower()) and within
        return self._add(ok, f"{ts_name} 修正器收斂",
                         f"status={status}, |diff|={abs(r.Difference):.4g} tol={r.Tolerance:g}")

    def dc_constraint(self, sat_path, ts_name, name, desired, tolerance=None):
        ts = self._ts(sat_path, ts_name)
        try:
            r = ts.Profiles.Item(0).Results.Item(0)
        except Exception:
            return self._add(None, f"{ts_name} 約束", "無法回讀")
        ok = (r.Name.lower().replace(" ", "") == name.lower().replace(" ", "")
              and abs(r.DesiredValue - desired) <= max(abs(desired) * 1e-9, 1e-9)
              and r.Enable)
        if tolerance is not None:
            ok = ok and abs(r.Tolerance - tolerance) <= 1e-9
        return self._add(ok, f"{ts_name} 約束 {name}",
                         "" if ok else f"讀回 {r.Name}={r.DesiredValue} tol={r.Tolerance} on={r.Enable}")

    def delta_v(self, sat_path, ts_name, expect_ms, rel_tol=0.10):
        """解出來的 delta-V(m/s)是否落在解析值的容許範圍內。
        注意:值在修正器的控制變數上,不在 segment 上 —— segment 永遠是標稱的 0。"""
        ts = self._ts(sat_path, ts_name)
        try:
            got = ts.Profiles.Item(0).ControlParameters.Item(0).FinalValue * 1000.0
        except Exception:
            return self._add(None, f"{ts_name} delta-V", "無法回讀")
        ok = abs(abs(got) - abs(expect_ms)) <= abs(expect_ms) * rel_tol
        return self._add(ok, f"{ts_name} delta-V ≈ {expect_ms:.1f} m/s (±{rel_tol*100:.0f}%)",
                         f"解出 {got:.2f} m/s,差 {100*(abs(got)-abs(expect_ms))/abs(expect_ms):+.1f}%")

    def final_element(self, sat_path, seg_name, element, expect, tol):
        """段末狀態的軌道根數(如近地點半徑)是否命中目標。"""
        try:
            r = self.root.ExecuteCommand(
                f"Astrogator_RM {sat_path} GetValue "
                f"MainSequence.SegmentList.{seg_name}.FinalState.Keplerian.{element}")
            got = float(r.Item(0).split("=", 1)[1].split()[0])
        except Exception:
            return self._add(None, f"{seg_name}.{element}", "無法回讀")
        ok = abs(got - expect) <= tol
        return self._add(ok, f"{seg_name}.{element} ≈ {expect:g} (±{tol:g})",
                         f"讀回 {got:.3f}")

    def custom(self, label, ok, detail=""):
        """自訂檢查:自己算好 True/False 丟進來。"""
        return self._add(ok, label, detail)

    # ---- 純數值檢查(不需要 STK,給 Python-only 的週次用)----
    # 用途:把實驗單上寫死的「基準結果」變成可執行的斷言。
    # 教材裡的數字最容易悄悄過期 —— 改了模型卻忘了改實驗單,
    # 學生跑出來對不上就會懷疑是自己弄錯。

    def expect(self, label, got, want, tol=0.0, unit=""):
        """數值相等(容許絕對誤差 tol)。"""
        try:
            ok = abs(float(got) - float(want)) <= tol
        except (TypeError, ValueError):
            ok = str(got) == str(want)
        return self._add(ok, f"{label} = {want}{unit} (±{tol:g})", f"實得 {got}{unit}")

    def expect_between(self, label, got, lo, hi, unit=""):
        ok = lo <= float(got) <= hi
        return self._add(ok, f"{label} 落在 [{lo:g}, {hi:g}]{unit}", f"實得 {got}{unit}")

    def expect_true(self, label, cond, detail=""):
        return self._add(bool(cond), label, detail)

    # ---- 報表 ----
    def report(self, title="驗證段"):
        npass = sum(1 for ok, _, _ in self.rows if ok is True)
        nfail = sum(1 for ok, _, _ in self.rows if ok is False)
        nskip = sum(1 for ok, _, _ in self.rows if ok is None)
        print(f"\n===== {title} =====")
        for ok, label, detail in self.rows:
            tag = "PASS" if ok is True else ("FAIL" if ok is False else "SKIP")
            print(f"  {tag}  {label:<58}{detail}")
        print(f"\n  合計 {len(self.rows)} 項:PASS {npass} / FAIL {nfail} / SKIP {nskip}")
        if nfail:
            print("  -> 有指令 ACK 了但沒有生效,或值被 STK 改寫。逐項檢查上面的 FAIL。")
        if nskip:
            print("  -> SKIP 表示 Connect 查不到該值,需要用物件模型或 GUI 另外確認。")
        return nfail == 0
tools/storage_vs_time.py
#!/usr/bin/env python3
# ============================================================
# storage_vs_time.py
#
# Analytic replacement for the SOLIS on-board data-recorder graph.
# SOLIS is not in the STK teaching edition, so instead of simulating
# the recorder we integrate it from STK ACCESS intervals:
#
#   * Imaging accesses  (Imager -> Wildfires)   FILL  the recorder
#     at the payload data rate while the sensor sees a fire.
#   * Downlink accesses (SBand  -> GroundStations) DRAIN the recorder
#     at the transmitter data rate while a ground station is in view.
#
#   recorder(t+dt) = clamp( recorder(t)
#                           + fill_rate  * (imaging active?)  * dt
#                           - drain_rate * (downlink active?) * dt ,
#                           0 , capacity )
#
# This is the STK-native, analytic answer to the tutorial's two
# findings ("storage fills up" / effect of lowering the imaging rate)
# WITHOUT needing SOLIS.
#
# INPUT: STK access intervals. Two ways to provide them:
#   (A) run get_access_intervals() live via COM (Windows + STK), or
#   (B) paste intervals into IMAGING_INTERVALS / DOWNLINK_INTERVALS
#       below (copy Start/Stop from an STK Access report).
#
# OUTPUT: storage_vs_time.png  (+ prints peak fill and overflow time)
# ============================================================

from datetime import datetime, timedelta
import re
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.dates as mdates

# ----------------------- CONFIG (from the tutorial) -----------------------
SCENARIO_START = "22 Jul 2020 16:00:00.000"
SCENARIO_STOP  = "22 Jul 2020 18:30:00.000"
STEP_SEC       = 1.0                      # integration step

# Payload (tutorial: Imager 5 Mbit/s = image every 2 s; FIREYEPosX 2.5 Mbit/s)
IMAGING_RATE_MBPS   = 5.0                 # fill rate while imaging
# SBand downlink (tutorial: 10 Mbit/s, 10% overhead -> ~9 Mbit/s effective)
DOWNLINK_RATE_MBPS  = 10.0 * 0.90
# Recorder (tutorial: 5 GB, starts 25% full)
CAPACITY_MBIT       = 5.0 * 8 * 1000      # 5 GB -> 40000 Mbit
INITIAL_FILL_FRAC   = 0.25

TIME_FMT = "%d %b %Y %H:%M:%S.%f"

# --- (B) Manual intervals: paste [("start","stop"), ...] in TIME_FMT ---
# Leave empty to use the report-file path (C) or COM path (A).
IMAGING_INTERVALS = [
    # ("22 Jul 2020 16:07:12.000", "22 Jul 2020 16:10:44.000"),
]
DOWNLINK_INTERVALS = [
    # ("22 Jul 2020 16:41:03.000", "22 Jul 2020 16:49:20.000"),
]

# --- (C) STK saved access reports from the Connect scenario ---
# These are the files written by FIREYE_Volcano_FullScenario_Connect.txt.
# Set to None to skip. Used when the manual lists above are empty.
IMAGING_REPORT_FILE  = "imaging_access.txt"    # or r"C:\STK_Reports\imaging_access.txt"
DOWNLINK_REPORT_FILE = "downlink_access.txt"   # or r"C:\STK_Reports\downlink_access.txt"

# ----------------------- (A) Live COM extraction --------------------------
def get_access_intervals(chain_path):
    """Pull [(start,stop),...] datetimes from a computed STK Chain via COM.
    Requires Windows + a running STK with the chain already computed."""
    import win32com.client
    stk  = win32com.client.GetActiveObject("STK12.Application")   # or STK13
    root = stk.Personality2
    chain = root.GetObjectFromPath(chain_path)
    dp = chain.DataProviders.Item("Complete Access").Exec(SCENARIO_START, SCENARIO_STOP)
    starts = dp.DataSets.GetDataSetByName("Start Time").GetValues()
    stops  = dp.DataSets.GetDataSetByName("Stop Time").GetValues()
    return [(str(s), str(e)) for s, e in zip(starts, stops)]

# ----------------------- helpers ------------------------------------------
def parse(t):
    t = t.strip()
    try:
        return datetime.strptime(t, TIME_FMT)
    except ValueError:
        return datetime.strptime(t, "%d %b %Y %H:%M:%S")

def active(now, intervals):
    for s, e in intervals:
        if s <= now <= e:
            return True
    return False

def to_dt_pairs(pairs):
    return [(parse(s), parse(e)) for s, e in pairs]

# STK date token like "1 Jan 2024 00:07:12.000" -> matches TIME_FMT / fallback.
_DATE_RE = re.compile(
    r"(\d{1,2}\s+[A-Za-z]{3}\s+\d{4}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)")

def parse_access_report(path):
    """Parse an STK saved 'Complete Access' report into [(start,stop),...].
    Robust to the report's header/columns: it grabs the first two STK date
    tokens on any data line (Start Time, Stop Time)."""
    pairs = []
    try:
        with open(path, "r", encoding="utf-8", errors="ignore") as f:
            for line in f:
                toks = _DATE_RE.findall(line)
                if len(toks) >= 2:
                    pairs.append((parse(toks[0]), parse(toks[1])))
    except FileNotFoundError:
        return None
    return pairs if pairs else None

# ----------------------- integrate the recorder ---------------------------
def integrate(imaging, downlink):
    t0, t1 = parse(SCENARIO_START), parse(SCENARIO_STOP)
    fill_rate  = IMAGING_RATE_MBPS            # Mbit/s
    drain_rate = DOWNLINK_RATE_MBPS           # Mbit/s
    rec = CAPACITY_MBIT * INITIAL_FILL_FRAC   # Mbit

    times, pct = [], []
    total_gen = 0.0
    total_down = 0.0
    overflow_at = None
    n = int((t1 - t0).total_seconds() / STEP_SEC) + 1
    for i in range(n):
        now = t0 + timedelta(seconds=i * STEP_SEC)
        img = active(now, imaging)
        dwn = active(now, downlink)
        if img:
            rec += fill_rate * STEP_SEC
            total_gen += fill_rate * STEP_SEC
        if dwn and rec > 0:
            drained = min(drain_rate * STEP_SEC, rec)
            rec -= drained
            total_down += drained
        if rec >= CAPACITY_MBIT:
            rec = CAPACITY_MBIT
            if overflow_at is None:
                overflow_at = now
        rec = max(rec, 0.0)
        times.append(now)
        pct.append(100.0 * rec / CAPACITY_MBIT)
    return times, pct, total_gen, total_down, overflow_at

# ----------------------- run ----------------------------------------------
def main():
    imaging = downlink = None
    # (B) manual intervals win if provided
    if IMAGING_INTERVALS or DOWNLINK_INTERVALS:
        imaging  = to_dt_pairs(IMAGING_INTERVALS)
        downlink = to_dt_pairs(DOWNLINK_INTERVALS)
    # (C) else parse the STK saved access reports from the Connect scenario
    if imaging is None and IMAGING_REPORT_FILE:
        imaging = parse_access_report(IMAGING_REPORT_FILE)
    if downlink is None and DOWNLINK_REPORT_FILE:
        downlink = parse_access_report(DOWNLINK_REPORT_FILE)
    if imaging is not None and downlink is not None:
        print(f"Loaded {len(imaging)} imaging + {len(downlink)} downlink "
              "intervals from STK access reports.")
    else:
        try:
            imaging  = to_dt_pairs(get_access_intervals("Chain/Imaging"))
            downlink = to_dt_pairs(get_access_intervals("Chain/Downlink"))
        except Exception as ex:
            print("No manual intervals and COM extraction failed:", ex)
            print("Paste intervals into IMAGING_INTERVALS / DOWNLINK_INTERVALS.")
            # demo fallback so the script still renders a figure:
            imaging  = to_dt_pairs([("22 Jul 2020 16:07:00.000", "22 Jul 2020 16:12:00.000"),
                                    ("22 Jul 2020 17:45:00.000", "22 Jul 2020 17:51:00.000")])
            downlink = to_dt_pairs([("22 Jul 2020 16:40:00.000", "22 Jul 2020 16:49:00.000"),
                                    ("22 Jul 2020 18:05:00.000", "22 Jul 2020 18:12:00.000")])

    times, pct, gen, down, overflow = integrate(imaging, downlink)

    print(f"Payload generated : {gen:,.0f} Mbit ({gen/8000:.2f} GB)")
    print(f"Downlinked        : {down:,.0f} Mbit ({down/8000:.2f} GB)")
    print(f"Net to recorder   : {gen-down:+,.0f} Mbit")
    print(f"Peak fill         : {max(pct):.1f} %")
    print(f"Overflow reached  : {overflow if overflow else 'no'}")

    fig, ax = plt.subplots(figsize=(11, 5))
    ax.plot(times, pct, color="black", lw=1.8, label="Recorder fill (%)")
    ax.axhline(100, color="red", ls="--", lw=1, label="Capacity")
    ax.axhline(INITIAL_FILL_FRAC*100, color="gray", ls=":", lw=1,
               label=f"Initial ({INITIAL_FILL_FRAC*100:.0f}%)")
    # shade imaging (fill) and downlink (drain) windows
    for s, e in imaging:
        ax.axvspan(s, e, color="tab:green", alpha=0.15)
    for s, e in downlink:
        ax.axvspan(s, e, color="tab:cyan", alpha=0.20)
    if overflow:
        ax.axvline(overflow, color="red", lw=1)
        ax.text(overflow, 50, " OVERFLOW", color="red", rotation=90, va="center")

    ax.set_ylim(0, 105)
    ax.set_ylabel("On-board data recorder fill (%)")
    ax.set_xlabel("Time (UTCG)")
    ax.set_title("FIREYE storage fill vs time (analytic, from STK access) "
                 f"| imaging {IMAGING_RATE_MBPS} Mbit/s, downlink {DOWNLINK_RATE_MBPS:.1f} Mbit/s")
    ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
    ax.legend(loc="upper left", fontsize=8)
    ax.grid(alpha=0.3)
    fig.tight_layout()
    fig.savefig("storage_vs_time.png", dpi=130)
    print("Wrote storage_vs_time.png")

if __name__ == "__main__":
    main()