commit 2006f7b4a415a5e2bed8d4c964e0668307de0d14 Author: Shuming Liu Date: Tue Sep 22 10:48:18 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8e2e0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +data/*.db +data/*.json +data/uploads/ +__pycache__/ +*.pyc +.DS_Store diff --git a/2024级指标分析成绩基础数据_5次考试.xlsx b/2024级指标分析成绩基础数据_5次考试.xlsx new file mode 100644 index 0000000..4977a6e Binary files /dev/null and b/2024级指标分析成绩基础数据_5次考试.xlsx differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e44d59d --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# 航标 · 班级达线指标预估 + +B/S 系统:管理员上传历次大考总分,系统用卡尔曼滤波估计每个学生的稳定水平,模拟后续考试的年级排名,把达线概率加总后下达各班指标。下达人数是“按现有轨迹本来会到的人数”,班级工作是超越它。 + +## 启动 + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python3 -m app +``` + +浏览器打开 http://127.0.0.1:8765 + +默认账号:`admin` / `hangbiao2026`(请在 `app/config.py` 修改) + +## 成绩表要求 + +- Excel `.xlsx` +- 至少包含:学号或姓名、班级、多次考试总分 +- 建议包含:方向(物理/历史)、选科、学籍、异动 +- 物理/历史分表、艺体表、异动表都可以;系统会识别并在第二步让你勾选口径 + +同目录的 `2024级指标分析成绩基础数据_5次考试.xlsx` 可在首页一键演示。 + +## 四步分析 + +1. 数据体检:读到哪些人、哪些考试、分数尺度 +2. 口径与目标:算谁、年级总盘子、预估步长 +3. 滤波与回测:Q/R 含义,以及用历史考试验证准不准 +4. 下达指标:班级期望 / 下达 / 保底 / 冲刺,点班级看学生,点学生看轨迹 + +## 指标怎么理解 + +- **下达指标**:滤波期望取整,合计等于年级目标人数 +- **保底 / 冲刺**:模拟分布大约 20% / 80% 分位 +- **相对最近一次**:预估相对最近一场已在线人数的增减,用来找上升生和不稳的在线生 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..41be52c --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,11 @@ +import os + +import uvicorn + +if __name__ == "__main__": + uvicorn.run( + "app.main:app", + host="127.0.0.1", + port=int(os.environ.get("PORT", "8765")), + reload=os.environ.get("RELOAD", "1") != "0", + ) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..6a12aef --- /dev/null +++ b/app/config.py @@ -0,0 +1,14 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = BASE_DIR / "data" +UPLOAD_DIR = DATA_DIR / "uploads" +DB_PATH = DATA_DIR / "hangbiao.db" +SAMPLE_XLSX = BASE_DIR / "2024级指标分析成绩基础数据_5次考试.xlsx" + +SECRET_KEY = "hangbiao-dev-secret-change-me" +ADMIN_USER = "admin" +ADMIN_PASSWORD = "hangbiao2026" + +SESSION_MAX_AGE = 60 * 60 * 12 +MAX_UPLOAD_MB = 15 diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..1fcd67a --- /dev/null +++ b/app/db.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime +from pathlib import Path + +from .config import DB_PATH + + +def connect() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + conn = connect() + conn.execute( + """ + CREATE TABLE IF NOT EXISTS analyses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + filename TEXT, + created_at TEXT, + step INTEGER DEFAULT 1, + parsed_path TEXT, + settings_json TEXT, + result_path TEXT + ) + """ + ) + conn.commit() + conn.close() + + +def create_analysis(title: str, filename: str, parsed_path: str) -> int: + conn = connect() + cur = conn.execute( + """ + INSERT INTO analyses (title, filename, created_at, step, parsed_path) + VALUES (?, ?, ?, 1, ?) + """, + (title, filename, datetime.now().strftime("%Y-%m-%d %H:%M:%S"), parsed_path), + ) + conn.commit() + aid = cur.lastrowid + conn.close() + return int(aid) + + +def list_analyses() -> list[dict]: + conn = connect() + rows = conn.execute("SELECT * FROM analyses ORDER BY id DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_analysis(aid: int) -> dict | None: + conn = connect() + row = conn.execute("SELECT * FROM analyses WHERE id=?", (aid,)).fetchone() + conn.close() + return dict(row) if row else None + + +def update_analysis(aid: int, **fields) -> None: + if not fields: + return + keys = ", ".join(f"{k}=?" for k in fields) + conn = connect() + conn.execute(f"UPDATE analyses SET {keys} WHERE id=?", (*fields.values(), aid)) + conn.commit() + conn.close() + + +def load_json(path: str): + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def save_json(path: str, obj) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(json.dumps(obj, ensure_ascii=False), encoding="utf-8") diff --git a/app/excel_parse.py b/app/excel_parse.py new file mode 100644 index 0000000..9171b70 --- /dev/null +++ b/app/excel_parse.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import re +from io import BytesIO +from pathlib import Path + +from openpyxl import load_workbook + + +META_ALIASES = { + "sid": ("学号", "考号", "id", "学生id"), + "name": ("姓名", "学生姓名", "名字"), + "class_name": ("班级", "班", "行政班"), + "track": ("方向", "科类", "首选", "首选科目"), + "subjects": ("选科", "再选", "科目组合"), + "status": ("学籍", "学籍类型", "身份"), + "move": ("异动", "备注", "说明"), +} + +EXAM_HINT = re.compile( + r"(总分|期末|联考|月考|模考|开学|学考|诊断|调研|高考)", + re.I, +) +SKIP_SHEETS = ("说明", "readme", "目录") +ART_RE = re.compile(r"艺术|体育|艺体") +LEAVE_RE = re.compile(r"请假|出国|休学|国际|不在校|港澳|港籍.*假") + + +def _norm(v) -> str: + if v is None: + return "" + if isinstance(v, float) and v.is_integer(): + return str(int(v)) + return str(v).strip() + + +def _is_exam_header(text: str) -> bool: + t = text.strip() + if not t or t in {"考试次数"}: + return False + if t.endswith("-总分") or t.endswith("总分"): + return True + return bool(EXAM_HINT.search(t)) and "次数" not in t + + +def _map_meta(header: str) -> str | None: + h = header.strip().lower() + for key, aliases in META_ALIASES.items(): + for a in aliases: + if h == a.lower() or h.replace(" ", "") == a.lower(): + return key + return None + + +def _sheet_kind(name: str) -> str: + n = name.lower() + if any(x in n for x in ("艺体", "艺术", "体育")): + return "art" + if "异动" in n: + return "leave" + if "历史" in n: + return "history" + if "物理" in n: + return "physics" + if n in ("全体", "全部", "汇总"): + return "all" + return "other" + + +def parse_workbook(path: Path | BytesIO) -> dict: + wb = load_workbook(path, data_only=True, read_only=True) + sheets = [] + students: list[dict] = [] + exam_order: list[str] = [] + warnings: list[str] = [] + + for ws in wb.worksheets: + kind = _sheet_kind(ws.title) + rows = ws.iter_rows(values_only=True) + try: + header_row = next(rows) + except StopIteration: + continue + headers = [_norm(h) for h in header_row] + if not any(headers): + continue + meta_idx = {} + exam_idx = {} + for i, h in enumerate(headers): + if not h: + continue + mk = _map_meta(h) + if mk: + meta_idx[mk] = i + continue + if _is_exam_header(h): + exam_idx[h] = i + if h not in exam_order: + exam_order.append(h) + if "sid" not in meta_idx and "name" not in meta_idx: + warnings.append(f"工作表「{ws.title}」未识别到学号/姓名,已跳过") + continue + + count = 0 + for raw in rows: + if raw is None or all(v is None or str(v).strip() == "" for v in raw): + continue + rec = { + "sid": _norm(raw[meta_idx["sid"]]) if "sid" in meta_idx else "", + "name": _norm(raw[meta_idx["name"]]) if "name" in meta_idx else "", + "class_name": _norm(raw[meta_idx["class_name"]]) if "class_name" in meta_idx else "未分班", + "track": _norm(raw[meta_idx["track"]]) if "track" in meta_idx else "", + "subjects": _norm(raw[meta_idx["subjects"]]) if "subjects" in meta_idx else "", + "status": _norm(raw[meta_idx["status"]]) if "status" in meta_idx else "", + "move": _norm(raw[meta_idx["move"]]) if "move" in meta_idx else "", + "sheet": ws.title, + "sheet_kind": kind, + "scores": {}, + } + if not rec["sid"] and not rec["name"]: + continue + if not rec["sid"]: + rec["sid"] = f"{ws.title}-{rec['name']}-{count}" + if kind == "physics" and not rec["track"]: + rec["track"] = "物理方向" + if kind == "history" and not rec["track"]: + rec["track"] = "历史方向" + if not rec["track"]: + if "物理" in rec["subjects"]: + rec["track"] = "物理方向" + elif "历史" in rec["subjects"]: + rec["track"] = "历史方向" + for exam, idx in exam_idx.items(): + if idx >= len(raw): + continue + val = raw[idx] + if val is None or str(val).strip() in ("", "缺考", "—", "-", "/"): + rec["scores"][exam] = None + continue + try: + rec["scores"][exam] = float(val) + except (TypeError, ValueError): + rec["scores"][exam] = None + rec["art"] = bool(ART_RE.search(rec["move"]) or ART_RE.search(ws.title)) + rec["leave"] = bool(LEAVE_RE.search(rec["move"])) + students.append(rec) + count += 1 + sheets.append( + { + "name": ws.title, + "kind": kind, + "rows": count, + "exams": list(exam_idx.keys()), + } + ) + + wb.close() + + # Prefer specialized sheets over 全体 to avoid duplicates + has_split = any(s["kind"] in ("physics", "history") for s in sheets) + if has_split: + kept = [] + skipped_all = 0 + for s in students: + if s["sheet_kind"] == "all": + skipped_all += 1 + continue + kept.append(s) + students = kept + if skipped_all: + warnings.append(f"已忽略「全体」表中的 {skipped_all} 行,避免与物理/历史表重复") + + # de-duplicate by sid, keep first non-all + seen = {} + uniq = [] + dups = 0 + for s in students: + if s["sid"] in seen: + dups += 1 + continue + seen[s["sid"]] = True + uniq.append(s) + students = uniq + if dups: + warnings.append(f"按学号去重 {dups} 条") + + if not exam_order: + warnings.append("未识别到考试成绩列,请确认表头含「总分」或「期末/联考/月考」") + + return { + "sheets": sheets, + "students": students, + "exams": exam_order, + "warnings": warnings, + } + + +def summarize_parse(parsed: dict) -> dict: + students = parsed["students"] + exams = parsed["exams"] + tracks = {} + classes = {} + status = {} + art = leave = 0 + exam_present = {e: 0 for e in exams} + for s in students: + tracks[s["track"] or "未分方向"] = tracks.get(s["track"] or "未分方向", 0) + 1 + classes[s["class_name"]] = classes.get(s["class_name"], 0) + 1 + status[s["status"] or "未填"] = status.get(s["status"] or "未填", 0) + 1 + if s["art"]: + art += 1 + if s["leave"]: + leave += 1 + for e in exams: + if s["scores"].get(e) is not None: + exam_present[e] += 1 + score_scale = [] + for e in exams: + vals = [s["scores"][e] for s in students if s["scores"].get(e) is not None] + if not vals: + continue + score_scale.append( + { + "exam": e, + "n": len(vals), + "min": round(min(vals), 1), + "max": round(max(vals), 1), + "mean": round(sum(vals) / len(vals), 1), + } + ) + return { + "n": len(students), + "n_class": len(classes), + "tracks": tracks, + "classes": dict(sorted(classes.items(), key=lambda kv: _class_key(kv[0]))), + "status": status, + "art": art, + "leave": leave, + "exam_present": exam_present, + "score_scale": score_scale, + "sheets": parsed["sheets"], + "exams": exams, + "warnings": parsed["warnings"], + } + + +def _class_key(name: str): + m = re.search(r"\d+", str(name)) + return (int(m.group()) if m else 999, str(name)) + + +def class_sort_key(name: str): + return _class_key(name) diff --git a/app/export_xlsx.py b/app/export_xlsx.py new file mode 100644 index 0000000..2a3c921 --- /dev/null +++ b/app/export_xlsx.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill, Border, Side + + +def build_export(result: dict) -> bytes: + wb = Workbook() + thin = Border( + left=Side(style="thin", color="D9D1C3"), + right=Side(style="thin", color="D9D1C3"), + top=Side(style="thin", color="D9D1C3"), + bottom=Side(style="thin", color="D9D1C3"), + ) + head = PatternFill("solid", fgColor="12233D") + head_font = Font(color="F4EFE6", bold=True) + wrap = Alignment(wrap_text=True, vertical="center") + + first = True + for g in result["groups"]: + for t in g["targets"]: + title = f"{g['group']}-{t['name']}"[:31] + ws = wb.active if first else wb.create_sheet(title) + if first: + ws.title = title + first = False + ws.append( + ["班级", "测算人数", "最近一次在线", "滤波期望", "下达指标", "保底下界", "冲刺上界", "相对最近一次"] + ) + for cell in ws[1]: + cell.fill = head + cell.font = head_font + for row in t["classes"]: + ws.append( + [ + row["class_name"], + row["n"], + row["current"], + row["expected"], + row["assigned"], + row["floor"], + row["stretch"], + row["delta"], + ] + ) + ws.append([]) + ws.append(["说明:下达指标为预估中位水平,班级应努力超越该人数。保底/冲刺来自模拟分布的约20%/80%分位。"]) + + ws = wb.create_sheet("学生明细") + exams = result["exams"] + headers = ["学号", "姓名", "班级", "方向", "学籍", "选科"] + headers += [f"{e}-排名" for e in exams] + # collect target names + tcols = [] + for g in result["groups"]: + for t in g["targets"]: + tcols.append((g["group"], t["id"], f"{g['group']}-{t['name']}概率")) + headers += [c[2] for c in tcols] + headers += ["滤波水平", "预测方差"] + ws.append(headers) + for cell in ws[1]: + cell.fill = head + cell.font = head_font + cell.alignment = wrap + for s in result["students"]: + row = [s["sid"], s["name"], s["class_name"], s["group"], s.get("status") or "", s.get("subjects") or ""] + ranks = s.get("ranks") or [] + for i, _e in enumerate(exams): + row.append(ranks[i] if i < len(ranks) else None) + for gname, tid, _ in tcols: + if s["group"] == gname: + row.append((s.get("probs") or {}).get(tid)) + else: + row.append(None) + row.append(s.get("pred_mean")) + row.append(s.get("pred_var")) + ws.append(row) + + bio = BytesIO() + wb.save(bio) + return bio.getvalue() diff --git a/app/indicators.py b/app/indicators.py new file mode 100644 index 0000000..909b356 --- /dev/null +++ b/app/indicators.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from collections import defaultdict + +from .excel_parse import class_sort_key +from .kalman import ( + fit_qr, + hamilton, + holdout_hits, + kalman_filter, + monte_carlo, + ranks_desc, + zscores, +) + + +TRACK_DEFAULTS = { + "物理": [{"id": "t88", "name": "特控线上", "k": 88}], + "历史": [ + {"id": "top5", "name": "尖子生", "k": 5}, + {"id": "top12", "name": "600分以上", "k": 12}, + {"id": "top52", "name": "特控线上", "k": 52}, + ], +} + + +def infer_track_group(track: str) -> str: + t = track or "" + if "历史" in t: + return "历史" + if "物理" in t: + return "物理" + return t or "未分方向" + + +def default_targets(groups: list[str]) -> dict[str, list[dict]]: + out = {} + for g in groups: + if g in TRACK_DEFAULTS: + out[g] = [dict(x) for x in TRACK_DEFAULTS[g]] + else: + out[g] = [{"id": "top20", "name": "年级前20%", "k": 20}] + return out + + +def select_students(parsed: dict, settings: dict) -> list[dict]: + include_art = settings.get("include_art", False) + include_leave = settings.get("include_leave", False) + include_mingyuan = settings.get("include_mingyuan", True) + include_other = settings.get("include_other", True) + exams = settings.get("exams") or parsed["exams"] + selected = [] + for s in parsed["students"]: + if s["art"] and not include_art: + continue + if s["leave"] and not include_leave: + continue + st = s["status"] or "在籍" + if st == "明远" and not include_mingyuan: + continue + if st not in ("在籍", "明远", "") and not include_other: + continue + scores = {e: s["scores"].get(e) for e in exams} + if all(v is None for v in scores.values()): + continue + row = dict(s) + row["scores"] = scores + row["group"] = infer_track_group(s["track"]) + selected.append(row) + return selected + + +def _forecast_horizon(q: float, p: float, horizon: int) -> float: + return p + max(1, horizon) * q + + +def analyze(parsed: dict, settings: dict) -> dict: + exams = settings.get("exams") or parsed["exams"] + if not exams: + raise ValueError("没有可用的考试列") + horizon = int(settings.get("horizon", 1)) + n_sim = int(settings.get("n_sim", 600)) + selected = select_students(parsed, settings) + groups = sorted({s["group"] for s in selected}) + targets = settings.get("targets") or default_targets(groups) + + # fit Q,R per group on z-scores + group_models = {} + for g in groups: + members = [s for s in selected if s["group"] == g] + z_by_exam = {} + for e in exams: + z_by_exam[e] = zscores([s["scores"].get(e) for s in members]) + for i, s in enumerate(members): + s["z"] = [z_by_exam[e][i] for e in exams] + s["ranks"] = [] + for e in exams: + rk = ranks_desc([s["scores"].get(e) for s in members]) + for i, s in enumerate(members): + s["ranks"].append(rk[i]) + series = [s["z"] for s in members] + q, r, rmse = fit_qr(series) + group_models[g] = {"q": q, "r": r, "rmse": rmse, "n": len(members)} + for s in members: + fr = kalman_filter(s["z"], q, r) + s["kf_x"] = fr.x + s["kf_p"] = fr.p + s["kf_xs"] = fr.xs + s["kf_ps"] = fr.ps + if fr.x is None: + s["pred_mean"] = None + s["pred_var"] = None + else: + s["pred_mean"] = fr.x + s["pred_var"] = _forecast_horizon(q, fr.p if fr.p is not None else r, horizon) + + results = [] + for g in groups: + members = [s for s in selected if s["group"] == g] + g_targets = targets.get(g) or default_targets([g])[g] + model = group_models[g] + eligible = [s for s in members if s["pred_mean"] is not None] + last_ranks = [s["ranks"][-1] if s["ranks"] else None for s in members] + holdouts = [] + if len(exams) >= 3: + series = [s["z"] for s in members] + true_ranks = [s["ranks"][-1] if s["ranks"] else None for s in members] + last_train_ranks = [s["ranks"][-2] if len(s["ranks"]) >= 2 else None for s in members] + for t in g_targets: + k = min(int(t["k"]), max(1, sum(1 for r in true_ranks if r is not None))) + h = holdout_hits( + series, + last_train_ranks, + true_ranks, + model["q"], + model["r"], + k, + ) + h["target_id"] = t["id"] + h["target_name"] = t["name"] + h["k"] = k + holdouts.append(h) + + target_blocks = [] + for t in g_targets: + k = int(t["k"]) + k = max(1, min(k, len(eligible))) + means = [s["pred_mean"] for s in eligible] + variances = [s["pred_var"] for s in eligible] + class_ids = [s["class_name"] for s in eligible] + probs, class_q = monte_carlo(means, variances, class_ids, k, n_sim=n_sim) + for s, p in zip(eligible, probs): + s.setdefault("probs", {})[t["id"]] = p + # current on-line: last exam rank <= k + class_now = defaultdict(int) + class_n = defaultdict(int) + class_exp = defaultdict(float) + class_mingyuan_exp = defaultdict(float) + for s in members: + class_n[s["class_name"]] += 1 + for s in eligible: + class_exp[s["class_name"]] += s["probs"][t["id"]] + if s.get("status") == "明远": + class_mingyuan_exp[s["class_name"]] += s["probs"][t["id"]] + if s["ranks"] and s["ranks"][-1] is not None and s["ranks"][-1] <= k: + class_now[s["class_name"]] += 1 + class_names = sorted(class_n.keys(), key=class_sort_key) + expected = [class_exp[c] for c in class_names] + assigned = hamilton(expected, k) + rows = [] + for c, a in zip(class_names, assigned): + qtl = class_q.get(c, {"p20": 0, "p50": 0, "p80": 0, "mean": 0}) + rows.append( + { + "class_name": c, + "n": class_n[c], + "current": class_now[c], + "expected": round(class_exp[c], 2), + "assigned": a, + "floor": int(qtl["p20"]), + "median": int(qtl["p50"]), + "stretch": int(qtl["p80"]), + "delta": a - class_now[c], + "mingyuan_expected": round(class_mingyuan_exp[c], 2), + } + ) + critical = [] + for s in eligible: + p = s["probs"][t["id"]] + if 0.2 <= p <= 0.8: + critical.append( + { + "sid": s["sid"], + "name": s["name"], + "class_name": s["class_name"], + "p": round(p, 3), + "last_rank": s["ranks"][-1] if s["ranks"] else None, + "status": s.get("status") or "", + } + ) + critical.sort(key=lambda x: -x["p"]) + target_blocks.append( + { + "id": t["id"], + "name": t["name"], + "k": k, + "classes": rows, + "critical_n": len(critical), + "critical": critical[:80], + "sum_expected": round(sum(expected), 2), + } + ) + + results.append( + { + "group": g, + "n": len(members), + "model": { + "q": model["q"], + "r": model["r"], + "rmse": round(model["rmse"], 3) if model["rmse"] is not None else None, + "horizon": horizon, + "n_sim": n_sim, + }, + "holdout": holdouts, + "targets": target_blocks, + } + ) + + student_views = [] + for s in selected: + student_views.append( + { + "sid": s["sid"], + "name": s["name"], + "class_name": s["class_name"], + "group": s["group"], + "track": s["track"], + "subjects": s["subjects"], + "status": s.get("status") or "", + "move": s.get("move") or "", + "art": s["art"], + "leave": s["leave"], + "scores": s["scores"], + "z": s.get("z"), + "ranks": s.get("ranks"), + "kf_xs": s.get("kf_xs"), + "kf_ps": s.get("kf_ps"), + "pred_mean": s.get("pred_mean"), + "pred_var": s.get("pred_var"), + "probs": {k: round(v, 4) for k, v in (s.get("probs") or {}).items()}, + } + ) + + return { + "exams": exams, + "horizon": horizon, + "n_selected": len(selected), + "n_total": len(parsed["students"]), + "groups": results, + "students": student_views, + } + + +def class_comment(row: dict, target_name: str) -> str: + assigned = row["assigned"] + current = row["current"] + expected = row["expected"] + floor = row["floor"] + stretch = row["stretch"] + name = row["class_name"] + bits = [ + f"{name}班{target_name}下达 {assigned} 人(滤波期望 {expected:.1f})。" + ] + if assigned > current: + bits.append(f"比最近一次在线人数多 {assigned - current} 人,班级里有上升轨迹的临界生,需要把潜力兑现。") + elif assigned < current: + bits.append(f"比最近一次在线人数少 {current - assigned} 人,说明部分当前在线学生轨迹不稳,重点是保住临界生。") + else: + bits.append("与最近一次在线人数持平,按现有轨迹属于维持性目标。") + bits.append(f"模拟分布大约在 {floor}–{stretch} 人之间,超越下达人数才算跑赢预估。") + return "".join(bits) diff --git a/app/kalman.py b/app/kalman.py new file mode 100644 index 0000000..e299d0c --- /dev/null +++ b/app/kalman.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import math +import random +from dataclasses import dataclass + + +def mean_std(values: list[float]) -> tuple[float, float]: + n = len(values) + if n == 0: + return 0.0, 1.0 + mean = sum(values) / n + if n == 1: + return mean, 1.0 + var = sum((x - mean) ** 2 for x in values) / (n - 1) + return mean, math.sqrt(var) if var > 0 else 1.0 + + +def zscores(values: list[float | None]) -> list[float | None]: + present = [v for v in values if v is not None] + mean, std = mean_std(present) + out: list[float | None] = [] + for v in values: + if v is None: + out.append(None) + else: + out.append((v - mean) / std) + return out + + +def ranks_desc(values: list[float | None]) -> list[int | None]: + indexed = [(i, v) for i, v in enumerate(values) if v is not None] + indexed.sort(key=lambda iv: -iv[1]) + out: list[int | None] = [None] * len(values) + for rank, (i, _) in enumerate(indexed, 1): + out[i] = rank + return out + + +@dataclass +class FilterResult: + x: float | None + p: float | None + xs: list[float | None] + ps: list[float | None] + + +def kalman_filter(zs: list[float | None], q: float, r: float) -> FilterResult: + x = None + p = None + xs: list[float | None] = [] + ps: list[float | None] = [] + for z in zs: + if x is None: + if z is None: + xs.append(None) + ps.append(None) + continue + x, p = z, r + xs.append(x) + ps.append(p) + continue + x_pred = x + p_pred = p + q + if z is None: + x, p = x_pred, p_pred + xs.append(x) + ps.append(p) + continue + k = p_pred / (p_pred + r) + x = x_pred + k * (z - x_pred) + p = (1 - k) * p_pred + xs.append(x) + ps.append(p) + return FilterResult(x=x, p=p, xs=xs, ps=ps) + + +def one_step_rmse(series_list: list[list[float | None]], q: float, r: float) -> float | None: + se = 0.0 + n = 0 + for zs in series_list: + x = None + p = None + for z in zs: + if x is None: + if z is None: + continue + x, p = z, r + continue + x_pred = x + p_pred = p + q + if z is None: + x, p = x_pred, p_pred + continue + se += (x_pred - z) ** 2 + n += 1 + k = p_pred / (p_pred + r) + x = x_pred + k * (z - x_pred) + p = (1 - k) * p_pred + if n == 0: + return None + return math.sqrt(se / n) + + +def fit_qr(series_list: list[list[float | None]]) -> tuple[float, float, float]: + usable = [s for s in series_list if sum(1 for z in s if z is not None) >= 2] + best = (1e9, 0.08, 0.25) + for q in (0.03, 0.05, 0.08, 0.10, 0.15, 0.22, 0.30): + for r in (0.12, 0.18, 0.25, 0.32, 0.40, 0.55): + rmse = one_step_rmse(usable, q, r) + if rmse is not None and rmse < best[0]: + best = (rmse, q, r) + return best[1], best[2], best[0] + + +def holdout_hits( + series_list: list[list[float | None]], + last_ranks: list[int | None], + true_ranks: list[int | None], + q: float, + r: float, + target_k: int, +) -> dict: + preds: list[tuple[int, float]] = [] + last_pred: list[tuple[int, int]] = [] + for i, zs in enumerate(series_list): + if true_ranks[i] is None: + continue + train = zs[:-1] + result = kalman_filter(train, q, r) + if result.x is None: + continue + preds.append((i, result.x)) + if last_ranks[i] is not None: + last_pred.append((i, last_ranks[i])) + preds.sort(key=lambda t: -t[1]) + kf_set = {i for i, _ in preds[:target_k]} + last_pred.sort(key=lambda t: t[1]) + last_set = {i for i, _ in last_pred[:target_k]} + actual = {i for i, rk in enumerate(true_ranks) if rk is not None and rk <= target_k} + kf_hit = len(kf_set & actual) + last_hit = len(last_set & actual) + return { + "n_actual": len(actual), + "kf_hit": kf_hit, + "last_hit": last_hit, + "kf_precision": kf_hit / target_k if target_k else 0, + "last_precision": last_hit / target_k if target_k else 0, + } + + +def monte_carlo( + means: list[float], + variances: list[float], + class_ids: list[str], + target_k: int, + n_sim: int = 600, + seed: int = 42, +) -> tuple[list[float], dict[str, dict]]: + rng = random.Random(seed) + n = len(means) + hits = [0] * n + sigmas = [math.sqrt(max(v, 1e-8)) for v in variances] + unique = list(dict.fromkeys(class_ids)) + buckets = {c: [] for c in unique} + for _ in range(n_sim): + zs = [means[i] + sigmas[i] * rng.gauss(0, 1) for i in range(n)] + order = sorted(range(n), key=lambda i: zs[i], reverse=True) + counts = {c: 0 for c in unique} + for i in order[:target_k]: + hits[i] += 1 + counts[class_ids[i]] += 1 + for c, v in counts.items(): + buckets[c].append(v) + out = {} + for c, arr in buckets.items(): + arr.sort() + m = n_sim + out[c] = { + "p20": arr[max(0, int(0.20 * m) - 1)], + "p50": arr[max(0, int(0.50 * m) - 1)], + "p80": arr[max(0, int(0.80 * m) - 1)], + "mean": sum(arr) / m, + } + return [h / n_sim for h in hits], out + + +def hamilton(values: list[float], total: int) -> list[int]: + if total <= 0 or not values: + return [0] * len(values) + s = sum(values) + if s <= 0: + out = [0] * len(values) + return out + scaled = [v * total / s for v in values] + floors = [int(math.floor(v + 1e-9)) for v in scaled] + remain = total - sum(floors) + order = sorted( + range(len(scaled)), + key=lambda i: (scaled[i] - floors[i], scaled[i]), + reverse=True, + ) + for i in order[: max(0, remain)]: + floors[i] += 1 + return floors diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..3f7d76f --- /dev/null +++ b/app/main.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import json +import shutil +import uuid +from pathlib import Path +from urllib.parse import quote + +from fastapi import FastAPI, File, Form, Request, UploadFile +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware + +from .config import ( + ADMIN_PASSWORD, + ADMIN_USER, + DATA_DIR, + MAX_UPLOAD_MB, + SAMPLE_XLSX, + SECRET_KEY, + UPLOAD_DIR, +) +from .db import ( + create_analysis, + get_analysis, + init_db, + list_analyses, + load_json, + save_json, + update_analysis, +) +from .excel_parse import parse_workbook, summarize_parse +from .export_xlsx import build_export +from .indicators import analyze, class_comment, default_targets, infer_track_group, select_students + +app = FastAPI(title="航标 · 班级达线指标预估") +app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, max_age=60 * 60 * 12) +app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") +templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) +templates.env.globals["class_comment"] = class_comment + + +def render(request: Request, name: str, **ctx): + ctx["request"] = request + ctx.setdefault("user", current_user(request)) + return templates.TemplateResponse(request, name, ctx) + + +@app.on_event("startup") +def startup(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + init_db() + + +def current_user(request: Request) -> str | None: + return request.session.get("user") + + +def require_login(request: Request): + if not current_user(request): + return RedirectResponse("/login", status_code=303) + return None + + +@app.get("/login", response_class=HTMLResponse) +def login_page(request: Request): + if current_user(request): + return RedirectResponse("/", status_code=303) + return render(request, "login.html", error=None) + + +@app.post("/login", response_class=HTMLResponse) +def login_submit(request: Request, username: str = Form(...), password: str = Form(...)): + if username.strip() == ADMIN_USER and password == ADMIN_PASSWORD: + request.session["user"] = username.strip() + return RedirectResponse("/", status_code=303) + return render(request, "login.html", error="账号或密码不正确") + + +@app.post("/logout") +def logout(request: Request): + request.session.clear() + return RedirectResponse("/login", status_code=303) + + +@app.get("/", response_class=HTMLResponse) +def home(request: Request): + if (redir := require_login(request)): + return redir + items = list_analyses() + return render(request, "home.html", items=items, has_sample=SAMPLE_XLSX.exists()) + + +def _store_parsed(title: str, filename: str, source: Path) -> int: + parsed = parse_workbook(source) + summary = summarize_parse(parsed) + token = uuid.uuid4().hex[:10] + parsed_path = str(DATA_DIR / f"parsed_{token}.json") + # keep a lighter parsed blob + save_json(parsed_path, parsed) + aid = create_analysis(title, filename, parsed_path) + update_analysis(aid, settings_json=json.dumps({"summary": summary}, ensure_ascii=False)) + return aid + + +@app.post("/upload") +async def upload(request: Request, file: UploadFile = File(...)): + if (redir := require_login(request)): + return redir + raw = await file.read() + if len(raw) > MAX_UPLOAD_MB * 1024 * 1024: + return HTMLResponse("文件过大,请压缩到 15MB 以内。", status_code=400) + suffix = Path(file.filename or "scores.xlsx").suffix.lower() + if suffix not in (".xlsx", ".xlsm"): + return HTMLResponse("请上传 .xlsx 成绩表。", status_code=400) + dest = UPLOAD_DIR / f"{uuid.uuid4().hex}{suffix}" + dest.write_bytes(raw) + aid = _store_parsed(Path(file.filename).stem, file.filename or dest.name, dest) + return RedirectResponse(f"/analysis/{aid}?step=1", status_code=303) + + +@app.post("/demo") +def demo(request: Request): + if (redir := require_login(request)): + return redir + if not SAMPLE_XLSX.exists(): + return HTMLResponse("未找到示例成绩表。", status_code=404) + dest = UPLOAD_DIR / f"{uuid.uuid4().hex}.xlsx" + shutil.copy2(SAMPLE_XLSX, dest) + aid = _store_parsed("2024级示例数据", SAMPLE_XLSX.name, dest) + return RedirectResponse(f"/analysis/{aid}?step=1", status_code=303) + + +def _settings_from(parsed: dict, stored: dict | None) -> dict: + summary = summarize_parse(parsed) + groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]}) + base = { + "exams": parsed["exams"], + "include_art": False, + "include_leave": False, + "include_mingyuan": True, + "include_other": True, + "horizon": 1, + "targets": default_targets(groups), + } + if stored: + for k in ("exams", "include_art", "include_leave", "include_mingyuan", "include_other", "horizon", "targets"): + if k in stored: + base[k] = stored[k] + base["summary"] = summary + base["groups"] = groups + return base + + +@app.get("/analysis/{aid}", response_class=HTMLResponse) +def analysis_page(request: Request, aid: int, step: int = 1): + if (redir := require_login(request)): + return redir + item = get_analysis(aid) + if not item: + return HTMLResponse("分析不存在", status_code=404) + parsed = load_json(item["parsed_path"]) + stored_settings = json.loads(item["settings_json"] or "{}") + settings = _settings_from(parsed, stored_settings) + result = load_json(item["result_path"]) if item.get("result_path") else None + preview_n = None + if step == 2: + preview_n = len(select_students(parsed, settings)) + return render( + request, + "analysis.html", + item=item, + step=step, + settings=settings, + summary=settings["summary"], + result=result, + preview_n=preview_n, + ) + + +@app.post("/analysis/{aid}/settings") +async def save_settings(request: Request, aid: int): + if (redir := require_login(request)): + return redir + item = get_analysis(aid) + if not item: + return HTMLResponse("分析不存在", status_code=404) + parsed = load_json(item["parsed_path"]) + form = await request.form() + exams = form.getlist("exams") + if not exams: + exams = parsed["exams"] + groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]}) + targets = {} + for g in groups: + names = form.getlist(f"t_name_{g}") + ks = form.getlist(f"t_k_{g}") + ids = form.getlist(f"t_id_{g}") + rows = [] + for i, name in enumerate(names): + k_raw = ks[i] if i < len(ks) else "0" + try: + k = int(float(k_raw)) + except ValueError: + k = 0 + tid = ids[i] if i < len(ids) and ids[i] else f"t{i+1}" + if name.strip() and k > 0: + rows.append({"id": tid, "name": name.strip(), "k": k}) + targets[g] = rows or default_targets([g])[g] + settings = { + "exams": list(exams), + "include_art": form.get("include_art") == "on", + "include_leave": form.get("include_leave") == "on", + "include_mingyuan": form.get("include_mingyuan") == "on", + "include_other": form.get("include_other") == "on", + "horizon": int(form.get("horizon") or 1), + "targets": targets, + } + update_analysis(aid, settings_json=json.dumps(settings, ensure_ascii=False), step=2) + action = form.get("action") or "next" + if action == "compute": + result = analyze(parsed, settings) + token = uuid.uuid4().hex[:10] + result_path = str(DATA_DIR / f"result_{token}.json") + save_json(result_path, result) + update_analysis(aid, result_path=result_path, step=3) + return RedirectResponse(f"/analysis/{aid}?step=3", status_code=303) + return RedirectResponse(f"/analysis/{aid}?step=2", status_code=303) + + +@app.get("/analysis/{aid}/api/student/{sid}") +def student_api(request: Request, aid: int, sid: str): + if not current_user(request): + return Response("unauthorized", status_code=401) + item = get_analysis(aid) + if not item or not item.get("result_path"): + return Response("not found", status_code=404) + result = load_json(item["result_path"]) + for s in result["students"]: + if str(s["sid"]) == str(sid): + return s + return Response("not found", status_code=404) + + +@app.get("/analysis/{aid}/api/class") +def class_api(request: Request, aid: int, group: str, target: str, class_name: str): + if not current_user(request): + return Response("unauthorized", status_code=401) + item = get_analysis(aid) + if not item or not item.get("result_path"): + return Response("not found", status_code=404) + result = load_json(item["result_path"]) + rows = [ + s + for s in result["students"] + if s["group"] == group and s["class_name"] == class_name + ] + rows.sort(key=lambda s: -((s.get("probs") or {}).get(target) or 0)) + return {"exams": result["exams"], "students": rows[:80], "n": len(rows)} + + +@app.get("/analysis/{aid}/export") +def export_analysis(request: Request, aid: int): + if (redir := require_login(request)): + return redir + item = get_analysis(aid) + if not item or not item.get("result_path"): + return HTMLResponse("请先完成测算", status_code=400) + result = load_json(item["result_path"]) + data = build_export(result) + ascii_name = f"hangbiao-{aid}.xlsx" + star = quote(f"{item['title']}-下达指标.xlsx") + return Response( + content=data, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={ascii_name}; filename*=UTF-8''{star}"}, + ) + + +# Jinja 3 / FastAPI 0.115+ TemplateResponse signature is (request, name, context) +# Keep compatibility if older: handled by FastAPI version we pin. diff --git a/app/static/css/app.css b/app/static/css/app.css new file mode 100644 index 0000000..fc852cf --- /dev/null +++ b/app/static/css/app.css @@ -0,0 +1,149 @@ +:root { + --ink: #12233d; + --ink-soft: #3a4a63; + --paper: #f3eee4; + --card: #fffdf8; + --line: #d8cfbf; + --teal: #1c6f63; + --teal-2: #2a9a86; + --amber: #c47b17; + --rose: #b44a4a; + --good: #2f7d4a; + --shadow: 0 18px 40px rgba(18, 35, 61, 0.08); + --font: "Avenir Next", "Segoe UI", "PingFang SC", "Hiragino Sans GB", sans-serif; +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: var(--font); + color: var(--ink); + background: + radial-gradient(1200px 500px at -10% -20%, #e7f3ee 0%, transparent 50%), + radial-gradient(900px 400px at 110% 0%, #f8e9d2 0%, transparent 46%), + var(--paper); + min-height: 100vh; +} +a { color: var(--teal); } +.topbar { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 28px; + border-bottom: 1px solid var(--line); + background: rgba(255,253,248,0.86); + backdrop-filter: blur(10px); + position: sticky; top: 0; z-index: 10; +} +.brand { display: flex; gap: 12px; align-items: center; text-decoration: none; color: var(--ink); } +.mark { + width: 36px; height: 36px; border-radius: 50%; + border: 2px solid var(--ink); + display: grid; place-items: center; + font-size: 11px; letter-spacing: 0.08em; +} +.brand b { display: block; font-size: 16px; } +.brand span { display: block; font-size: 12px; color: var(--ink-soft); font-weight: 400; } +.topbar form { margin: 0; } +.ghost, .btn, .primary { + border: 1px solid var(--ink); background: transparent; color: var(--ink); + padding: 8px 14px; border-radius: 999px; cursor: pointer; font: inherit; + text-decoration: none; display: inline-flex; align-items: center; gap: 8px; +} +.primary { background: var(--ink); color: var(--paper); } +.btn.teal { background: var(--teal); border-color: var(--teal); color: white; } +.wrap { width: min(1180px, calc(100% - 40px)); margin: 28px auto 64px; } +.hero, .card, .login-card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 22px; + box-shadow: var(--shadow); +} +.hero { padding: 28px; display: grid; grid-template-columns: 1.4fr 1fr; gap: 24px; } +.hero h1 { margin: 0 0 8px; font-size: 30px; letter-spacing: 0.02em; } +.hero p { margin: 0; color: var(--ink-soft); line-height: 1.7; } +.drop { + border: 1.5px dashed var(--teal); + border-radius: 18px; padding: 22px; background: #f4fbf8; +} +.drop h3 { margin: 0 0 8px; } +.muted { color: var(--ink-soft); font-size: 13px; line-height: 1.6; } +.list { margin-top: 22px; display: grid; gap: 10px; } +.item { + background: var(--card); border: 1px solid var(--line); border-radius: 14px; + padding: 14px 16px; display: flex; justify-content: space-between; align-items: center; +} +.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: 24px; } +.login-card { width: min(440px, 100%); padding: 32px; } +.login-card h1 { margin: 8px 0 4px; } +label { display: block; font-size: 13px; margin: 14px 0 6px; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=number], select { + width: 100%; padding: 10px 12px; border-radius: 10px; + border: 1px solid var(--line); font: inherit; background: white; +} +.err { color: var(--rose); font-size: 13px; margin-top: 8px; } +.layout { display: grid; grid-template-columns: 250px 1fr; gap: 22px; } +.rail { + background: var(--ink); color: #e8e2d6; border-radius: 22px; padding: 20px 16px; + height: fit-content; position: sticky; top: 88px; +} +.rail h2 { font-size: 14px; margin: 0 0 16px; font-weight: 600; } +.step { + display: grid; grid-template-columns: 28px 1fr; gap: 10px; padding: 10px 8px; + border-radius: 12px; color: #c9c1b3; text-decoration: none; +} +.step b { display: block; color: inherit; font-size: 14px; } +.step span { display: block; font-size: 12px; opacity: 0.8; } +.dot { + width: 28px; height: 28px; border-radius: 50%; border: 1px solid #7d8796; + display: grid; place-items: center; font-size: 12px; +} +.step.on { background: rgba(255,255,255,0.06); color: white; } +.step.on .dot { background: var(--teal-2); border-color: var(--teal-2); color: white; } +.panel { padding: 24px 26px 30px; } +.panel h1 { margin: 0 0 6px; font-size: 26px; } +.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 18px 0; } +.kpi { border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; background: #fbfaf6; } +.kpi em { display: block; font-style: normal; font-size: 12px; color: var(--ink-soft); } +.kpi strong { font-size: 22px; } +.table { width: 100%; border-collapse: collapse; font-size: 14px; } +.table th { text-align: left; font-size: 12px; color: var(--ink-soft); font-weight: 600; padding: 8px 6px; border-bottom: 1px solid var(--line); } +.table td { padding: 9px 6px; border-bottom: 1px solid #eee6d8; } +.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 4px; } +.chip { background: #efe8da; border-radius: 999px; padding: 4px 10px; font-size: 12px; } +.warn { background: #fff4e6; border: 1px solid #f1d2a3; color: #8a5a12; padding: 10px 12px; border-radius: 12px; margin: 8px 0; font-size: 13px; } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.check { display: flex; gap: 8px; align-items: flex-start; margin: 8px 0; font-size: 14px; } +.check input { margin-top: 3px; } +.actions { display: flex; gap: 10px; margin-top: 22px; } +.explain { + background: linear-gradient(180deg, #f7fbf9, #fff); + border: 1px solid #cfe3dc; border-radius: 16px; padding: 16px 18px; line-height: 1.7; +} +.route { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0 4px; } +.route i { + font-style: normal; background: var(--ink); color: var(--paper); + border-radius: 999px; padding: 6px 10px; font-size: 12px; +} +.route i.alt { background: var(--teal); } +.bar-wrap { height: 10px; background: #efe8da; border-radius: 99px; overflow: hidden; } +.bar { height: 100%; background: var(--teal); } +.delta-up { color: var(--good); } +.delta-down { color: var(--rose); } +.tag { font-size: 11px; padding: 2px 7px; border-radius: 99px; background: #efe8da; } +.tag.mid { background: #f8e3bf; color: #8a5a12; } +.tag.high { background: #d8f0e5; color: #1c6f63; } +.drawer-bg { + position: fixed; inset: 0; background: rgba(18,35,61,0.35); display: none; + align-items: stretch; justify-content: flex-end; z-index: 20; +} +.drawer-bg.show { display: flex; } +.drawer { + width: min(560px, 100%); background: var(--card); padding: 22px; + overflow: auto; box-shadow: -20px 0 40px rgba(18,35,61,0.15); +} +.spark { width: 100%; height: 220px; } +.tiny { font-size: 12px; color: var(--ink-soft); } +.clickable { cursor: pointer; } +.clickable:hover { background: #f7f3ea; } +@media (max-width: 900px) { + .hero, .layout, .grid-2, .kpis { grid-template-columns: 1fr; } + .rail { position: static; } +} diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..a8cd9cf --- /dev/null +++ b/app/static/js/app.js @@ -0,0 +1,112 @@ +function closeDrawer() { + const el = document.getElementById("drawer"); + if (el) el.classList.remove("show"); +} + +function sparkline(svg, exams, zs, xs, lastRank) { + const w = 520, h = 220, pad = 28; + const vals = []; + zs.forEach((z, i) => { if (z !== null && z !== undefined) vals.push(z); }); + xs.forEach((z) => { if (z !== null && z !== undefined) vals.push(z); }); + if (!vals.length) return; + const min = Math.min(...vals, -0.5) - 0.3; + const max = Math.max(...vals, 0.5) + 0.3; + const xAt = (i) => pad + (i * (w - pad * 2)) / Math.max(exams.length - 1, 1); + const yAt = (v) => pad + (max - v) * (h - pad * 2) / (max - min); + const pts = (arr) => arr.map((v, i) => v == null ? null : `${xAt(i)},${yAt(v)}`).filter(Boolean).join(" "); + let raw = ""; + zs.forEach((v, i) => { + if (v == null) return; + raw += ``; + }); + const pathXs = xs.map((v, i) => (v == null ? null : [xAt(i), yAt(v)])).filter(Boolean); + let d = ""; + pathXs.forEach((p, i) => { d += (i === 0 ? `M${p[0]} ${p[1]}` : ` L${p[0]} ${p[1]}`); }); + const labels = exams.map((e, i) => { + const short = String(e).replace("-总分", "").replace("高", ""); + return `${short}`; + }).join(""); + svg.setAttribute("viewBox", `0 0 ${w} ${h}`); + svg.innerHTML = ` + + + ${raw} + ${labels} + 标准分轨迹 · 橙点考试,绿线滤波估计 + `; +} + +async function openStudent(aid, sid) { + const res = await fetch(`/analysis/${aid}/api/student/${encodeURIComponent(sid)}`); + if (!res.ok) return; + const s = await res.json(); + const box = document.getElementById("drawer"); + const body = document.getElementById("drawer-body"); + const probs = Object.entries(s.probs || {}).map(([k, v]) => `${k}: ${(v * 100).toFixed(1)}%`).join(" "); + body.innerHTML = ` +
+
+

${s.name} ${s.sid}

+
${s.class_name}班 · ${s.group} · ${s.status || "在籍"} · ${s.subjects || ""}
+
+ +
+

+ 绿线是卡尔曼估计的稳定水平。如果橙点突然跳开绿线,系统会当成发挥波动,不会立刻改判断。 + 达线概率 ${probs || "—"}。 +

+ + + + +
考试分数方向内名次标准分滤波
+ `; + const rows = document.getElementById("spark-rows"); + (s.z || []).forEach((z, i) => { + const exam = (window.HB_EXAMS || [])[i] || `第${i + 1}次`; + const score = s.scores ? s.scores[exam] : ""; + const rank = (s.ranks || [])[i]; + const kf = (s.kf_xs || [])[i]; + rows.insertAdjacentHTML("beforeend", ` + ${exam}${score ?? "缺考"}${rank ?? "—"} + ${z == null ? "—" : z.toFixed(2)} + ${kf == null ? "—" : kf.toFixed(2)} + `); + }); + sparkline(document.getElementById("spark"), window.HB_EXAMS || [], s.z || [], s.kf_xs || []); + box.classList.add("show"); +} + +async function openClass(aid, group, target, className) { + const url = `/analysis/${aid}/api/class?group=${encodeURIComponent(group)}&target=${encodeURIComponent(target)}&class_name=${encodeURIComponent(className)}`; + const res = await fetch(url); + if (!res.ok) return; + const data = await res.json(); + const box = document.getElementById("drawer"); + const body = document.getElementById("drawer-body"); + const rows = data.students.map((s) => { + const p = ((s.probs || {})[target] || 0) * 100; + const tag = p >= 80 ? "high" : p >= 20 ? "mid" : ""; + return ` + ${s.name}${s.status || ""} + ${(s.ranks || []).slice(-1)[0] ?? "—"} + ${p.toFixed(1)}% + `; + }).join(""); + body.innerHTML = ` +
+

${className}班学生概率

+ +
+

点击学生可看成绩轨迹。共 ${data.n} 人,下列按达线概率排序。

+ + + ${rows} +
姓名学籍最近名次达线概率
+ `; + box.classList.add("show"); +} + +document.addEventListener("click", (e) => { + if (e.target.id === "drawer") closeDrawer(); +}); diff --git a/app/templates/analysis.html b/app/templates/analysis.html new file mode 100644 index 0000000..47efd2b --- /dev/null +++ b/app/templates/analysis.html @@ -0,0 +1,263 @@ + + + + + + {{ item.title }} · 航标 + + + +
+ +
航
+
航标{{ item.title }}
+
+
+ {% if result %} + 导出 Excel + {% endif %} +
+
+
+
+ + +
+ {% if step == 1 %} +

第一步:成绩表体检

+

先确认系统读到的是你想算的那批人。分数量纲可以不同,系统稍后会在每次考试内部标准化。

+
+
识别学生{{ summary.n }}
+
班级{{ summary.n_class }}
+
考试次数{{ summary.exams|length }}
+
艺体 / 请假类{{ summary.art }} / {{ summary.leave }}
+
+ {% for w in summary.warnings %} +
{{ w }}
+ {% endfor %} +
+
+

工作表

+ + + + {% for sh in summary.sheets %} + + {% endfor %} + +
表名类型行数
{{ sh.name }}{{ sh.kind }}{{ sh.rows }}
+

方向 / 学籍

+
+ {% for k,v in summary.tracks.items() %}{{ k or "未分方向" }} {{ v }}{% endfor %} +
+
+ {% for k,v in summary.status.items() %}{{ k }} {{ v }}{% endfor %} +
+
+
+

考试识别与分数尺度

+ + + + {% for e in summary.score_scale %} + + + + + {% endfor %} + +
考试有效人数最低最高平均
{{ e.exam }}{{ e.n }}{{ e.min }}{{ e.max }}{{ e.mean }}
+

平均分从高一到高三明显下降是正常的(试卷结构不同),所以不能直接比原始分,只能比同次考试里的相对位置。

+
+
+

各班人数

+
+ {% for k,v in summary.classes.items() %}{{ k }}班 {{ v }}人{% endfor %} +
+ + {% endif %} + + {% if step == 2 %} +

第二步:口径与目标线

+

下达指标前先把“算谁、瞄哪条线”钉死。默认排除艺体和长期请假,明远生可参与年级排名。

+
+

用于滤波的考试

+

建议全选。系统会自动让近考权重大、早考权重小;不是把五次考试简单平均。

+ {% for e in summary.exams %} + + {% endfor %} +
+
+

纳入测算

+ + + + + {% if preview_n is not none %}

按当前口径约 {{ preview_n }} 人进入测算。

{% endif %} +
+
+

预估多远

+ + +

步长越远,不确定性越大,临界生会变多,下达人数仍按期望取整到目标总量。

+
+
+

各方向目标人数

+

这是年级总盘子。班级指标由个人达线概率加总后再取整,合计等于这里的人数。

+ {% for g in settings.groups %} +
+ {{ g }} + {% for t in settings.targets[g] %} +
+ +
+
+
+ {% endfor %} +
+ {% endfor %} +
+ + +
+
+ {% endif %} + + {% if step == 3 %} +

第三步:滤波在做什么

+ {% if not result %} +

还没有测算结果。请回到第二步点击「开始滤波测算」。

+ 去设置口径 + {% else %} +
+ 原始分同次标准化卡尔曼估计水平模拟下一次排名个人概率班级加总取整 +
+

+ 把每次考试看成一次带噪声的定位:发挥好坏是测量噪声 R,真实进步是过程噪声 Q。 + R 相对大时,系统不会只信最近一次;Q 相对大时,会更快跟上近期趋势。这就是导航里“既看 GPS,也看车自己开的方向”。 +

+ {% for g in result.groups %} +

{{ g.group }} · 模型参数

+
+
过程噪声 Q(真实水平会变){{ g.model.q }}
+
测量噪声 R(发挥波动){{ g.model.r }}
+
一步预测 RMSE{{ g.model.rmse }}
+
测算人数 / 模拟次数{{ g.n }} / {{ g.model.n_sim }}
+
+ {% if g.holdout %} +

留出最近一次考试做对照:用更早的成绩预估最近一次,看滤波是否比“只看上一场名次”更准。

+ + + + {% for h in g.holdout %} + + + + + + + {% endfor %} + +
目标K滤波命中只看最近一次
{{ h.target_name }}{{ h.k }}{{ h.kf_hit }} / {{ h.k }}({{ '%.0f'|format(h.kf_precision * 100) }}%){{ h.last_hit }} / {{ h.k }}({{ '%.0f'|format(h.last_precision * 100) }}%)
+ {% endif %} + {% endfor %} + + {% endif %} + {% endif %} + + {% if step == 4 %} +

第四步:下达指标

+ {% if not result %} +

还没有测算结果。请先完成第二步测算。

+ {% else %} +

+ 「下达指标」是滤波期望取整后的人数,含义是:若学生按现有轨迹走,这个班本来应当到线的人数。 + 班会目标是超越它,而不是刚好打平。保底 / 冲刺是模拟分布的大约 20% / 80% 分位,用来看把握而不是另下两套考核。 +

+ {% for g in result.groups %} + {% for t in g.targets %} +

{{ g.group }} · {{ t.name }}(年级 {{ t.k }} 人)

+

期望合计 {{ t.sum_expected }},临界生(概率 20%–80%){{ t.critical_n }} 人。点击班级看学生概率,点击学生看轨迹。

+ + + + + + + + + {% for row in t.classes %} + + + + + + + + + + + + + + {% endfor %} + +
班级测算人数最近在线滤波期望下达指标保底冲刺相对最近
{{ row.class_name }}班{{ row.n }}{{ row.current }}{{ '%.2f'|format(row.expected) }}{{ row.assigned }}{{ row.floor }}{{ row.stretch }} + {% if row.delta > 0 %}+{{ row.delta }}{% else %}{{ row.delta }}{% endif %} +
{{ class_comment(row, t.name) }}
+ {% if t.critical %} +

临界生(优先关注)

+ + + + {% for s in t.critical[:15] %} + + + + + + {% endfor %} + +
姓名班最近名次达线概率
{{ s.name }}{{ s.class_name }}{{ s.last_rank or "—" }}{{ '%.1f'|format(s.p * 100) }}%
+ {% endif %} + {% endfor %} + {% endfor %} + + {% endif %} + {% endif %} +
+
+ +
+
+
+ + + + diff --git a/app/templates/home.html b/app/templates/home.html new file mode 100644 index 0000000..53b16af --- /dev/null +++ b/app/templates/home.html @@ -0,0 +1,55 @@ + + + + + + 航标 · 班级达线指标预估 + + + +
+ +
航
+
航标班级达线指标预估系统
+
+
+
+
+
+
+

把历史成绩当成导航轨迹,
而不是一张静态排名表。

+

+ 上传各次大考总分后,系统会把分数换成同一次考试内的标准分,用卡尔曼滤波估计每个学生的稳定水平, + 再模拟下一次全年级排名,把达线概率加总到班级。下达的是“按现有轨迹本来会到的人数”,班级工作是超越它。 +

+
+
+

上传成绩表

+

xlsx,需含学号/姓名/班级,以及多次考试总分列。若有「方向」或物理/历史分表,会分开测算。

+ +
+ +
+
+
+ {% if has_sample %} +
+ +
+ {% endif %} +
+ {% for it in items %} + +
+ {{ it.title }} +
{{ it.filename }} · {{ it.created_at }}
+
+ 继续第 {{ it.step or 1 }} 步 +
+ {% else %} +
还没有分析记录。上传成绩表后会在这里列出。
+ {% endfor %} +
+
+ + diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..126b0f5 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,25 @@ + + + + + + 登录 · 航标 + + + +
+ +
+ + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6ff6ff7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.115 +uvicorn>=0.32 +python-multipart>=0.0.9 +jinja2>=3.1 +itsdangerous>=2.2.0 +openpyxl>=3.1 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..63c9567 --- /dev/null +++ b/run.sh @@ -0,0 +1,8 @@ +#!/bin/zsh +cd "$(dirname "$0")" +if [ ! -x .venv/bin/python ]; then + python3 -m venv .venv + .venv/bin/pip install -r requirements.txt +fi +export PORT="${PORT:-8765}" +exec .venv/bin/python -m app diff --git a/高三指标划定操作指引.docx b/高三指标划定操作指引.docx new file mode 100644 index 0000000..c9e4126 Binary files /dev/null and b/高三指标划定操作指引.docx differ