Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,6 @@ cython_debug/

# Hytek test files
hytek-test-files/

# A library ships no lockfile
uv.lock
37 changes: 37 additions & 0 deletions hytek_parser/hy3/_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import math
from typing import Optional, Union

from hytek_parser._utils import safe_cast, select_from_enum
Expand Down Expand Up @@ -30,3 +31,39 @@ def parse_time_or_none(raw_time: str) -> Optional[float]:
"""
val = parse_time(raw_time)
return val if isinstance(val, float) and val > 0.0 else None


def parse_reaction_time(raw: str) -> Optional[float]:
"""Parse a reaction/takeoff-time column (E2 col 83-87, F2 col 83-102).

NEGATIVE VALUES ARE MEANINGFUL and load-bearing here: a relay takeover slot
records an early exchange as a negative number (7,868 values corpus-wide).
``parse_time_or_none`` requires > 0.0 and would silently destroy every one
of them -- do not substitute it.

Sentinels, all meaning "not recorded": blank, 0.00 in any sign spelling,
and the literal NRT ("No Reaction Time") that Meet Manager writes into
takeover slots when the exchange was not measured.

Values above the plausible reaction range are returned unchanged. A
minority of files put something else in these columns; its meaning is
unresolved, and filtering it here would make it permanently invisible.
"""
val = raw.strip()
if not val or val.upper() == "NRT":
return None
try:
num = float(val)
except ValueError:
# Observed malformed forms: a bare "+" sign, stray high bytes.
return None
if not math.isfinite(num):
# float() accepts "nan"/"inf"/"-inf", all five characters or fewer,
# so they fit this column like any other token. Neither is a
# reaction time -- and a bare int() downstream would raise on them
# (ValueError on nan, OverflowError on inf) instead of yielding None
# like every other malformed token, dropping the whole file.
return None
# float() maps "0.00", "+0.00" and "-0.00" all to zero; all three are the
# "not recorded" sentinel.
return None if num == 0.0 else num
12 changes: 12 additions & 0 deletions hytek_parser/hy3/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ class Stroke(Enum):
BREASTSTROKE = "C", "3", 3
BUTTERFLY = "D", "4", 4
MEDLEY = "E", "5", 5
# Diving. Hy-Tek encodes the BOARD in the stroke column:
# F = 1-metre springboard, G = 3-metre springboard, H = platform.
# Verified on a multi-conference corpus: the chars nest strictly
# (F, then F+G, then F+G+H -- never G without F, never H without G),
# carry the same dive count within a meet, and their median scores
# ascend with degree of difficulty.
# Without these members all three fall through select_from_enum() to
# UNKNOWN, which is the catch-all for any unrecognized byte -- making
# diving indistinguishable from file corruption.
DIVING_1M = "F"
DIVING_3M = "G"
DIVING_PLATFORM = "H"

UNKNOWN = "U", "0", 0

Expand Down
5 changes: 4 additions & 1 deletion hytek_parser/hy3/line_parsers/e_event_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Any

from hytek_parser._utils import extract, get_age_group, safe_cast, select_from_enum
from hytek_parser.hy3._utils import parse_time, parse_time_or_none
from hytek_parser.hy3._utils import parse_reaction_time, parse_time, parse_time_or_none
from hytek_parser.hy3.enums import (
Course,
DisqualificationCode,
Expand Down Expand Up @@ -117,6 +117,8 @@ def e2_parser(
button_3_time = parse_time_or_none(extract(line, 55, 8))
backup_4_time = parse_time_or_none(extract(line, 75, 8))
alt_time_code = extract(line, 96, 1) or None # observed: 'A' / 'K' / blank
# col 83-87. Signed; sentinels are blank / 0.00 in any sign spelling.
reaction_time = parse_reaction_time(extract(line, 83, 5))

raw_date = extract(line, 88, 8).strip()
date_ = datetime.strptime(raw_date, "%m%d%Y").date() if raw_date else None
Expand Down Expand Up @@ -150,6 +152,7 @@ def e2_parser(
setattr(entry, f"{prefix}_button_2_time", button_2_time)
setattr(entry, f"{prefix}_button_3_time", button_3_time)
setattr(entry, f"{prefix}_backup_4_time", backup_4_time)
setattr(entry, f"{prefix}_reaction_time", reaction_time)
setattr(entry, f"{prefix}_alt_time_code", alt_time_code)

event.last_entry = entry
Expand Down
24 changes: 19 additions & 5 deletions hytek_parser/hy3/line_parsers/f_relay_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Any

from hytek_parser._utils import extract, get_age_group, safe_cast, select_from_enum
from hytek_parser.hy3._utils import parse_time, parse_time_or_none
from hytek_parser.hy3._utils import parse_reaction_time, parse_time, parse_time_or_none
from hytek_parser.hy3.enums import (
Course,
DisqualificationCode,
Expand Down Expand Up @@ -108,16 +108,22 @@ def f2_parser(
overall_place = safe_cast(int, extract(line, 30, 4))

# previously-dropped F2 timing fields. The five timing-column
# offsets are IDENTICAL to e2_parser; only alt_time_code differs because F2
# has a 15-column gap before its date field (date at col 103, not 88).
# F2 alt_time_code lives at col 111, not col 96.
# offsets are IDENTICAL to e2_parser. F2 then carries FOUR reaction-time
# slots at cols 83-102 (5 chars each) where E2 carries one at 83-87, which
# is why F2's date sits at col 103 and its alt_time_code at col 111.
# Slot 1 is the leadoff block start; slots 2-4 are exchange takeovers and
# are legitimately negative when a swimmer leaves early.
pad_time = parse_time_or_none(extract(line, 63, 12))
button_1_time = parse_time_or_none(extract(line, 39, 8))
button_2_time = parse_time_or_none(extract(line, 47, 8))
button_3_time = parse_time_or_none(extract(line, 55, 8))
backup_4_time = parse_time_or_none(extract(line, 75, 8))
alt_time_code = extract(line, 111, 1) or None # F2 offset; observed: 'A'/'K'/blank

reaction_times = [
parse_reaction_time(extract(line, 83 + 5 * i, 5)) for i in range(4)
]

raw_date = extract(line, 103, 8).strip()
date_ = datetime.strptime(raw_date, "%m%d%Y").date() if raw_date else None

Expand Down Expand Up @@ -150,6 +156,7 @@ def f2_parser(
setattr(entry, f"{prefix}_button_2_time", button_2_time)
setattr(entry, f"{prefix}_button_3_time", button_3_time)
setattr(entry, f"{prefix}_backup_4_time", backup_4_time)
setattr(entry, f"{prefix}_reaction_times", reaction_times)
setattr(entry, f"{prefix}_alt_time_code", alt_time_code)

event.last_entry = entry
Expand All @@ -172,7 +179,14 @@ def f3_parser(
# Out of swimmers
break

swimmer = file.meet.swimmers[swimmer_meet_id]
swimmer = file.meet.swimmers.get(swimmer_meet_id)
if swimmer is None:
# The F3 leg references a swimmer meet-id with no D1 roster record in
# this file — seen in some incomplete meet exports (e.g. relay legs for
# athletes the roster section omits). Skip the leg rather than raising a
# KeyError; the relay keeps its other legs. Mirrors the tolerance the
# empty-swimmers and absent-leg-1 cases already get below.
continue
swimmer_leg = safe_cast(int, extract(line, 15 + offset, 1))

# Hy-Tek encodes legs 1..8; preserve the leg number as-is.
Expand Down
10 changes: 7 additions & 3 deletions hytek_parser/hy3/line_parsers/h_dq_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ def h1_parser(
dq_code = select_from_enum(DisqualificationCode, extract(line, 3, 2))
dq_info = extract(line, 5, 124) # Whitespace is stripped

assert (
entry.prelim_dq_info or entry.swimoff_dq_info or entry.finals_dq_info
), "There must be a DQ for there to be an H1 line"
# No-op if the entry carries no DQ slot to attach to — mirrors h2_parser.
# An H1 can appear whose last_entry is not the DQ'd swim it describes (e.g. a
# relay DQ, or a non-DQ entry emitted between the DQ result and its H1); skip
# the detail rather than raising. The DQ result itself is unaffected, only the
# human-readable reason string is dropped.
if not (entry.prelim_dq_info or entry.swimoff_dq_info or entry.finals_dq_info):
return file

if entry.finals_dq_info:
# DQ happened in prelims
Expand Down
10 changes: 9 additions & 1 deletion hytek_parser/hy3/schemas.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from datetime import date, datetime
from typing import Optional, Union
from typing import List, Optional, Union

from attrs import Factory, define, field

Expand Down Expand Up @@ -119,6 +119,8 @@ class EventEntry:
prelim_button_2_time: Optional[float] = None
prelim_button_3_time: Optional[float] = None
prelim_backup_4_time: Optional[float] = None
prelim_reaction_time: Optional[float] = None
prelim_reaction_times: Optional[List[Optional[float]]] = None
# col 96; semantics unverified — observed 'A'/'K'/blank
prelim_alt_time_code: Optional[str] = None

Expand All @@ -139,6 +141,8 @@ class EventEntry:
swimoff_button_2_time: Optional[float] = None
swimoff_button_3_time: Optional[float] = None
swimoff_backup_4_time: Optional[float] = None
swimoff_reaction_time: Optional[float] = None
swimoff_reaction_times: Optional[List[Optional[float]]] = None
# col 96; semantics unverified — observed 'A'/'K'/blank
swimoff_alt_time_code: Optional[str] = None

Expand All @@ -159,6 +163,8 @@ class EventEntry:
finals_button_2_time: Optional[float] = None
finals_button_3_time: Optional[float] = None
finals_backup_4_time: Optional[float] = None
finals_reaction_time: Optional[float] = None
finals_reaction_times: Optional[List[Optional[float]]] = None
# col 96; semantics unverified — observed 'A'/'K'/blank
finals_alt_time_code: Optional[str] = None

Expand Down Expand Up @@ -216,6 +222,8 @@ def __init__(
setattr(self, f"{course}_button_2_time", None)
setattr(self, f"{course}_button_3_time", None)
setattr(self, f"{course}_backup_4_time", None)
setattr(self, f"{course}_reaction_time", None)
setattr(self, f"{course}_reaction_times", None)
setattr(self, f"{course}_alt_time_code", None)

self.prelim_dq_info = None
Expand Down
20 changes: 19 additions & 1 deletion tests/hy3/fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ from publicly distributed meet results:
| `mm4_ymca_col92_division_citizenship.hy3` | MM4 4.0Ec | 2013 YMCA Nationals Short Course (Virginia Swimming) | E1 col-92 `meet_division` (`SW`); D1 `citizenship` (`USA`); C1 `region` (NE/MD/NI); E2 pad+button timing; F2 relay pad+button timing |
| `mm_col77_division.hy3` | MM5 6.0Cc | 2015 State/Non-State Open 25 yd (Wisconsin Swimming) | E1 col-77 `meet_division` (`JV`); E2 `alt_time_code` (`A`, `K`); pad-vs-button divergence (pad=107.39 vs btn1=102.49); C1 `region` (`WI`) |
| `mm_pad_button_divergence.hy3` | MM5 8.0Fd | 2025 MT HOT Tropical Meet (Montana Swimming) | clear pad-vs-button divergence (pad=36.26 vs result=75.29, half-pool touchpad); E2 `alt_time_code` (`A`); two LSC regions (MT, WY) |
| `mm_reaction_times_dense.hy3` | MM5 8.0Gh | 2026 CA SCS Summer A/G Champs @ BREA (Southern California Swimming) | E2 `reaction_time` densely populated (35 of 37 rows, 0.53-0.90); F2 four-slot `reaction_times` fully populated, including a relay with three negative takeovers |
| `mm_relay_nrt_sentinel.hy3` | MM5 7.0Dd | 2019 Western Zone Age Group Championships (Inland Empire Swimming) | F2 `NRT` sentinel on every takeover slot and a signed `+0.00` on every leadoff; E2 reaction column carries a signed `+0.00` on 25 of 40 rows, blank on the other 15 |

## Redaction

Expand All @@ -28,19 +30,35 @@ same column width so the files remain parseable:
- `D1` date_of_birth → `01011970`
- `C1` contact_name_1 (cols 56-85) → `Test Contact` padded to 30 chars, or blank if original was blank
- `C1` contact_name_2 (cols 86-115) → `Test Contact` padded to 30 chars, or blank if original was blank
- `C2` address_1, city, zip_code → blank (state and country retained)
- `C2` address_1, address_2, city, zip_code → blank (state and country retained)
- `C3` daytime_phone, evening_phone, fax, email → blank
- `E1` cols 9-13 (the first five characters of the swimmer's last name) → `Swimm`
- `F3` cols 9-13 of each 13-char relay-leg slot (same name prefix) → `Swimm`

The `E1`/`F3` name prefixes are not read by any parser, but they do carry a
real surname fragment, so they are redacted to match the `D1` placeholder.
The rule applies to every fixture in this directory.

Team codes, team names, meet name, facility, and event metadata are
intact — these are public information from the original meet results.

Every replacement is the same width as the field it replaces, so all lines
stay 130 characters and every column offset is preserved. Line checksums
(the trailing two characters) are left as-is; the parser does not validate
them.

## Coverage gaps (known)

`backup_4_time` (E2 col 75-82) is always zero in the three source meets;
no real file with a non-zero value was found among the designated sources. The field
is exercised by the line-parser-level tests in `test_e_event_parsers.py` but is not
covered by any integration fixture.

A minority of files put values above 2.0 in the E2 reaction column (80,706
across a 33,008-file corpus, concentrated in 117 files that are 100%
implausible). Their meaning is unresolved; the parser passes them through
unchanged and no fixture asserts on them.

## Bug references

The bugs these fixtures exercise are described in the PR that introduced
Expand Down
Loading