256 lines
8.0 KiB
Python
256 lines
8.0 KiB
Python
"""
|
|
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)
|