Changelog¶
All notable changes to tunas are documented here in Keep a Changelog format, adhering to Semantic Versioning.
[Unreleased]¶
[0.6.1] — 2026-05-30¶
Fixed¶
read_hy3member ID stored in the wrong field: the 14-character.hy3D1member ID (cols 70–83) was written toSwimmer.id_short, leavingid_longunset — the opposite ofread_cl2, whoseid_short/id_longhold the 12- and 14-char forms. The 14-char ID now populatesid_long, andid_shortis derived as its 12-char prefix, so the same swimmer carries matching IDs across a meet's.cl2and.hy3exports. (Rare legacy DOB-based IDs whose stored 12-char form differs from the 14-char prefix cannot be recovered from the.hy3alone.)read_hy3never setIndividualSwim.attach_status: every Hy-Tek individual swim defaulted toATTACHED, even for athletes under theUN/Unattachedteam. The status is now derived from the team (matchingread_cl2), so unattached swims are markedUNATTACHED.read_hy3read the wrong column forswimmer_age_class: it took the numeric Age field (cols 98–99), yielding'0'whereread_cl2reports the grade/class (JR,SO, …). It now prefers a real (non-zero) age and otherwise falls back to theD1Grade/Class field (cols 100–101), matching the single "age or class" fieldread_cl2reads from the SDIFD0.read_cl2relay split distances shifted short on missing splits: relayG0cumulative distances were derived from a running count of recorded splits, so a blank interior split — or a final leg with aG0but noF0record of its own (NO SWIMMER NAME) — shifted every later mark down one increment (e.g. a 4×50's final mark labeled150instead of200). The distance is now derived from a running leg index (seeded from eachF0leg number, advanced once perG0record), so missing interior splits and unnamed final legs no longer displace the remaining marks.
[0.6.0] — 2026-05-29¶
Changed¶
- Breaking —
read_hy3clubteam_codenow carries the LSC prefix: a Hy-Tek club'steam_codeis now the LSC-prefixed code (e.g.PCSCSCinstead ofSCSC), matchingread_cl2, which already stores the prefixed code from the SDIFC1record. The bare code is kept only when the file carries no LSC. This aligns club (and relay) identity across the two readers — previously the prefix difference made the same club look different between a meet's.cl2and.hy3exports.
Fixed¶
.hy3D1with a blank athlete name no longer aborts the file: a missing first (or last) name on aD1athlete record was treated as a fatal structural violation, so a single malformed record made the entire meet unparseable even in lenient mode. The record is now skipped with aSKIPPEDwarning (and its following entries/results orphan-skip cleanly); strict mode still raises.
[0.5.0] — 2026-05-29¶
Changed¶
- Relay leg splits (
RelaySwim.splits) are now derived, not stored: relay splits live on the relay row (Relay.splits) as whole-relay cumulative marks, and a leg'ssplitsis computed on demand from that row — the marks swum during the leg, re-based to the leg start so the leg reads like a flat-start swim (distances from 0, cumulative time from the leg's takeoff). It remains alist[Split](empty when there is nothing to derive — the relay has no splits, or the slot is an alternate), preserving the uniformSwim.splitstype. The property is read-only.
Fixed¶
.cl2relay splits misplaced on legs at a bogus distance, and some dropped entirely: SDIFG0relay splits are whole-relay cumulative times (e.g. a 200 medley relay's 50/100/150/200 marks), but the reader attached each to an individual leg and, because every leg'sG0restarts at slot 0, stamped every split withdistance=50— so leg 2's cumulative time read as a 50-yard split. Relays whoseG0s followed theE0directly with noF0leg records had their splits orphaned and dropped. Relay splits now attach to the relay row (Relay.splits, matching the.hy3reader and the documented "whole-relay cumulative splits" contract) with distances climbing 50/100/150/200/… across the relay'sG0records, and the no-F0case is rescued rather than dropped. Per-leg segments are exposed through the derivedRelaySwim.splitsdescribed above..hy3split distances on half-length-counter files: some timing systems indexG1split counters by half-length (e.g. a 200 SCY at counters4/8/12/16instead of2/4/6/8), which a blindcounter × 25mapped to impossible 100/200/300/400-yard splits. The per-counter distance is now resolved by anchoring the furthest counter in the swim to the event distance (halving the pool-length unit until it fits), so these resolve to the correct 50/100/150/200 and a lone finishing split lands on the event distance. The mapping is also course-aware now (50 m per length for LCM rather than a hardcoded 25).- Compound first names truncated in
.cl2(and any SDIFLast, First MIfield): a whole-word second given-name token (e.g.Zhou, Melissa Hanlin,Lam, Lok Yiu) was treated as a middle initial, yieldingfirst="Melissa",middle="H". A trailing token is now taken as a middle initial only when it is genuinely initial-like (a single letter, optionally dotted); otherwise it stays part offirst_name(first="Melissa Hanlin", no middle initial). Real trailing initials (Smith, John Q) are unchanged.
[0.4.0] — 2026-05-28¶
Changed¶
- Breaking — removed the
max_workersparameter fromread_cl2andread_hy3. Parsing is now always single-threaded. The work is CPU-bound pure Python, so the thread pool serialized under the GIL and, even on a free-threaded build (3.13t+), plateaued at a sublinear ~2.4× before regressing as workers contended on atomic refcounts over shared immutables and on cyclic-GC coordination — complexity that bought no reliable speed-up. The readers remain lazy and yield oneMeetArchiveper file in source order, keeping peak memory flat regardless of corpus size. - Migration: drop the
max_workersargument (the default1was already the only value worth using). To parse across multiple cores, shard the file list over separate processes and fold the per-file reports withParseReport.merge.
Internal¶
- Simplified the
parser.pydriver to a plain sequential generator, removing theThreadPoolExecutorand the bounded order-preserving_imap_orderedlook-ahead.
[0.3.1] — 2026-05-27¶
Fixed¶
- Model
repr()/str()no longer blow up: the aggregates form a cyclic graph (Meet↔Club↔Swimmer↔ result, plusRelaySwim.relay), so the dataclass-generated__repr__walked the back-references and expanded combinatorially — a populated meet rendered into megabytes and effectively hung (and could hitRecursionError).Meet,Club,Swimmer,MeetResult,IndividualSwim,Relay, andRelaySwimnow define a concise__repr__/__str__that summarises collections by count and names related objects by a short label (a club'steam_code, a swimmer'sfull_name) instead of recursing, so each renders as a single short line.
[0.3.0] — 2026-05-26¶
Changed¶
- Breaking — streaming reader API:
read_cl2andread_hy3now return a lazyIterator[MeetArchive]— one archive per source file — instead oftuple[list[Meet], ParseReport]. Files are parsed as the iterator is consumed, so a large corpus is processed one file at a time without holding every meet in memory at once, and each file's diagnostics stay attached to that file rather than being merged into one report.max_workersnow dispatches files across a thread pool behind a bounded, order-preserving look-ahead window (archives are still yielded in source order); on a free-threaded interpreter the per-file work parses in genuine parallel. - Migration: to recover the old behaviour, flatten the iterator —
meets = [m for arc in read_cl2(src) for m in arc.meets]— and, if a single combined report is needed, fold the per-file reports withParseReport.merge. For a single file/stream,(archive,) = read_cl2(src)orarchive = next(iter(read_cl2(src)))yields the one archive.
Added¶
MeetArchive: the per-file parse result yielded by both readers, wrappingsource(path or"<stream>"),meets(a file may hold more than one), and a per-filereport. Exported from the top-level package..hy3event metadata:read_hy3now populatesevent_min_age,event_max_age, andevent_numberon individual (E1/E2) and relay (F1/F2) results, decoded from the newly-confirmedE1/F1columns (age range 23–28, event number 39–42). Open-ended age bounds use the SDIFNoneconvention (the0/109file sentinels map toNone).
Fixed¶
.hy3format reference: Corrected theE1/F1field map — cols 23–28 are the event age range (min 23–25, max 26–28), and the event number is at cols 39–42, not 25–28 as previously documented.- SDIF club back-reference: when a swimmer appears unattached and then attached to a real team under the same USS# within one file, merging now registers the swimmer on
club.swimmers(not justswimmer.club), so the two sides of the graph stay consistent. .hy3unresolvable-event diagnostic: the skip warning for anE2/F2result now reports the parsed entry stroke instead of mis-reading the result record's heat column (the stroke lives on theE1/F1entry, not the result).
Internal¶
- Reworked the
parser.pydriver into a lazy archive iterator: eager argument validation, a per-file engine, and a bounded order-preserving_imap_orderedlook-ahead over the thread pool (at most2 * max_workersfiles in flight) in place of the old map-and-merge parallel path. - Refactored the shared parse engine for readability with no change to per-file parse output: hoisted the duplicated event-resolution and split-appending logic out of
_parser/cl2.pyand_parser/hy3.pyinto_BaseEngine(_resolve_event,_append_split), and unified the generic code-enum helpers on Python 3.12 type-parameter syntax.
[0.2.0] — 2026-05-25¶
Added¶
read_hy3: A reader for Hy-Tek.hy3result files, mirroringread_cl2's signature and producing the sameMeetobject graph. Parses confirmed fields fromA1throughH2records..hy3-only model fields: Added fields for data carried only by.hy3files (defaulting toNone/empty forread_cl2):SourceFile(hy3_file_type,created_time,licensee),Meet(venue,age_up_date,sanction_number),Club(email),MeetResult(dq_code,dq_reason,converted_seed_time,converted_seed_course,backup_times), andRelay.splits(whole-relay cumulative splits). AddedHy3FileTypeenum andResultStatus.EXHIBITION.
Changed¶
- Parser refactoring: Shared a common parsing core (
_BaseEnginein_parser/engine.py) between SDIF (_parser/cl2.py) and Hy-Tek (_parser/hy3.py) formats, with no change toread_cl2output.
Documentation¶
.hy3parsing: Updated guides, API references, and format documentation to coverread_hy3and.hy3support.- Expanded
.hy3format reference: Reverse-engineered the.hy3format using meet results from Pacific Swimming (2013–2026, ~1,680 meets) and Michigan Swimming (425 meets) matched against.cl2siblings. Decoded the line checksum, athlete numbering, springboard diving strokes, watch-time columns, exhibition statuses, and relay structures. - Complete
.cl2/ SDIF v3 reference: Rewrote the.cl2guide into a comprehensive field-level reference covering all record types (A0–Z0, registrationD1/D2, time-standardsJ0–J2), column ranges, and code tables. Documented empirically-verified deviations of real-world Hy-Tek/TeamUnify output (measured over 2,190 files). - Guide & API Reference Updates: Expanded the data model guide with PII value types,
Timeaccessors, and event helpers. Added cookbook recipes for split arithmetic, concurrent parsing, and standards lookups. Added a parser-internals breakdown to the architecture page and a public symbol index. - Docs Styling & Organization: Set a neutral black dark mode theme and renamed the "Data" navigation section to "File Format".
- README Polish: Simplified the dependency note and improved core concept definitions.
[0.1.1] — 2026-05-24¶
Added¶
- Parallel parsing:
read_cl2gains amax_workersparameter (default1);max_workers > 1parses multiple files concurrently (one file per thread) and merges the per-file results back in source order, producing output identical to the sequential default.
Documentation¶
- Hosted docs site: Published a MkDocs Material site at https://ajoe2.github.io/tunas/, deployed automatically on release.
- Guide: Getting started, parsing & error handling, the data model, a recipe cookbook, and the SDIF/
.cl2file-format reference. - Complete API reference: Generated from source docstrings and type hints, so it always matches the installed version; docstrings now cover every public class, function, property, and enum.
Internal¶
- Refactored the parse engine and supporting modules for readability with no change to the public API or parse output: unified the duplicated per-session result parsing, replaced magic column offsets with named
SessionColumnslayouts, centralized record-orphan handling, and named the remaining magic constants (split layout, Z0 count checks, affiliation flags).
[0.1.0] — 2026-05-24¶
Initial release of the tunas library, providing a parser and domain model for USA Swimming .cl2 (Hy-Tek SDIF v3) files.
Added¶
- Unified Entry Point:
read_cl2(source, *, strict=False, encoding="cp1252", errors="replace") -> tuple[list[Meet], ParseReport]accepts paths, directories, lists, or file-like objects. - Spec Coverage: Parses every meet-results record type (
A0–G0,Z0); qualifying-time records (J0–J2) surface as warnings. - Domain Model: Dataclass graph covering
Meet,Club,Swimmer, and the swim/result hierarchy (Swim→IndividualSwim,RelaySwim;MeetResult→IndividualSwim,Relay), as well asSplit,SwimmerContact,SwimmerRegistration,MeetHost,SourceFile,Time, andEvent. Swimmers expose a unifiedswimslist. - Pure and Faithful Parsing: Meets are self-contained. Swimmers are unified within a meet by member ID (
id_short, falling back toid_long). - Zero Data Loss: All entered swims are kept, including non-time outcomes (scratches, DQs, no-shows tracked via
ResultStatus). Missing optional fields are set toNone. Raw line contents are preserved on validation failures. - Structured Error Model: Structural (M1) violations raise
ParseError. Data quality (M2) violations emit warnings or raise instrictmode.ParseReportprovides query helpers (by_severity,warnings_for) and aggregates counts. - Time Standards: Offline lookup helpers (
qualifies_for,standard_time,all_qualified) using bundled 2025–2028 motivational standards. - Type-hinted: Fully type-hinted and marked
py.typed.