附錄 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()