소스 검색

Add good-with-good-advance strategy to generate_seating.py: tiered seating

按赋分成绩分层排座位,新增 -t good-with-good-advance:
- 成绩列按分组表头定位(默认赋分成绩),修掉原来误取「总分/原始成绩」的问题
- 排层次按「优-差-中」循环,每排差生的正前方恰好是优生排,
  满足「差生正前或正后方必须有优生」的严格口径
- 每排各桌组同层次、同性别,同桌必定同性,优生集中在整排
- 空位由优生排与其正后方差生排按同组同列配对挖出,避免差生正前方变空位
- 生成后自动校验同桌同性 / 差生前后有优生 / 落座人数并打印每排构成

新增 --score-column、--good-ratio、--bad-ratio;原有三种策略行为不变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 1 개월 전
부모
커밋
3be1639333
2개의 변경된 파일과 390개의 추가작업 그리고 9개의 파일을 삭제
  1. 22 1
      README.md
  2. 368 8
      换座位/generate_seating.py

+ 22 - 1
README.md

@@ -71,11 +71,32 @@
 
 
 | 文件 | 说明 |
 | 文件 | 说明 |
 |------|------|
 |------|------|
-| `generate_seating.py` | 根据 Excel 学生数据(班级、姓名、性别、总分)自动生成座位表,支持命令行参数 |
+| `generate_seating.py` | 根据 Excel 学生数据(班级、姓名、性别、成绩)自动生成座位表。按分组表头定位成绩列(默认**赋分成绩**,`--score-column` 可切原始成绩),支持任意座位样式与四种配对策略,见下方用法 |
 | `根据模板换座位.py` | 读取 Excel 模板,将座位表中的数字编号替换为实际人名 |
 | `根据模板换座位.py` | 读取 Excel 模板,将座位表中的数字编号替换为实际人名 |
 | `座位表.html` | 座位表 HTML 可视化展示 |
 | `座位表.html` | 座位表 HTML 可视化展示 |
 | `Temp.xlsx` | 座位模板 Excel 文件 |
 | `Temp.xlsx` | 座位模板 Excel 文件 |
 
 
+用法:
+
+```bash
+cd 换座位
+
+# 分层排布:优生集中,差生正前或正后方必有优生,同桌同性
+python generate_seating.py 排行榜.xlsx -c 高补55 -s 2333 -t good-with-good-advance
+
+# 其他策略:good-with-good(好+好)/ good-with-bad(好+差)/ random(随机)
+python generate_seating.py 排行榜.xlsx -c 高补55 -s 2222 -t good-with-good
+
+# 一次生成 5 份候选(每份都通过校验),再用 --seed 固定选中的那份
+python generate_seating.py 排行榜.xlsx -c 高补55 -s 2333 -t good-with-good-advance -n 5 --seed 42
+```
+
+`-s` 座位样式:每个数字是一组的宽度,如 `2333` = 每排 4 组、分别坐 2/3/3/3 人。
+
+`good-with-good-advance` 策略:按成绩排名前 1/3 为优生、后 1/3 为差生(`--good-ratio` / `--bad-ratio` 可调),**排的层次按「优-差-中」循环**,因此每排差生的正前方正好是优生排;空位由优生排与其正后方差生排按「同组同列」配对挖出,不会出现差生正前方变空位。生成后会自动校验同桌同性、差生前后有优生、落座人数,并在控制台打印每排构成。
+
+依赖:`openpyxl`。
+
 ### 成绩分析 (`小分表/`)
 ### 成绩分析 (`小分表/`)
 
 
 | 文件 | 说明 |
 | 文件 | 说明 |

+ 368 - 8
换座位/generate_seating.py

@@ -6,6 +6,7 @@ Required input columns: 班级, 姓名, 性别, 总分
 """
 """
 
 
 import argparse
 import argparse
+import itertools
 import random
 import random
 import sys
 import sys
 from pathlib import Path
 from pathlib import Path
@@ -41,8 +42,22 @@ def parse_args():
     )
     )
     parser.add_argument(
     parser.add_argument(
         "-t", "--strategy", type=str, default="good-with-good",
         "-t", "--strategy", type=str, default="good-with-good",
-        choices=["good-with-good", "good-with-bad", "random"],
-        help="成绩配对策略:good-with-good(好+好)、good-with-bad(好+差)、random(无关)"
+        choices=["good-with-good", "good-with-bad", "random", "good-with-good-advance"],
+        help="成绩配对策略:good-with-good(好+好)、good-with-bad(好+差)、"
+             "random(无关)、good-with-good-advance(好+好进阶:好生集中,"
+             "差生正前方或正后方必有优生,同桌同性)"
+    )
+    parser.add_argument(
+        "--score-column", type=str, default="赋分成绩",
+        help="排序用的成绩列(默认:赋分成绩,即「总分」组下的赋分子列;找不到则用总分列)"
+    )
+    parser.add_argument(
+        "--good-ratio", type=float, default=1 / 3,
+        help="优生比例(仅 good-with-good-advance 策略,默认 1/3)"
+    )
+    parser.add_argument(
+        "--bad-ratio", type=float, default=1 / 3,
+        help="差生比例(仅 good-with-good-advance 策略,默认 1/3)"
     )
     )
     parser.add_argument(
     parser.add_argument(
         "-o", "--output", type=str, default=None,
         "-o", "--output", type=str, default=None,
@@ -67,7 +82,24 @@ def parse_args():
     return parser.parse_args()
     return parser.parse_args()
 
 
 
 
-def read_students(filepath, class_name, sheet_name=None):
+def find_score_column(ws, header_row, total_col, score_column):
+    """在「总分」分组表头下方找到子表头为 score_column 的那一列(1-based)。
+
+    排行榜表格第2行是分组表头(总分/语数外/…),第3行才是子表头
+    (原始成绩/赋分成绩/班级排名)。直接取「总分」列拿到的是原始成绩,
+    这里按子表头选列。返回 (列号, 是否按子表头命中);找不到就用「总分」列。
+    """
+    sub_row = header_row + 1
+    if sub_row <= ws.max_row:
+        # 只在「总分」组内往后找,避免串到后面的科目分组
+        for c in range(total_col, min(total_col + 4, ws.max_column + 1)):
+            val = ws.cell(row=sub_row, column=c).value
+            if val and str(val).strip() == score_column:
+                return c, True
+    return total_col, False
+
+
+def read_students(filepath, class_name, sheet_name=None, score_column="赋分成绩"):
     """从Excel读取学生数据,按班级筛选。自动查找表头行。"""
     """从Excel读取学生数据,按班级筛选。自动查找表头行。"""
     from openpyxl import load_workbook
     from openpyxl import load_workbook
 
 
@@ -108,6 +140,13 @@ def read_students(filepath, class_name, sheet_name=None):
     class_name_clean = class_name.rstrip("班")
     class_name_clean = class_name.rstrip("班")
     data_start = header_row + 1
     data_start = header_row + 1
 
 
+    total_col = indices["总分"] + 1
+    score_col, hit = find_score_column(ws, header_row, total_col, score_column)
+    if hit:
+        print(f"排序成绩列:第 {score_col} 列({score_column})")
+    else:
+        print(f"排序成绩列:第 {total_col} 列(未找到「{score_column}」子列,改用表头「总分」列)")
+
     students = []
     students = []
     for row in range(data_start, ws.max_row + 1):
     for row in range(data_start, ws.max_row + 1):
         class_val = str(ws.cell(row=row, column=indices["班级"] + 1).value or "").strip()
         class_val = str(ws.cell(row=row, column=indices["班级"] + 1).value or "").strip()
@@ -117,7 +156,7 @@ def read_students(filepath, class_name, sheet_name=None):
 
 
         name = str(ws.cell(row=row, column=indices["姓名"] + 1).value or "").strip()
         name = str(ws.cell(row=row, column=indices["姓名"] + 1).value or "").strip()
         gender = str(ws.cell(row=row, column=indices["性别"] + 1).value or "").strip()
         gender = str(ws.cell(row=row, column=indices["性别"] + 1).value or "").strip()
-        score_str = str(ws.cell(row=row, column=indices["总分"] + 1).value or "").strip()
+        score_str = str(ws.cell(row=row, column=score_col).value or "").strip()
 
 
         if not name:
         if not name:
             continue
             continue
@@ -246,6 +285,314 @@ def allocate_students(students, desk_sizes, strategy, allow_mixed, seed):
     return groups
     return groups
 
 
 
 
+# ------------------------------------------------------- 分层排布(进阶策略)
+
+TIER_GOOD = "优"
+TIER_MID = "中"
+TIER_BAD = "差"
+TIER_CYCLE = (TIER_GOOD, TIER_BAD, TIER_MID)   # 排层次循环:优生排紧跟差生排
+
+
+def gender_key(student):
+    """把性别归一成「女」/「男」两池(无法识别的归入男生池)。"""
+    return "女" if is_female(student["gender"]) else "男"
+
+
+def assign_tiers(students, good_ratio, bad_ratio):
+    """按成绩排名划分层次:前 good_ratio 为优生,后 bad_ratio 为差生,其余为中。"""
+    ordered = sorted(students, key=lambda s: (-s["score"], s["name"]))
+    n = len(ordered)
+    n_good = min(n, max(0, int(round(n * good_ratio))))
+    n_bad = min(n - n_good, max(0, int(round(n * bad_ratio))))
+    for i, stu in enumerate(ordered):
+        if i < n_good:
+            stu["tier"] = TIER_GOOD
+        elif i >= n - n_bad:
+            stu["tier"] = TIER_BAD
+        else:
+            stu["tier"] = TIER_MID
+    return ordered
+
+
+def _subset_sums(widths):
+    """一组桌宽能凑出的所有座位数。"""
+    sums = {0}
+    for w in widths:
+        sums |= {s + w for s in sums}
+    return sorted(sums)
+
+
+def _choose_counts(rows_bands, total, rng):
+    """给每一排定一个女座数,使合计等于 total,并尽量让女生分散到各排。"""
+    options = [_subset_sums([w for _, w in rb]) for rb in rows_bands]
+    caps = [sum(w for _, w in rb) for rb in rows_bands]
+    total_cap = sum(caps) or 1
+    best = None
+
+    def walk(i, remain, acc):
+        nonlocal best
+        if i == len(rows_bands):
+            if remain != 0:
+                return
+            score = sum(abs(acc[j] - total * caps[j] / total_cap) for j in range(len(caps)))
+            if best is None or score < best[0] - 1e-9 or (
+                    abs(score - best[0]) < 1e-9 and rng.random() < 0.5):
+                best = (score, list(acc))
+            return
+        for v in options[i]:
+            if v <= remain:
+                walk(i + 1, remain - v, acc + [v])
+
+    walk(0, total, [])
+    return best[1] if best else None
+
+
+def _choose_bands(bands, target, rng):
+    """在一排里挑出若干桌组坐女生,座位数之和等于 target,尽量打散到更多组。"""
+    for size in range(len(bands), -1, -1):
+        hits = [combo for combo in itertools.combinations(range(len(bands)), size)
+                if sum(bands[i][1] for i in combo) == target]
+        if hits:
+            return {bands[i][0] for i in rng.choice(hits)}
+    return None
+
+
+def _tail_positions(sizes, count):
+    """从最右侧座位往左数 count 个,返回 [(组, 组内位置), ...]。"""
+    out = []
+    for b in range(len(sizes) - 1, -1, -1):
+        for p in range(sizes[b] - 1, -1, -1):
+            out.append((b, p))
+            if len(out) >= count:
+                return out
+    return out
+
+
+def plan_tiered_layout(students, sizes, good_ratio, bad_ratio, seed):
+    """分层排布:优生集中,差生正前方或正后方必有优生。
+
+    1. 排层次按「优-差-中」循环,所以每排差生的正前方恰好是优生排;
+       每排各桌组同层次、同性别(同桌必定同性)。
+    2. 各层次座位容量减去人数得到空位数。优生排的空位必须和它正后方
+       差生排的「同组、同位置」一起挖空,否则会出现差生正前方变成空位、
+       前后都没有优生的情况。
+    3. 按层次把女生的座位数分配到各排,再决定排内哪几个组坐女生。
+    4. 每个 (性别, 层次) 池内按成绩降序,从前往后、从左往右填座,
+       所以优生里分数最高的坐在最前排。
+    """
+    rng = random.Random(seed)
+    ordered = assign_tiers(students, good_ratio, bad_ratio)
+    n = len(ordered)
+    row_capacity = sum(sizes)
+
+    unknown = [s["name"] for s in ordered if not is_male(s["gender"]) and not is_female(s["gender"])]
+    if unknown:
+        print(f"警告:{len(unknown)} 名学生性别无法识别({'、'.join(unknown[:5])}"
+              f"{' 等' if len(unknown) > 5 else ''}),已按男生编排。")
+
+    if n == 0:
+        return {
+            "groups": [[] for _ in range(len(sizes))],
+            "grid": {}, "voids": set(), "row_roles": [TIER_CYCLE[0]],
+            "num_rows": 1, "ordered": [],
+        }
+
+
+    num_rows = max(1, (n + row_capacity - 1) // row_capacity)
+    row_roles = [TIER_CYCLE[r % len(TIER_CYCLE)] for r in range(num_rows)]
+
+    capacity = {t: 0 for t in TIER_CYCLE}
+    for t in row_roles:
+        capacity[t] += row_capacity
+    need = {t: 0 for t in TIER_CYCLE}
+    for stu in ordered:
+        need[stu["tier"]] += 1
+    deficit = {t: capacity[t] - need[t] for t in TIER_CYCLE}
+
+    for t in TIER_CYCLE:
+        if deficit[t] < 0:
+            print(f"错误:{num_rows} 排座位装不下 {need[t]} 名「{t}」等生(该层次只有 {capacity[t]} 座)。")
+            print("      可调整 --good-ratio / --bad-ratio,或换一个座位样式。")
+            sys.exit(1)
+    if deficit[TIER_GOOD] != deficit[TIER_BAD]:
+        print(f"错误:优生空位({deficit[TIER_GOOD]})与差生空位({deficit[TIER_BAD]})不相等,")
+        print("      无法保证每个差生的正前或正后方都是优生。请让优生、差生比例保持一致。")
+        sys.exit(1)
+
+    # --- 空位:优生排与其正后方的差生排按同组同位置配对挖空 ---
+    voids = set()
+    pair = min(deficit[TIER_GOOD], row_capacity)
+    if pair:
+        anchor = None
+        for r in range(num_rows - 1, -1, -1):
+            if row_roles[r] == TIER_GOOD and r + 1 < num_rows and row_roles[r + 1] == TIER_BAD:
+                anchor = r
+                break
+        if anchor is None:
+            print("错误:排数不足以形成「优生排 + 差生排」的配对空位。")
+            sys.exit(1)
+        for b, p in _tail_positions(sizes, pair):
+            voids.add((anchor, b, p))
+            voids.add((anchor + 1, b, p))
+
+    # --- 空位:中等生排从后往前挖 ---
+    left = deficit[TIER_MID]
+    for r in range(num_rows - 1, -1, -1):
+        if left <= 0:
+            break
+        if row_roles[r] != TIER_MID:
+            continue
+        free = [(b, p) for b in range(len(sizes)) for p in range(sizes[b])
+                if (r, b, p) not in voids]
+        for b, p in reversed(free):
+            if left <= 0:
+                break
+            voids.add((r, b, p))
+            left -= 1
+    if left > 0:
+        print(f"错误:还有 {left} 个「{TIER_MID}」等生空位没地方安排。")
+        sys.exit(1)
+
+    # --- 每排实际可用的桌组与座位数 ---
+    row_bands = {}
+    for r in range(num_rows):
+        bands = [(b, sum(1 for p in range(sizes[b]) if (r, b, p) not in voids))
+                 for b in range(len(sizes))]
+        row_bands[r] = [item for item in bands if item[1] > 0]
+
+    # --- 男女分配:先定每排女座数,再定排内哪几个组坐女生 ---
+    female_need = {t: sum(1 for s in ordered if s["tier"] == t and gender_key(s) == "女")
+                   for t in TIER_CYCLE}
+    band_gender = {}
+    for t in TIER_CYCLE:
+        rows = [r for r in range(num_rows) if row_roles[r] == t]
+        if not rows:
+            continue
+        counts = _choose_counts([row_bands[r] for r in rows], female_need[t], rng)
+        if counts is None:
+            print(f"错误:「{t}」等生的男女人数排不进对应的桌组,请调整比例或样式。")
+            sys.exit(1)
+        for r, want in zip(rows, counts):
+            female_bands = _choose_bands(row_bands[r], want, rng)
+            if female_bands is None:
+                print(f"错误:第 {r + 1} 排无法排出 {want} 个女生座位。")
+                sys.exit(1)
+            for b, _ in row_bands[r]:
+                band_gender[(r, b)] = "女" if b in female_bands else "男"
+
+    # --- 填座 ---
+    pools = {(g, t): [s for s in ordered if gender_key(s) == g and s["tier"] == t]
+             for g in ("女", "男") for t in TIER_CYCLE}
+    idx = {key: 0 for key in pools}
+
+    grid = {}
+    for t in TIER_CYCLE:
+        for r in range(num_rows):
+            if row_roles[r] != t:
+                continue
+            for b, _ in row_bands[r]:
+                g = band_gender[(r, b)]
+                pool = pools[(g, t)]
+                for p in range(sizes[b]):
+                    if (r, b, p) in voids or idx[(g, t)] >= len(pool):
+                        continue
+                    grid[(r, b, p)] = pool[idx[(g, t)]]
+                    idx[(g, t)] += 1
+
+    unseen = {key: len(pools[key]) - idx[key] for key in pools if len(pools[key]) != idx[key]}
+    if unseen:
+        print(f"错误:有学生没排下:{unseen}")
+        sys.exit(1)
+
+    groups = []
+    for r in range(num_rows):
+        for b in range(len(sizes)):
+            groups.append([grid[(r, b, p)] for p in range(sizes[b]) if (r, b, p) in grid])
+
+    return {
+        "groups": groups,
+        "grid": grid,
+        "voids": voids,
+        "row_roles": row_roles,
+        "num_rows": num_rows,
+        "ordered": ordered,
+    }
+
+
+def verify_tiered_layout(layout, sizes):
+    """校验硬性约束:同桌同性、差生正前或正后方同列有优生、落座人数。"""
+    grid = layout["grid"]
+    num_rows = layout["num_rows"]
+    problems = []
+
+    mixed = 0
+    for r in range(num_rows):
+        for b in range(len(sizes)):
+            genders = {gender_key(grid[(r, b, p)]) for p in range(sizes[b]) if (r, b, p) in grid}
+            if len(genders) > 1:
+                mixed += 1
+                problems.append(f"第 {r + 1} 排第 {b + 1} 组同桌男女混坐")
+            tiers = {grid[(r, b, p)]["tier"] for p in range(sizes[b]) if (r, b, p) in grid}
+            if len(tiers) > 1:
+                problems.append(f"第 {r + 1} 排第 {b + 1} 组层次不统一")
+
+    roster = sorted(s["name"] for s in layout["ordered"])
+    seated = sorted(stu["name"] for stu in grid.values())
+    if seated != roster:
+        problems.append(f"落座名单与班级名单不符(落座 {len(seated)} 人,班级 {len(roster)} 人)")
+
+    bad_total = bad_ok = 0
+    for (r, b, p), stu in sorted(grid.items()):
+        if stu["tier"] != TIER_BAD:
+            continue
+        bad_total += 1
+        if any(grid.get((r + dr, b, p)) and grid[(r + dr, b, p)]["tier"] == TIER_GOOD
+               for dr in (-1, 1)):
+            bad_ok += 1
+        else:
+            problems.append(f"第 {r + 1} 排第 {b + 1} 组「{stu['name']}」正前/正后方没有优生")
+
+    return {
+        "mixed": mixed,
+        "bad_total": bad_total,
+        "bad_ok": bad_ok,
+        "placed": len(grid),
+        "empty": num_rows * sum(sizes) - len(grid),
+        "problems": problems,
+    }
+
+
+def print_tiered_report(layout, stats, sizes):
+    """打印分层排布的每排构成与约束校验结论。"""
+    grid = layout["grid"]
+    num_rows = layout["num_rows"]
+    print("\n分层排布结果(good-with-good-advance):")
+    for r in range(num_rows):
+        counts = {}
+        for b in range(len(sizes)):
+            for p in range(sizes[b]):
+                stu = grid.get((r, b, p))
+                if stu:
+                    key = (stu["tier"], gender_key(stu))
+                    counts[key] = counts.get(key, 0) + 1
+        detail = ",".join(f"{t}生{k} {v}人" for (t, k), v in sorted(counts.items()))
+        empty = sum(1 for b in range(len(sizes)) for p in range(sizes[b])
+                    if (r, b, p) not in grid)
+        tail = f",空 {empty} 座" if empty else ""
+        print(f"  第 {r + 1:2d} 排({layout['row_roles'][r]}生排){detail}{tail}")
+
+    ok_gender = "通过" if stats["mixed"] == 0 else "不通过"
+    ok_frontback = "通过" if stats["bad_ok"] == stats["bad_total"] else "不通过"
+    print("\n约束校验:")
+    print(f"  同桌同性:{ok_gender}(混坐 {stats['mixed']} 组)")
+    print(f"  差生正前或正后方有优生:{ok_frontback}({stats['bad_ok']}/{stats['bad_total']} 人)")
+    print(f"  落座 {stats['placed']} 人,空 {stats['empty']} 座")
+    if stats["problems"]:
+        print("  未通过明细(最多列 20 条):")
+        for msg in stats["problems"][:20]:
+            print(f"    - {msg}")
+
+
 def generate_excel(groups, style_str, output_path, class_name):
 def generate_excel(groups, style_str, output_path, class_name):
     """生成座位表Excel文件。"""
     """生成座位表Excel文件。"""
     wb = Workbook()
     wb = Workbook()
@@ -361,7 +708,7 @@ def main():
 
 
     # 读取学生(只读一次)
     # 读取学生(只读一次)
     print(f"正在读取 {args.excel_file} ...")
     print(f"正在读取 {args.excel_file} ...")
-    students = read_students(args.excel_file, args.class_name, args.sheet)
+    students = read_students(args.excel_file, args.class_name, args.sheet, args.score_column)
 
 
     if not students:
     if not students:
         print(f"警告:班级 '{args.class_name}' 中没有找到任何学生,将生成空座位表。")
         print(f"警告:班级 '{args.class_name}' 中没有找到任何学生,将生成空座位表。")
@@ -380,8 +727,12 @@ def main():
         "good-with-good": "好+好",
         "good-with-good": "好+好",
         "good-with-bad": "好+差",
         "good-with-bad": "好+差",
         "random": "与成绩无关",
         "random": "与成绩无关",
+        "good-with-good-advance": "好+好进阶(分层排布,差生前后排必有优生)",
     }
     }
 
 
+    if args.strategy == "good-with-good-advance" and args.allow_mixed:
+        print("提示:good-with-good-advance 固定「同桌同性」,-m/--allow-mixed 对它无效。")
+
     # 生成多个座位表
     # 生成多个座位表
     for i in range(args.count):
     for i in range(args.count):
         seed = base_seed + i
         seed = base_seed + i
@@ -393,9 +744,18 @@ def main():
         else:
         else:
             output_path = output_base
             output_path = output_base
 
 
-        groups = allocate_students(students, desk_sizes, args.strategy, args.allow_mixed, seed)
-
-        generate_excel(groups, args.style, output_path, args.class_name)
+        if args.strategy == "good-with-good-advance":
+            sizes = [int(c) for c in args.style]
+            layout = plan_tiered_layout(
+                students, sizes, args.good_ratio, args.bad_ratio, seed
+            )
+            groups = layout["groups"]
+            generate_excel(groups, args.style, output_path, args.class_name)
+            stats = verify_tiered_layout(layout, sizes)
+            print_tiered_report(layout, stats, sizes)
+        else:
+            groups = allocate_students(students, desk_sizes, args.strategy, args.allow_mixed, seed)
+            generate_excel(groups, args.style, output_path, args.class_name)
 
 
     # 摘要
     # 摘要
     print(f"\n座位表配置:")
     print(f"\n座位表配置:")