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/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/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..0b24e46 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,116 @@ 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]) + + +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)