From a4660b933bc51a67b4a191e75ab51c6a3187f860 Mon Sep 17 00:00:00 2001 From: Felipe Salum Date: Mon, 7 Sep 2026 11:17:22 -0700 Subject: [PATCH 1/2] feat(hy3): keep each entry's own distance, stroke, course and column-96 flag An Event is keyed by event number alone, so when one number carries more than one distance the Event holds only the first distance seen and every later entry loses its own. Two real export shapes do this: - a combined-distance event, where swimmers in one event number choose between distances (400 or 500, 1000 or 1650, 25 or 100); and - a split-request entry: Meet Manager records an intermediate split as its own official time by writing a second E1 for the same event number with the shorter distance, fee 0.00, seed 0.00 and an "S" (sometimes "T") in column 96, followed by an E2 carrying the split time. EventEntry now carries `distance`, `stroke`, `course` and `entry_flag` read from its own E1/F1 line, and the entry's distance joins the same-swimmer identity so a split-request entry is never folded into the swimmer's main entry for that event (prelim + finals of one swim still merge as before). Event fields are unchanged; consumers that only read Event.distance see no difference. --- .../hy3/line_parsers/e_event_parsers.py | 7 ++ .../hy3/line_parsers/f_relay_parsers.py | 5 ++ hytek_parser/hy3/schemas.py | 38 +++++++++- .../hy3/line_parsers/test_e_event_parsers.py | 74 +++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) diff --git a/hytek_parser/hy3/line_parsers/e_event_parsers.py b/hytek_parser/hy3/line_parsers/e_event_parsers.py index 3a70b93..574671d 100644 --- a/hytek_parser/hy3/line_parsers/e_event_parsers.py +++ b/hytek_parser/hy3/line_parsers/e_event_parsers.py @@ -68,6 +68,7 @@ def e1_parser( # The two are mutually exclusive; col 77 takes precedence. meet_division = extract(line, 77, 3) or extract(line, 92, 2) or None exhibition = extract(line, 84, 1) == "X" + entry_flag = extract(line, 96, 1) or None entry = event.get_or_create_entry( swimmers=entry_swimmers, @@ -79,6 +80,12 @@ def e1_parser( converted_seed_time_course=entry_converted_seed_time_course, meet_division=meet_division, exhibition=exhibition, + # The entry's own event fields (see EventEntry): the Event is keyed by + # number alone and keeps only the first distance seen for it. + distance=distance, + stroke=stroke, + course=event_course, + entry_flag=entry_flag, ) # Update event diff --git a/hytek_parser/hy3/line_parsers/f_relay_parsers.py b/hytek_parser/hy3/line_parsers/f_relay_parsers.py index ac8853c..12a7471 100644 --- a/hytek_parser/hy3/line_parsers/f_relay_parsers.py +++ b/hytek_parser/hy3/line_parsers/f_relay_parsers.py @@ -79,6 +79,11 @@ def f1_parser( converted_seed_time_course=entry_converted_seed_time_course, relay_team_id=relay_team, relay_swim_team_code=team_code, + # The entry's own event fields (see EventEntry). + distance=distance, + stroke=stroke, + course=event_course, + entry_flag=extract(line, 96, 1) or None, ) # Update event diff --git a/hytek_parser/hy3/schemas.py b/hytek_parser/hy3/schemas.py index 555b864..7dd6514 100644 --- a/hytek_parser/hy3/schemas.py +++ b/hytek_parser/hy3/schemas.py @@ -180,6 +180,22 @@ class EventEntry: meet_division: Optional[str] = None exhibition: bool = False + # The entry's OWN event fields, as written on its E1/F1 line (cols 16-22 + # and 51). An Event is keyed by event number alone, so when one number + # carries more than one distance -- a combined-distance event where + # swimmers choose 400 or 500, 1000 or 1650, or a split-request entry that + # records an intermediate split as its own official time -- the Event's + # distance is only the first one seen. These fields say what THIS entry + # swam. None on entries built before the parser set them. + distance: Optional[float] = None + stroke: Optional[Stroke] = None + course: Optional[Course] = None + # Col 96 of E1/F1. Observed values: "S" and "T" on split-request entries + # (an intermediate split recorded as an official time for a shorter + # distance; fee and seed are 0 and the distance differs from the event's). + # Blank on ordinary entries. Semantics of "S" vs "T" are not documented. + entry_flag: Optional[str] = None + def __init__( self, swimmers: dict[int, Swimmer], @@ -193,6 +209,10 @@ def __init__( relay_swim_team_code: Optional[str] = None, meet_division: Optional[str] = None, exhibition: bool = False, + distance: Optional[float] = None, + stroke: Optional[Stroke] = None, + course: Optional[Course] = None, + entry_flag: Optional[str] = None, ) -> None: self.swimmers = swimmers self.relay = relay @@ -205,6 +225,10 @@ def __init__( self.relay_swim_team_code = relay_swim_team_code self.meet_division = meet_division self.exhibition = exhibition + self.distance = distance + self.stroke = stroke + self.course = course + self.entry_flag = entry_flag for course in ("prelim", "swimoff", "finals"): setattr(self, f"{course}_time", None) @@ -237,7 +261,8 @@ def same_swimmer_entry_as(self, other: "EventEntry") -> bool: relay_swim_team_code, seed_time, seed_course) — not swimmers, which are populated later by F3. For individuals the identity is the existing (swimmers, event_number, - seed_time, seed_course, converted_seed_time, converted_seed_time_course). + seed_time, seed_course, converted_seed_time, converted_seed_time_course) + plus the entry's own distance. """ if self.relay or other.relay: return ( @@ -250,6 +275,9 @@ def same_swimmer_entry_as(self, other: "EventEntry") -> bool: ) return ( self.swimmers == other.swimmers + # An entry at a different distance is a different swim, even for + # the same swimmer in the same event (a split-request entry). + and self.distance == other.distance and self.event_number == other.event_number and self.seed_time == other.seed_time and self.seed_course == other.seed_course @@ -296,6 +324,10 @@ def get_or_create_entry( relay_swim_team_code: Optional[str] = None, meet_division: Optional[str] = None, exhibition: bool = False, + distance: Optional[float] = None, + stroke: Optional[Stroke] = None, + course: Optional[Course] = None, + entry_flag: Optional[str] = None, ) -> EventEntry: """Get an event entry or create one if needed.""" entry = EventEntry( @@ -310,6 +342,10 @@ def get_or_create_entry( relay_swim_team_code=relay_swim_team_code, meet_division=meet_division, exhibition=exhibition, + distance=distance, + stroke=stroke, + course=course, + entry_flag=entry_flag, ) if self.entries and self.entries[-1].same_swimmer_entry_as(entry): # P/F entries always listed together: a swimmer (individuals) or a diff --git a/tests/hy3/line_parsers/test_e_event_parsers.py b/tests/hy3/line_parsers/test_e_event_parsers.py index e0b9b48..57e20e5 100644 --- a/tests/hy3/line_parsers/test_e_event_parsers.py +++ b/tests/hy3/line_parsers/test_e_event_parsers.py @@ -4,6 +4,7 @@ from hytek_parser.hy3.line_parsers.d_swimmer_parsers import d1_parser from hytek_parser.hy3.line_parsers.e_event_parsers import e1_parser, e2_parser from hytek_parser.hy3.schemas import Meet, Team, Gender, Stroke +from hytek_parser.hy3.enums import Course class TestEEventParser(unittest.TestCase): @@ -369,3 +370,76 @@ def test_non_dq_clears_anchor(self): if __name__=='__main__': unittest.main() + + +class TestEntryOwnEventFields(unittest.TestCase): + """An entry keeps the distance/stroke/course written on its own E1 line. + + Events are keyed by event number, so when one number carries more than + one distance the Event holds only the first distance seen. The entry's + own fields are what that entry actually swam. + """ + + def _file(self): + opts = {"default_country": "USA"} + file = ParsedHytekFile() + file.meet = Meet() + file.meet.last_team = ("FOO", Team("Foo Bar", "FOO", "foo","","","","","","","","","","","",{})) + d_line = "D1F 27Hansen Mads 10272010 13 27" + return d1_parser(d_line, file, opts), opts + + def test_entry_carries_its_own_fields(self): + file, opts = self._file() + e_line = "E1F 27HanseFG 1000A 13 14 0S 4.25 7A 715.47Y 715.47Y 3.00 0.00 NN N " + file = e1_parser(e_line, file, opts) + entry = file.meet.events["7A"].last_entry + self.assertEqual(1000.0, entry.distance) + self.assertEqual(Stroke.FREESTYLE, entry.stroke) + self.assertEqual(Course.SCY, entry.course) + self.assertIsNone(entry.entry_flag) + + def test_split_request_entry_is_a_second_entry_at_its_own_distance(self): + """A meet-management export can record an intermediate split as its + own official time: a second E1 for the same event number with the + shorter distance, fee 0, seed 0 and an "S" flag in column 96, followed + by an E2 carrying the split time. It must land as its own entry at + its own distance, while the Event keeps the distance it was created + with.""" + file, opts = self._file() + main = "E1F 27HanseFG 1000A 13 14 0S 4.25 7A 715.47Y 715.47Y 3.00 0.00 NN N " + e2_main = "E2F 707.50Y 0 4 7 6 14 0 707.56 707.49 707.52 707.50 0.00 12032009 " + split = "E1F 27HanseFG 500A 13 18 0A 0.00 7A 0.00 0.00 0.00 0.00 NN SN " + e2_split = "E2F 351.55Y 0 1 6 3 2 0 0.00 0.00 0.00 0.00 0.00 " + for ln, fn in ((main, e1_parser), (e2_main, e2_parser), (split, e1_parser), (e2_split, e2_parser)): + file = fn(ln, file, opts) + event = file.meet.events["7A"] + self.assertEqual(1000.0, event.distance) + self.assertEqual(2, len(event.entries)) + first, second = event.entries + self.assertEqual((1000.0, None, 707.50), (first.distance, first.entry_flag, first.finals_time)) + self.assertEqual((500.0, "S", 351.55), (second.distance, second.entry_flag, second.finals_time)) + + def test_prelim_and_finals_of_one_swim_still_merge(self): + """The distance joins the entry identity; a prelim + finals re-listing + at the same distance still folds into one entry as before.""" + file, opts = self._file() + p = "E1F 27HanseFG 100A 13 14 0S 4.25 71 61.05Y 61.05Y 1.00 0.00 NN N " + e2p = "E2P 59.85Y 0 3 1 6 16 0 59.83 60.01 59.65 59.85 0.00 12052009 " + e2f = "E2F 59.26Y 0 1 8 8 16 0 59.34 59.25 59.25 59.26 0.00 12052009 " + file = e1_parser(p, file, opts); file = e2_parser(e2p, file, opts) + file = e1_parser(p, file, opts); file = e2_parser(e2f, file, opts) + event = file.meet.events["71"] + self.assertEqual(1, len(event.entries)) + self.assertEqual((59.85, 59.26), (event.entries[0].prelim_time, event.entries[0].finals_time)) + + def test_combined_distance_event_keeps_each_entrys_distance(self): + """One event number, two distances (swimmers choose 400 or 500).""" + file, opts = self._file() + d2 = "D1F 28Smith Jane 10272010 13 28" + file = d1_parser(d2, file, opts) + a = "E1F 27HanseFG 500A 13 14 0S 4.25 41C 356.02Y 356.02Y 1.00 0.00 NN N " + b = "E1F 28SmithFG 400A 13 14 0S 4.25 41C 290.00L 290.00L 1.00 0.00 NN N " + file = e1_parser(a, file, opts); file = e1_parser(b, file, opts) + event = file.meet.events["41C"] + self.assertEqual(500.0, event.distance) + self.assertEqual([500.0, 400.0], [e.distance for e in event.entries]) From 15364792407e96bf674fce905b684a613cc7fd81 Mon Sep 17 00:00:00 2001 From: Felipe Salum Date: Mon, 7 Sep 2026 12:10:03 -0700 Subject: [PATCH 2/2] fix(hy3): de-wrap the two-character G1 split index The G1 split index is a two-character field and wraps at 100. A swim with more than 49 split slots -- a 1500 LCM or 1650 SCY recorded every 25 -- continues "...F96 ... F00 ... F04 ..." on the next G1 line; read literally, the wrapped indexes (0, 4, 8, ...) collide with the early ones and overwrite them, so such a swim kept 13 of its 30 splits and its finish landed under key 20. Indexes are monotonic within an entry, so a value at or below the highest already seen for that slot is taken as wrapped and advanced by 100. Swims that never reach index 100 are unaffected. --- .../hy3/line_parsers/g_split_parsers.py | 34 ++++++++++------ .../hy3/line_parsers/test_e_event_parsers.py | 40 +++++++++++++++++++ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/hytek_parser/hy3/line_parsers/g_split_parsers.py b/hytek_parser/hy3/line_parsers/g_split_parsers.py index dc0d852..49680b9 100644 --- a/hytek_parser/hy3/line_parsers/g_split_parsers.py +++ b/hytek_parser/hy3/line_parsers/g_split_parsers.py @@ -15,26 +15,34 @@ def g1_parser( event_num, event = file.meet.last_event entry = event.last_entry - # Read splits - line_pos = 4 - splits: dict[int, float] = {} - while line_pos < 124 and line[line_pos] != " ": - split_num = safe_cast(int, extract(line, line_pos, 2)) - split_time = safe_cast(float, extract(line, line_pos + 2, 8)) - - splits[split_num] = split_time - line_pos += 11 # MM for some reason specifies P/S/F every time??? - # Set correct attribute if event_type == ResultType.PRELIM: - entry.prelim_splits |= splits + target = entry.prelim_splits elif event_type == ResultType.SWIMOFF: - entry.swimoff_splits |= splits + target = entry.swimoff_splits elif event_type == ResultType.FINAL: - entry.finals_splits |= splits + target = entry.finals_splits else: raise ValueError("Invalid event type!") + # Read splits. The index is a two-character field, so it wraps at 100: + # a swim with more than 49 split slots (a 1500 LCM or 1650 SCY recorded + # every 25) continues "...F96 ... F00 ... F04 ..." on the next G1 line, + # and read literally the wrapped indexes collide with the early ones and + # overwrite them. Indexes are monotonic within an entry, so a value below + # the highest one already seen for this slot means the field wrapped. + line_pos = 4 + highest = max(target) if target else -1 + while line_pos < 124 and line[line_pos] != " ": + split_num = safe_cast(int, extract(line, line_pos, 2)) + split_time = safe_cast(float, extract(line, line_pos + 2, 8)) + + while split_num <= highest and highest >= 0: + split_num += 100 + highest = max(highest, split_num) + target[split_num] = split_time + line_pos += 11 # MM for some reason specifies P/S/F every time??? + event.last_entry = entry file.meet.last_event = (event_num, event) diff --git a/tests/hy3/line_parsers/test_e_event_parsers.py b/tests/hy3/line_parsers/test_e_event_parsers.py index 57e20e5..0b24e46 100644 --- a/tests/hy3/line_parsers/test_e_event_parsers.py +++ b/tests/hy3/line_parsers/test_e_event_parsers.py @@ -443,3 +443,43 @@ def test_combined_distance_event_keeps_each_entrys_distance(self): event = file.meet.events["41C"] self.assertEqual(500.0, event.distance) self.assertEqual([500.0, 400.0], [e.distance for e in event.entries]) + + +class TestG1SplitIndexWrap(unittest.TestCase): + """The G1 split index is a two-character field and wraps at 100.""" + + def test_wrapped_indexes_continue_instead_of_overwriting(self): + from hytek_parser.hy3.line_parsers.g_split_parsers import g1_parser + opts = {"default_country": "USA"} + file = ParsedHytekFile() + file.meet = Meet() + file.meet.last_team = ("FOO", Team("Foo Bar", "FOO", "foo","","","","","","","","","","","",{})) + d = "D1M 27Hansen Mads 10272010 13 27" + e1 = "E1M 27HanseMB 1500A 15109 0A 10.00 12C 0.00L 0.00L 0.00 0.00 NN N 10" + e2 = "E2F 1087.01L 0 1 5 2 2 0 0.00 0.00 0.00 1087.01 0.00 05292021K 0 46" + g1a = "G1F 4 0.00F 8 66.75F12 0.00F16 139.54F20 0.00F24 212.15F28 0.00F32 284.59F36 0.00F40 357.38F44 0.00 42" + g1b = "G1F48 431.18F52 0.00F56 504.48F60 0.00F64 577.28F68 0.00F72 651.37F76 0.00F80 725.04F84 0.00F88 798.37 63" + g1c = "G1F92 0.00F96 871.63F00 0.00F04 945.32F08 0.00F12 1018.09F16 0.00F20 1087.01 79" + file = d1_parser(d, file, opts); file = e1_parser(e1, file, opts); file = e2_parser(e2, file, opts) + for ln in (g1a, g1b, g1c): + file = g1_parser(ln, file, opts) + splits = file.meet.events["12C"].last_entry.finals_splits + self.assertEqual(30, len(splits)) + self.assertEqual(66.75, splits[8]) # early split survives + self.assertEqual(945.32, splits[104]) # "04" after the wrap -> 104 + self.assertEqual(1087.01, splits[120]) # the finish, "20" -> 120 + self.assertEqual(list(range(4, 121, 4)), sorted(splits)) + + def test_unwrapped_swim_is_unchanged(self): + from hytek_parser.hy3.line_parsers.g_split_parsers import g1_parser + opts = {"default_country": "USA"} + file = ParsedHytekFile() + file.meet = Meet() + file.meet.last_team = ("FOO", Team("Foo Bar", "FOO", "foo","","","","","","","","","","","",{})) + d = "D1F 27Hansen Mads 10272010 13 27" + e1 = "E1F 27HanseFG 100A 13 14 0S 4.25 71 61.05Y 61.05Y 1.00 0.00 NN N " + e2 = "E2F 59.26Y 0 1 8 8 16 0 59.34 59.25 59.25 59.26 0.00 12052009 " + g1 = "G1F 2 28.47F 4 59.26 " + file = d1_parser(d, file, opts); file = e1_parser(e1, file, opts); file = e2_parser(e2, file, opts) + file = g1_parser(g1, file, opts) + self.assertEqual({2: 28.47, 4: 59.26}, file.meet.events["71"].last_entry.finals_splits)