#!/usr/bin/env python3 """ 国家学生体质健康标准(2014年修订)— 评测引擎 按年级/性别/测试值自动计算总分、附加分、评级 满分120分 = 100标准分 + 20附加分 """ import json import os import sys BASE_DIR = '/home/songyi/Documents/ai_agent_scraper_study/data/国家学生体质健康标准' DATA_PATH = os.path.join(BASE_DIR, '02_加工数据/评分标准结构化数据.json') def load_data(path=DATA_PATH): with open(path, 'r', encoding='utf-8') as f: return json.load(f) # 年级 → 学段分组(用于匹配权重表) GRADE_TO_LEVEL = { '一年级': '小学一、二年级', '二年级': '小学一、二年级', '三年级': '小学三、四年级', '四年级': '小学三、四年级', '五年级': '小学五、六年级', '六年级': '小学五、六年级', '初一': '初中、高中、大学各年级', '初二': '初中、高中、大学各年级', '初三': '初中、高中、大学各年级', '高一': '初中、高中、大学各年级', '高二': '初中、高中、大学各年级', '高三': '初中、高中、大学各年级', '大学': '初中、高中、大学各年级', } # 年级别名映射(JSON中的key vs 用户输入的简写) GRADE_ALIAS = { '大学': '大一 大二', '大一': '大一 大二', '大二': '大一 大二', '大三': '大三 大四', '大四': '大三 大四', '大一大二': '大一 大二', # 兼容无空格格式 '大三大四': '大三 大四', } # 年龄 → 年级映射(简化) AGE_TO_GRADE = { 6: '一年级', 7: '二年级', 8: '三年级', 9: '四年级', 10: '五年级', 11: '六年级', 12: '初一', 13: '初二', 14: '初三', 15: '高一', 16: '高二', 17: '高三', 18: '大学', 19: '大学', 20: '大学', 21: '大学', 22: '大学', } # 权重指标名 → 评分指标名映射(处理文档中的长名称/脏文本) WEIGHT_INDICATOR_MAP = { '体重指数(BMI)': 'BMI', '肺活量': '肺活量', '50米跑': '50米跑', '坐位体前屈': '坐位体前屈', '1分钟跳绳': '1分钟跳绳', '1分钟仰卧起坐': '1分钟仰卧起坐', '引体向上': '引体向上', '立定跳远': '立定跳远', '耐力跑': '耐力跑', '50米×8往返跑': '耐力跑', '1000米跑(男)/800米跑(女)': '耐力跑', } def normalize_grade(grade): """标准化年级名""" return GRADE_ALIAS.get(grade, grade) def get_grade_for_level(grade, table): """在表格的年级列中查找匹配的年级名""" grade_normalized = normalize_grade(grade) grade_stripped = grade_normalized.replace(' ', '') # 直接匹配 for gn in table['grade_names']: if gn == grade_normalized: return gn # 去空格匹配 for gn in table['grade_names']: if gn.replace(' ', '') == grade_stripped: return gn # 部分匹配 for gn in table['grade_names']: if grade_normalized in gn or gn in grade_normalized: return gn # 模糊匹配 for gn in table['grade_names']: for entry in table['data']: if grade in entry['grades'] or grade_normalized in entry['grades']: return grade for eg in entry['grades']: if grade in eg or grade_normalized in eg: return eg return grade def age_to_grade(age): """年龄转年级""" age_int = int(age) if age_int in AGE_TO_GRADE: return AGE_TO_GRADE[age_int] if age_int < 6: return '一年级' if age_int <= 11: return ['一年级','二年级','三年级','四年级','五年级','六年级'][age_int - 6] if age_int <= 14: return ['初一','初二','初三'][age_int - 12] if age_int <= 17: return ['高一','高二','高三'][age_int - 15] return '大学' def get_level_for_grade(grade): """年级 → 学段名(权重表key)""" return GRADE_TO_LEVEL.get(grade, '小学一、二年级') # 指标方向:越高越好(+) 还是越低越好(-) INDICATOR_DIRECTION = { 'BMI': 'category', '肺活量': '+', '坐位体前屈': '+', '立定跳远': '+', '1分钟跳绳': '+', '1分钟仰卧起坐': '+', '引体向上': '+', '50米跑': '-', '耐力跑': '-', } def find_in_scoring_table(table, grade, value, direction='+'): """在评分表中查找得分 学生标准采用阈值制:exact值为达标的门槛值 - 越高越好(+):找最高得分,其中value >= exact - 越低越好(-):找最高得分,其中value <= exact - 'category':按分类匹配 """ sorted_entries = sorted(table['data'], key=lambda x: x['score'], reverse=True) for entry in sorted_entries: grade_data = entry.get('grades', {}).get(grade) if not grade_data: continue score = entry['score'] r = grade_data # 尝试解析raw时间格式: "3'40\"" → 220秒 if 'raw' in r: parsed = parse_time_to_seconds(r['raw']) if parsed is not None: if direction == '-' and value <= parsed: return score elif direction == '+' and value >= parsed: return score if 'min' in r and 'max' in r: if r['min'] <= value <= r['max']: return score elif 'min' in r and value >= r['min']: return score elif 'max' in r and value <= r['max']: return score elif 'exact' in r: if direction == '+' and value >= r['exact']: return score elif direction == '-' and value <= r['exact']: return score elif direction == 'category' and abs(value - r['exact']) < 0.01: return score return 0 def parse_time_to_seconds(raw): """解析时间格式: \"3'40\\\"\" → 220 秒""" if not raw: return None raw = raw.strip().replace('"', '').replace("'", ' ').replace('′', ' ').replace('″', '') parts = raw.split() if len(parts) == 2: try: return int(parts[0]) * 60 + float(parts[1]) except: pass elif len(parts) == 1: try: return int(parts[0]) except: pass return None def find_bonus_in_table(table, grade, value): """在加分表中查找加分 table['data'] = [{'bonus_score': 5, 'grades': {'一年级': {'min': 20, 'max': 25}, ...}}, ...] 加分逻辑:超过100分对应的值后,每多一个档次加对应分数 """ total_bonus = 0 for entry in table['data']: grade_data = entry.get('grades', {}).get(grade) if not grade_data: continue bonus = entry['bonus_score'] r = grade_data matched = False if 'min' in r and 'max' in r: if r['min'] <= value <= r['max']: matched = True elif 'min' in r and value >= r['min']: matched = True elif 'max' in r and value <= r['max']: matched = True elif 'exact' in r and abs(value - r['exact']) < 0.01: matched = True if matched: total_bonus = max(total_bonus, bonus) return total_bonus def evaluate_student(age, gender, grade=None, test_values=None, data=None): """ 评测一个学生 参数: age: 年龄(周岁) gender: '男' 或 '女' grade: 年级(如'一年级','初一','高一','大学'),不传则从年龄推断 test_values: dict BMI, 肺活量(ml), 50米跑(秒), 坐位体前屈(cm), 1分钟跳绳(次) — 小学 立定跳远(cm) — 初中以上 引体向上(次) — 初中以上男 1分钟仰卧起坐(次) — 三年级以上女/小学 耐力跑(秒) — 五年级以上 """ if data is None: data = load_data() if grade is None: grade = age_to_grade(age) gender_key = '男' if gender in ['男', 'male', 'M'] else '女' results = { 'age': age, 'gender': gender_key, 'grade': grade, 'level': get_level_for_grade(grade), 'standard': '国家学生体质健康标准(2014年修订)', 'base_score': 0, 'bonus_score': 0, 'total_score': 0, 'rating': '', 'indicators': {}, 'bonus_detail': {} } # 获取权重表 level_name = get_level_for_grade(grade) weights = data['weight_table'].get(level_name, {}) # 加上通用权重(BMI和肺活量是全部适用) if level_name != '小学一年级至大学四年级': common = data['weight_table'].get('小学一年级至大学四年级', {}) for k, v in common.items(): if k not in weights: weights[k] = v if not test_values: test_values = {} # 指标别名映射 alias = { '肺活量(ml)': '肺活量', '肺活量': '肺活量', '50米跑(秒)': '50米跑', '50米跑': '50米跑', '坐位体前屈(cm)': '坐位体前屈', '坐位体前屈': '坐位体前屈', '1分钟跳绳(次)': '1分钟跳绳', '1分钟跳绳': '1分钟跳绳', '1分钟仰卧起坐(次)': '1分钟仰卧起坐', '1分钟仰卧起坐': '1分钟仰卧起坐', '立定跳远(cm)': '立定跳远', '立定跳远': '立定跳远', '引体向上(次)': '引体向上', '引体向上': '引体向上', '耐力跑(秒)': '耐力跑', '耐力跑': '耐力跑', '1000米跑(秒)': '耐力跑', '800米跑(秒)': '耐力跑', '50米×8往返跑(秒)': '耐力跑', } # 评分表索引映射:表1-x → 指标名+性别 table_index_map = {} for t in data['scoring_tables']: ti = t['table_index'] gn = t['grade_names'] # 通过表index和年级范围推断指标 table_index_map[ti] = t # 指标→表索引映射(手动) indicator_table_map = { ('BMI', '男'): 1, ('BMI', '女'): 2, ('肺活量', '男'): 3, ('肺活量', '女'): 4, ('50米跑', '男'): 5, ('50米跑', '女'): 6, ('坐位体前屈', '男'): 7, ('坐位体前屈', '女'): 8, ('1分钟跳绳', '男'): 9, ('1分钟跳绳', '女'): 10, ('立定跳远', '男'): 11, ('立定跳远', '女'): 12, ('1分钟仰卧起坐/引体向上', '男'): 13, ('引体向上', '男'): 13, ('1分钟仰卧起坐', '女'): 14, ('耐力跑', '男'): 15, ('耐力跑', '女'): 16, } # 加分表索引映射 bonus_table_map = { ('1分钟跳绳', '男'): 17, ('1分钟跳绳', '女'): 18, ('引体向上', '男'): 19, ('1分钟仰卧起坐', '女'): 20, ('耐力跑', '男'): 21, ('耐力跑', '女'): 22, } # 1. 处理输入值并逐项评分 processed = {} for raw_key, value in test_values.items(): if value is None or value == '': continue try: value = float(value) except (ValueError, TypeError): continue indicator = alias.get(raw_key, raw_key) processed[indicator] = value # BMI特殊处理(可能没直接传入,从身高体重计算) if 'BMI' not in processed: height_cm = test_values.get('身高(cm)', test_values.get('身高')) weight_kg = test_values.get('体重(kg)', test_values.get('体重')) if height_cm and weight_kg: bmi = weight_kg / ((height_cm / 100) ** 2) processed['BMI'] = round(bmi, 1) # 2. 评分 base_total = 0.0 weight_used = 0.0 for indicator, value in processed.items(): # 查评分表 table_key = (indicator, gender_key) ti = indicator_table_map.get(table_key) table = table_index_map.get(ti) if ti else None if not table: continue # 使用通配年级匹配 match_grade = get_grade_for_level(grade, table) direction = INDICATOR_DIRECTION.get(indicator, '+') score = find_in_scoring_table(table, match_grade, value, direction) # 查找该指标权重(用WEIGHT_INDICATOR_MAP做模糊匹配) indicator_weight = 0 for wname, wval in weights.items(): mapped_indicator = WEIGHT_INDICATOR_MAP.get(wname, wname) if indicator == mapped_indicator or indicator in wname or mapped_indicator == indicator: indicator_weight = wval / 100.0 break weighted = score * indicator_weight base_total += weighted weight_used += indicator_weight results['indicators'][indicator] = { 'value': value, 'score': score, 'weight': indicator_weight, 'weighted': round(weighted, 1) } results['base_score'] = round(base_total, 1) # 3. 附加分 total_bonus = 0 for indicator, value in processed.items(): bti = bonus_table_map.get((indicator, gender_key)) btable = table_index_map.get(bti) if bti else None if not btable: continue # 检查该指标原始得分是否已达到100分(即需要先拿到100分才有加分资格) # 但在评分表中,低年级跳绳最高分可能是100对应的值 # 加分表只对超过100分阈值的额外成绩加分 bonus = find_bonus_in_table(btable, grade, value) if bonus > 0: if indicator == '1分钟跳绳': max_bonus = 20 else: max_bonus = 10 total_bonus += min(bonus, max_bonus) results['bonus_detail'][indicator] = min(bonus, max_bonus) results['bonus_score'] = min(total_bonus, 20) results['total_score'] = round(results['base_score'] + results['bonus_score'], 1) # 4. 评级 ts = results['total_score'] if ts >= 90: results['rating'] = '优秀' elif ts >= 80: results['rating'] = '良好' elif ts >= 60: results['rating'] = '及格' else: results['rating'] = '不及格' results['weight_coverage'] = round(weight_used, 2) return results def format_report(result): lines = [] lines.append("=" * 60) lines.append(" 国家学生体质健康标准(2014年修订)— 评测报告") lines.append("=" * 60) lines.append(f" 年级:{result['grade']} 性别:{result['gender']}") lines.append(f" 年龄:{result['age']}岁 学段:{result['level']}") lines.append("-" * 60) lines.append(f" {'指标':<18} {'实测值':<10} {'得分':<6} {'权重':<6} {'加权分':<8}") lines.append("-" * 60) for ind, info in result['indicators'].items(): val = info['value'] val_str = f"{val:.1f}" if isinstance(val, (int, float)) else str(val) w_str = f"{info['weight']:.2f}" if info['weight'] > 0 else '-' wp_str = f"{info['weighted']:.1f}" if info['weight'] > 0 else '-' lines.append(f" {ind:<18} {val_str:<10} {info['score']:<6} {w_str:<6} {wp_str:<8}") lines.append("-" * 60) lines.append(f" 标准分:{result['base_score']}") if result['bonus_score'] > 0: lines.append(f" 附加分:+{result['bonus_score']}") for ind, bonus in result['bonus_detail'].items(): lines.append(f" {ind} 加分:+{bonus}") lines.append(f" 总分(满分120):{result['total_score']}") lines.append(f" 权重覆盖率:{result['weight_coverage']:.0%}") lines.append(f" 评级:{result['rating']}") lines.append("=" * 60) return '\n'.join(lines) def demo(): data = load_data() demos = [ (10, '男', '四年级', {'BMI': 17, '肺活量(ml)': 1800, '50米跑(秒)': 9.5, '坐位体前屈(cm)': 8, '1分钟跳绳(次)': 90, '1分钟仰卧起坐(次)': 25}), (15, '男', '初三', {'BMI': 21, '肺活量(ml)': 3200, '50米跑(秒)': 7.8, '坐位体前屈(cm)': 6, '立定跳远(cm)': 220, '引体向上(次)': 8, '耐力跑(秒)': 255}), (13, '女', '初二', {'身高(cm)': 160, '体重(kg)': 50, '肺活量(ml)': 2500, '50米跑(秒)': 8.5, '坐位体前屈(cm)': 14, '立定跳远(cm)': 180, '1分钟仰卧起坐(次)': 30, '耐力跑(秒)': 240}), ] for i, (age, gender, grade, tests) in enumerate(demos): print(f"\n{'='*60}") print(f" 演示{i+1}:{grade}{'男' if gender=='男' else '女'},{age}岁") print(f"{'='*60}") r = evaluate_student(age, gender, grade, tests, data) print(format_report(r)) def interactive(): data = load_data() print("国家学生体质健康标准(2014年修订)— 交互式评测") print("=" * 50) age = int(input("年龄(周岁): ") or 0) grade = input("年级(如'初一',直接回车从年龄推断): ").strip() or None gender = input("性别(男/女): ").strip() if gender not in ['男', '女']: print("性别输入错误") return if grade is None: grade = age_to_grade(age) grade = grade.replace(' ', '') print(f"\n→ 年级:{grade}({get_level_for_grade(grade)}组)") # 提示输入 test_values = {} prompts = { 'BMI': '体重指数(BMI)(直接回车从身高体重计算)', '身高(cm)': '身高(cm)', '体重(kg)': '体重(kg)', '肺活量(ml)': '肺活量(ml)', '50米跑(秒)': '50米跑(秒)', '坐位体前屈(cm)': '坐位体前屈(cm)', '1分钟跳绳(次)': '1分钟跳绳(次)', '1分钟仰卧起坐(次)': '1分钟仰卧起坐(次)', '立定跳远(cm)': '立定跳远(cm)', '引体向上(次)': '引体向上(次)', '耐力跑(秒)': '耐力跑(秒)(如1000米跑238秒则输入238)', } print("\n请输入测试值(直接回车跳过):") for key, prompt in prompts.items(): val = input(f" {prompt}: ").strip() if val: try: test_values[key] = float(val) except ValueError: test_values[key] = val result = evaluate_student(age, gender, grade, test_values, data) print() print(format_report(result)) if __name__ == '__main__': if len(sys.argv) > 1 and sys.argv[1] in ('--interactive', '-i'): interactive() else: demo()