Files

525 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()