diff --git a/2024级指标分析成绩基础数据_5次考试.xlsx b/2024级指标分析成绩基础数据_5次考试.xlsx deleted file mode 100644 index 4977a6e..0000000 Binary files a/2024级指标分析成绩基础数据_5次考试.xlsx and /dev/null differ diff --git a/README.md b/README.md index 768fabf..9118767 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 指标测算 · 相邻层次法 -B/S 系统:上传历次大考总分,按方向内名次把学生分到固定宽度层次;每次只比较相邻两次考试的“上一层次 → 本次到线”命中人数;汇总后做拟合概率,再用最后一次考试各班层次人数摊派尖子线和特控线指标。 +B/S 系统:上传历次大考总分或单科分,按方向内名次把学生分到固定宽度层次;每次只比较相邻两次考试的“上一层次 → 本次到线”命中人数;汇总后做拟合概率,再用最后一次考试各班层次人数摊派尖子线和特控线指标。 ## 启动 @@ -18,12 +18,13 @@ python3 -m app ## 成绩表要求 - Excel `.xlsx` -- 至少包含:学号/姓名、班级、多次考试总分 +- 至少包含:学号/姓名、班级、多次考试成绩列 +- 成绩列可以是总分(如 `高一下期末-总分`),也可以是单科分(如 `高一下期末-数学分`、`高三分班合成-语文分`) - 建议包含:方向、选科、学籍、异动 - 物理/历史分表可直接识别;同分按同名次,后续名次递增 - 未排除的缺考学生按 0 分参加同方向排名 -同目录 `basedata.xlsx` 上传后可作为按新算法复算的示例。 +同目录 `basedata.xlsx` 是总分示例;`2024级高三指标数核算表:数学(六次).xlsx`、`2024级高三指标数核算表:语文(六次).xlsx` 可作为单科示例。 ## Web 设置 diff --git a/app/excel_parse.py b/app/excel_parse.py index 8435aa0..d7930ca 100644 --- a/app/excel_parse.py +++ b/app/excel_parse.py @@ -1,10 +1,12 @@ from __future__ import annotations +import ast import re from io import BytesIO from pathlib import Path from openpyxl import load_workbook +from openpyxl.utils import column_index_from_string META_ALIASES = { @@ -18,9 +20,12 @@ META_ALIASES = { } EXAM_HINT = re.compile( - r"(总分|期末|联考|月考|模考|开学|学考|诊断|调研|高考)", + r"(总分|期中|期末|联考|月考|模考|开学|学考|诊断|调研|高考|分班合成)", re.I, ) +SUBJECT_SUFFIX = re.compile( + r"-(语文|数学|英语|外语|物理|化学|生物|历史|地理|政治|思想政治)分$" +) SKIP_SHEETS = ("说明", "readme", "目录") @@ -32,6 +37,8 @@ def _is_skip_sheet(name: str) -> bool: ART_RE = re.compile(r"艺术|体育|艺体") LEAVE_RE = re.compile(r"请假|出国|休学|国际|不在校|港澳|港籍.*假") +CELL_REF_RE = re.compile(r"\$?([A-Za-z]+)\$?(\d+)") +MISSING_TOKENS = {"", "缺考", "—", "-", "/", "none", "nan"} def _norm(v) -> str: @@ -42,13 +49,97 @@ def _norm(v) -> str: return str(v).strip() +def _safe_arith(expr: str) -> float: + tree = ast.parse(expr, mode="eval") + allowed = ( + ast.Expression, + ast.BinOp, + ast.UnaryOp, + ast.Add, + ast.Sub, + ast.Mult, + ast.Div, + ast.USub, + ast.UAdd, + ast.Constant, + ast.Load, + ast.Mod, + ) + for node in ast.walk(tree): + if not isinstance(node, allowed): + raise ValueError("unsupported formula") + return float(eval(compile(tree, "", "eval"), {"__builtins__": {}}, {})) + + +def _eval_simple_formula(formula: str, raw, row_number: int) -> float | None: + if not isinstance(formula, str) or not formula.startswith("="): + return None + expr = formula[1:].strip() + if re.search(r"[^0-9A-Za-z+\-*/().$,\s]", expr): + return None + + def repl(match: re.Match) -> str: + col = column_index_from_string(match.group(1).upper()) - 1 + src_row = int(match.group(2)) + if src_row != row_number or col < 0 or col >= len(raw): + raise ValueError("formula refers outside the current row") + val = raw[col] + if isinstance(val, str) and val.startswith("="): + raise ValueError("nested formula") + if val is None or str(val).strip().lower() in MISSING_TOKENS: + raise ValueError("missing ref") + return str(float(val)) + + try: + filled = CELL_REF_RE.sub(repl, expr) + return _safe_arith(filled) + except (TypeError, ValueError, SyntaxError, ZeroDivisionError): + return None + + +def _to_score(val, raw, row_number: int): + if val is None: + return None + if isinstance(val, str): + text = val.strip() + if text.lower() in MISSING_TOKENS: + return None + if text.startswith("="): + return _eval_simple_formula(text, raw, row_number) + val = text + try: + return float(val) + except (TypeError, ValueError): + return None + + def _is_exam_header(text: str) -> bool: t = text.strip() - if not t or t in {"考试次数"}: + if not t or t in {"考试次数"} or "次数" in t: return False - if t.endswith("-总分") or t.endswith("总分"): + # 总分、数学分、语文分、分班合成-数学分 等都视为成绩列。 + if t.endswith("分"): return True - return bool(EXAM_HINT.search(t)) and "次数" not in t + return bool(EXAM_HINT.search(t)) + + +def infer_score_kind(exam_names: list[str], source_name: str = "") -> str: + found: list[str] = [] + for name in exam_names: + m = SUBJECT_SUFFIX.search(str(name).strip()) + if m: + found.append(m.group(1)) + elif str(name).endswith("总分"): + found.append("总分") + uniq = list(dict.fromkeys(found)) + if len(uniq) == 1: + return uniq[0] + if uniq and "总分" not in uniq: + return uniq[0] + for subj in ("语文", "数学", "英语", "外语", "化学", "生物", "地理", "政治"): + if subj in source_name: + return subj + return "总分" def _map_meta(header: str) -> str | None: @@ -76,11 +167,12 @@ def _sheet_kind(name: str) -> str: def parse_workbook(path: Path | BytesIO) -> dict: - wb = load_workbook(path, data_only=True, read_only=True) + wb = load_workbook(path, data_only=False, read_only=True) sheets = [] students: list[dict] = [] exam_order: list[str] = [] warnings: list[str] = [] + source_name = path.name if isinstance(path, Path) else "" for ws in wb.worksheets: kind = _sheet_kind(ws.title) @@ -112,7 +204,7 @@ def parse_workbook(path: Path | BytesIO) -> dict: continue count = 0 - for raw in rows: + for row_number, raw in enumerate(rows, start=2): if raw is None or all(v is None or str(v).strip() == "" for v in raw): continue rec = { @@ -143,14 +235,7 @@ def parse_workbook(path: Path | BytesIO) -> dict: 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["scores"][exam] = _to_score(raw[idx], raw, row_number) 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) @@ -195,12 +280,19 @@ def parse_workbook(path: Path | BytesIO) -> dict: warnings.append(f"按学号去重 {dups} 条") if not exam_order: - warnings.append("未识别到考试成绩列,请确认表头含「总分」或「期末/联考/月考」") + warnings.append("未识别到考试成绩列,请确认表头含「总分」或单科分(如「高一下期末-数学分」)") + else: + for exam in exam_order: + present = sum(1 for s in students if s["scores"].get(exam) is not None) + if present == 0: + warnings.append(f"考试列「{exam}」没有读到数值,若是未计算的公式请先在 Excel 中打开保存后再上传") + score_kind = infer_score_kind(exam_order, source_name) return { "sheets": sheets, "students": students, "exams": exam_order, + "score_kind": score_kind, "warnings": warnings, } @@ -253,6 +345,7 @@ def summarize_parse(parsed: dict) -> dict: "score_scale": score_scale, "sheets": parsed["sheets"], "exams": exams, + "score_kind": parsed.get("score_kind") or infer_score_kind(exams), "warnings": parsed["warnings"], } diff --git a/app/export_xlsx.py b/app/export_xlsx.py index 8a2bee4..906a071 100644 --- a/app/export_xlsx.py +++ b/app/export_xlsx.py @@ -36,6 +36,16 @@ def _safe_title(text: str) -> str: return text.replace(":", "-").replace("/", "-").replace("\\", "-").replace("?", "-")[:31] +def _score_kind(result_or_group: dict) -> str: + return (result_or_group.get("score_kind") or "总分").strip() or "总分" + + +def _line_title(group: dict, line_name: str) -> str: + kind = _score_kind(group) + prefix = "" if kind == "总分" else f"{kind}——" + return f"{prefix}{group['group']}方向{line_name}指标分配" + + def _raw_sheet(wb: Workbook, group: dict, students: list[dict]): title = _safe_title(f"{group['group']}方向人员") ws = wb.create_sheet(title) @@ -149,7 +159,7 @@ def _metric_sheet(wb: Workbook, group: dict): ws = wb.create_sheet(title) r = 1 for line in group["targets"]: - ws.cell(r, 1, f"{group['group']}方向{line['name']}指标分配") + ws.cell(r, 1, _line_title(group, line["name"])) r += 1 headers = ["班级"] + [f"{i+1}层人数" for i in range(group["n_layers"])] + ["加权计算", "摊派指标"] ws.cell(r, 1, headers[0]) @@ -182,7 +192,11 @@ def _metric_sheet(wb: Workbook, group: dict): ws.cell(r, len(headers) - 1, line["sum_expected"]) ws.cell(r, len(headers), line["sum_assigned"]) r += 1 - ws.cell(r, 1, "注:加权计算=Σ(各层人数×拟合概率);摊派指标=加权计算四舍五入。") + last_exam = (group.get("_exams") or [None])[-1] + note = "注:加权计算=Σ(各层人数×拟合概率);摊派指标=加权计算四舍五入。" + if last_exam: + note = f"注:数据源以最后一次考试「{last_exam}」为准。" + note[2:] + ws.cell(r, 1, note) r += 3 ws.column_dimensions["A"].width = 14 for c in range(2, ws.max_column + 1): @@ -197,9 +211,11 @@ def build_export(result: dict) -> bytes: wb.remove(wb.active) # Keep the same familiar order as basedata.xlsx: 物理 raw/prob/metric, then 历史. groups = sorted(result["groups"], key=lambda g: _group_order(g["group"])) + score_kind = _score_kind(result) for i, group in enumerate(groups): students = [s for s in result["students"] if s["group"] == group["group"]] group["_exams"] = result["exams"] + group["score_kind"] = score_kind _raw_sheet(wb, group, students) for group in groups: _probability_sheet(wb, group) diff --git a/app/layer_method.py b/app/layer_method.py index 16d9df3..4079dd2 100644 --- a/app/layer_method.py +++ b/app/layer_method.py @@ -347,8 +347,10 @@ def analyze(parsed: dict, settings: dict) -> dict: } ) + score_kind = parsed.get("score_kind") or "总分" return { "algorithm": "adjacent-layer-markov-v2", + "score_kind": score_kind, "exams": exams, "layer_size": LAYER_SIZE, "pair_count": len(probability_exams) - 1 if len(probability_exams) > 1 else 0, diff --git a/app/templates/analysis.html b/app/templates/analysis.html index c333261..bbb8b72 100644 --- a/app/templates/analysis.html +++ b/app/templates/analysis.html @@ -40,11 +40,12 @@
{% if step == 1 %}

第一步:成绩表体检

-

这里只检查源表识别结果。缺考学生在后续测算中会按 0 分参加同方向排名。

+

这里只检查源表识别结果。缺考学生在后续测算中会按 0 分参加同方向排名。单科成绩与总分使用同一套相邻层次算法。

识别学生{{ summary.n }}
班级{{ summary.n_class }}
考试次数{{ summary.exams|length }}
+
成绩口径{{ summary.score_kind or '总分' }}
异动备注{{ summary.moves }}
{% for w in summary.warnings %} @@ -82,7 +83,7 @@

第二步:口径、层次与线

考试范围

-

考试列只来自本次上传工作簿的物理/历史人员表;只比较这里勾选后的相邻两次考试。例如勾选 7 次,会生成 6 组转移样本;最后一次既进入相邻概率计算,也用于最终摊派。

+

考试列只来自本次上传工作簿的物理/历史人员表,总分或单科分均可;只比较这里勾选后的相邻两次考试。例如勾选 7 次,会生成 6 组转移样本;最后一次既进入相邻概率计算,也用于最终摊派。

本次源表:{{ item.filename }};方向人员表识别到 {{ summary.exams|length }} 个考试列。
{% for e in summary.exams %} diff --git a/app/templates/home.html b/app/templates/home.html index 6801691..0fa5562 100644 --- a/app/templates/home.html +++ b/app/templates/home.html @@ -25,7 +25,7 @@

上传成绩表

-

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

+

xlsx,需含学号/姓名/班级,以及多次考试成绩列(总分或单科分,如「高一下期末-数学分」)。物理/历史分表会分开测算。