refactoring
This commit is contained in:
255
core/field_calculator.py
Normal file
255
core/field_calculator.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
core/field_calculator.py — 타구 좌표/거리/수비 시퀀스 계산
|
||||
|
||||
필드 좌표 기반의 타구 처리 로직. Playwright 의존성 없음.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from core.config_loader import (
|
||||
field_coordinates,
|
||||
hit_ball_type_map,
|
||||
foul_fly_coords,
|
||||
position_number_map,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 타구 종류 추론
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def infer_hit_ball_type(result_text: str) -> str:
|
||||
"""결과 텍스트에서 타구 종류 추론
|
||||
|
||||
'2루수 땅볼 아웃' → '땅볼'
|
||||
'좌익수 뒤 2루타' → '일반바운드'
|
||||
"""
|
||||
if "번트" in result_text:
|
||||
return "번트타구"
|
||||
if "몸에 맞는 타구" in result_text:
|
||||
return "땅볼"
|
||||
if "파울희생플라이" in result_text or "파울 희생플라이" in result_text:
|
||||
return "플라이"
|
||||
if "파울플라이" in result_text:
|
||||
return "플라이"
|
||||
if "라인드라이브" in result_text or "직선타" in result_text:
|
||||
return "라인드라이브"
|
||||
if "플라이" in result_text:
|
||||
return "플라이"
|
||||
if "땅볼" in result_text:
|
||||
return "땅볼"
|
||||
if "홈런" in result_text:
|
||||
return "홈런성타구"
|
||||
return "일반바운드"
|
||||
|
||||
|
||||
def get_hit_ball_type_code(hit_ball_type: str) -> str:
|
||||
"""타구 종류 라벨 → 사이트 value 코드"""
|
||||
return hit_ball_type_map().get(hit_ball_type, "1")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 필드 존 추론
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
ORDERED_ZONES = (
|
||||
"좌중간", "우중간",
|
||||
"좌전", "중전", "우전",
|
||||
"좌월", "중월", "우월",
|
||||
"좌익수", "중견수", "우익수",
|
||||
"유격수", "3루수", "2루수", "1루수",
|
||||
"투수", "포수",
|
||||
)
|
||||
|
||||
|
||||
def infer_field_zone(result_text: str) -> str:
|
||||
"""결과 텍스트에서 타구 방향(zone) 추론
|
||||
|
||||
'우익수 앞 1루타' → '우익수'
|
||||
"""
|
||||
if "몸에 맞는 타구" in result_text:
|
||||
return "1루수"
|
||||
for zone in ORDERED_ZONES:
|
||||
if zone in result_text:
|
||||
return zone
|
||||
return "중견수"
|
||||
|
||||
|
||||
def extract_direction_offsets(result_text: str) -> tuple[int, int]:
|
||||
"""결과 텍스트에서 방향 오프셋 추출
|
||||
|
||||
'좌익수 왼쪽 뒤' → (-1, -1)
|
||||
"""
|
||||
x_delta = 0
|
||||
y_delta = 0
|
||||
if "왼쪽" in result_text:
|
||||
x_delta -= 1
|
||||
if "오른쪽" in result_text:
|
||||
x_delta += 1
|
||||
if "앞" in result_text:
|
||||
y_delta += 1
|
||||
if "뒤" in result_text:
|
||||
y_delta -= 1
|
||||
return x_delta, y_delta
|
||||
|
||||
|
||||
def is_infield_zone(zone: str) -> bool:
|
||||
"""내야 존인지 확인"""
|
||||
return zone in {"투수", "포수", "1루수", "2루수", "3루수", "유격수"}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 좌표 계산
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def get_zone_coordinates(zone: str) -> tuple[int, int]:
|
||||
"""존 이름 → (x, y) 퍼센트 좌표"""
|
||||
coords = field_coordinates()
|
||||
coord = coords.get(zone, coords.get("중견수", [50, 24]))
|
||||
return tuple(coord)
|
||||
|
||||
|
||||
def get_foul_fly_coordinates(side: str) -> tuple[int, int]:
|
||||
"""파울 플라이 좌표 ('left' 또는 'right')"""
|
||||
coords = foul_fly_coords()
|
||||
return tuple(coords.get(side, [50, 70]))
|
||||
|
||||
|
||||
def calculate_hit_ball_coordinates(
|
||||
result_text: str,
|
||||
zone: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""결과 텍스트로부터 타구 좌표 계산
|
||||
|
||||
Returns: (x, y) 퍼센트 좌표
|
||||
"""
|
||||
if zone is None:
|
||||
zone = infer_field_zone(result_text)
|
||||
|
||||
x, y = get_zone_coordinates(zone)
|
||||
x_delta, y_delta = extract_direction_offsets(result_text)
|
||||
|
||||
infield = is_infield_zone(zone)
|
||||
step = 3 if infield else 5
|
||||
|
||||
x += x_delta * step
|
||||
y += y_delta * step
|
||||
|
||||
# 범위 제한
|
||||
x = max(0, min(100, x))
|
||||
y = max(0, min(100, y))
|
||||
|
||||
return x, y
|
||||
|
||||
|
||||
def calculate_distance(x: int, y: int, meter_per_px: float) -> float:
|
||||
"""좌표에서 홈까지의 거리 계산 (미터)"""
|
||||
home_x, home_y = 50, 93
|
||||
dx = (x - home_x) * meter_per_px
|
||||
dy = (y - home_y) * meter_per_px
|
||||
return math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 수비 시퀀스 추출
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _position_label_map() -> dict[str, str]:
|
||||
"""번호 → 포지션명 역매핑"""
|
||||
return {v: k for k, v in position_number_map().items()}
|
||||
|
||||
|
||||
def extract_defense_sequence(result_text: str) -> list[str]:
|
||||
"""결과 텍스트에서 수비 시퀀스 추출
|
||||
|
||||
'2루수 땅볼 아웃 (2루수->1루수 송구아웃)' → ['2루수', '1루수']
|
||||
"""
|
||||
pos_label = _position_label_map()
|
||||
|
||||
# 1) '2-6', '2-5-3' 같은 숫자 패턴
|
||||
num_seq_match = re.search(r"(\d+(?:-\d+)+)", result_text)
|
||||
if num_seq_match:
|
||||
nums = num_seq_match.group(1).split("-")
|
||||
pos_names = [pos_label[n] for n in nums if n in pos_label]
|
||||
if pos_names:
|
||||
return pos_names
|
||||
|
||||
# 2) 괄호 안에서 포지션 추출
|
||||
parenthetical_match = re.search(r"\(([^)]*)\)", result_text)
|
||||
if parenthetical_match:
|
||||
sequence = re.findall(
|
||||
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
|
||||
parenthetical_match.group(1),
|
||||
)
|
||||
if sequence:
|
||||
return sequence
|
||||
|
||||
# 3) 괄호 앞 본문에서 포지션 추출
|
||||
leading_text = result_text.split("(", 1)[0]
|
||||
sequence = re.findall(
|
||||
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
|
||||
leading_text,
|
||||
)
|
||||
if sequence:
|
||||
return sequence
|
||||
|
||||
# 4) 존에서 폴백
|
||||
zone = infer_field_zone(result_text)
|
||||
pos_num = position_number_map()
|
||||
if zone in pos_num:
|
||||
return [zone]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def extract_error_position(result_text: str) -> str | None:
|
||||
"""실책 관련 텍스트에서 실책 수비자 포지션 추출"""
|
||||
parenthetical_match = re.search(r"\(([^)]*실책[^)]*)\)", result_text)
|
||||
search_texts = [parenthetical_match.group(1)] if parenthetical_match else []
|
||||
search_texts.append(result_text)
|
||||
for text in search_texts:
|
||||
positions = re.findall(
|
||||
r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)",
|
||||
text,
|
||||
)
|
||||
if positions:
|
||||
return positions[0]
|
||||
return None
|
||||
|
||||
|
||||
def infer_error_position_fallback(text: str) -> str:
|
||||
"""실책 포지션 추론 폴백"""
|
||||
if "야수선택" in text:
|
||||
return "야수선택"
|
||||
if "도루" in text:
|
||||
return "포수"
|
||||
if "포구" in text:
|
||||
return "포수"
|
||||
if "송구" in text:
|
||||
return "투수"
|
||||
return "포수"
|
||||
|
||||
|
||||
def is_error_result(result_text: str) -> bool:
|
||||
"""실책 결과인지 확인"""
|
||||
return "실책" in result_text
|
||||
|
||||
|
||||
def is_throwing_error(result_text: str) -> bool:
|
||||
"""송구 실책인지 확인"""
|
||||
keywords = ("송구실책", "송구 실책", "악송구", "throwing error", "송구에러")
|
||||
return any(keyword in result_text for keyword in keywords)
|
||||
|
||||
|
||||
def is_double_play_result(result_text: str) -> bool:
|
||||
"""병살인지 확인"""
|
||||
return "병살" in result_text
|
||||
|
||||
|
||||
def build_double_play_first_sequence(event: dict[str, Any]) -> list[str]:
|
||||
"""병살 이벤트의 첫 번째 수비 시퀀스"""
|
||||
result_text = ((event.get("result") or {}).get("text") or "").strip()
|
||||
return extract_defense_sequence(result_text)
|
||||
Reference in New Issue
Block a user