525 lines
18 KiB
Python
525 lines
18 KiB
Python
#!/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()
|