swimparse
v0.5.0
Published
Parses SDIF v3 (.sd3) and Hy-Tek (.hy3) swim-meet result files into one NormalizedMeet JSON contract, and Hy-Tek meet-setup files (.ev3/.hyv) — events, sessions and qualifying cuts — into a NormalizedMeetSetup. Zero dependencies; runs in the browser, Node
Maintainers
Readme
swimparse
Reads swim-meet result files — SDIF v3 (.sd3) and Hy-Tek (.hy3) — and emits
one NormalizedMeet JSON shape, so tools consume a single contract instead of
re-implementing fixed-width parsing twice.
It also reads the other end of a meet: Hy-Tek meet-setup files (.ev3 / .hyv) —
the event list, session schedule, entry fees and qualifying cuts — as a
NormalizedMeetSetup.
- Zero dependencies. Plain ESM — browser, Node, and CI unchanged.
- Lossless. Every swim is kept: placing or not, exhibition, DQ, no-show — plus birthdates, seed times, relay legs, splits and DQ reasons.
- Format-agnostic output. SDIF and HY3 of the same meet parse to the same result.
Scope
swimparse reads files and emits JSON. That is all it does. It has no concept of a league: no age bands, no scoring rules, no team-code registry, no qualifying standards, no synthesized meet names.
That boundary is deliberate. Those things differ per league and change over time, while the file formats do not. Keeping them out means a summer-league scorer, a USA-Swimming analyzer, and a championship-meet tool can share one parser and disagree about everything else.
| Concern | Where it belongs |
|---|---|
| SDIF / HY3 / EV3 / HYV record layouts, times, dates, format detection | swimparse |
| The NormalizedMeet and NormalizedMeetSetup contracts | swimparse |
| Age bands, age-up date, age-group labels for swimmers | your league layer |
| Scoring: point values, which relays count, team totals | your league layer |
| Canonical team codes / alias mapping | your league layer |
| Stripping or aggregating PII | your league layer |
| Whether a swimmer meets a cut, course conversions, records, personal bests | your application |
Install
npm install swimparseZero dependencies, so there is nothing else to pull in.
Releases are published from CI with provenance, so every version on npm can be traced to the commit and workflow run that built it.
Usage
import { parse, detectFormat } from 'swimparse';
const meet = parse(fileText, { filename: 'GG_at_WW.hy3' }); // auto-detects format
const meet = parse(fileText, { format: 'sdif-v3' }); // or force oneMeet-setup files go through parseSetup instead — they hold events, not results, so
they parse to a different shape rather than an empty NormalizedMeet:
import { parseSetup, qualifyingStandards } from 'swimparse';
const setup = parseSetup(fileText, { filename: 'Meet Events-2026 Champs.ev3' });
setup.events[0].qualifyingTimes; // { LCM, SCM, SCY } — the event's cut per course
qualifyingStandards(setup); // flat cut table: one row per event that has onequalifyingStandards() is a view over the same values, not a filter — a meet that does
not accept a course may fill it with a placeholder like 0.01, and that arrives as
stated. Filtering it is the consumer's judgement, and parseSetup(text, { placeholders:
'null' }) is how to hand that judgement back to the parser: it clears placeholder cuts
and unset-date sentinels, and nothing else.
If you are consuming the cuts, read
docs/qualifying-cuts.md first — the output shape, and the
four things that are easy to get wrong (course keys, what null means, comparing on
seconds, and where the meet's rules end and yours begin).
CLI:
swimparse meet.hy3 --pretty # NormalizedMeet JSON to stdout
swimparse meet.sd3 -o meet.json # to a file
swimparse a.sd3 b.hy3 -d out/ # one <name>.json per input
swimparse events.ev3 --pretty # NormalizedMeetSetup JSON
swimparse events.ev3 --cuts # just the qualifying-time tableThe NormalizedMeet contract
{ format, source, meet, teams, swimmers, events } — see src/model.js
for the full typedefs. Highlights:
- Times always carry both
{ text: "1:11.35", seconds: 71.35 }. - Dates are ISO
YYYY-MM-DD. result.roundisprelim | swimoff | final— one result per round swum. A timed-finals meet records every swim asfinaland reads exactly as it always has. At a prelims/finals meet a swimmer holds two results in one event: they are a prelim and a final, not a duplicate, and each carries its own time, place, heat and lane.finalTimeis the time swum in that result's round — the name predates rounds and is kept for compatibility, so read it together withround.result.statusisok | dq | ns | dnf | scratch | exhibition.event.ageGroupis the event's age range as printed in the file ("9-10","10 & Under","Open"). It is not a swimmer's age group — computing that needs a birthdate and a league's bands, which is your layer's job.event.courseisSCY|LCM|SCM, andevent.eventKeyis the stable identity shared with a setup file's events — join on it rather than ondescription, whose distance unit follows the course.team.codehas the two-letter LSC prefix stripped (VAWW→WW), an SDIF file convention;team.fullCodekeeps the raw value. Mapping either onto a league's canonical code is your layer's job.result.pointsis whatever the file stored. SDIF carries points; HY3 does not, so it reads0. Deriving points from place is scoring, so it lives in your layer.
Format differences worth knowing
| | SDIF (.sd3) | Hy-Tek (.hy3) |
|---|---|---|
| Rounds on disk | one record, a slot per round | one record per round |
| DQ time | nulled | retained (finalTime kept) |
| DQ reason | — | dqReason (e.g. "Arms: Underwater recovery") |
| Points | stored | absent (reads 0) |
| Names | 28-char field | wider, less truncation |
The NormalizedMeetSetup contract
{ format, source, meet, sessions, events } — see src/model.js. A
setup file is the meet before anyone has entered it, so events[] here are event
definitions, not results.
event.qualifyingTimesis{ LCM, SCM, SCY }— the same cut expressed in each course, each aSwimTimeornull. A meet that sets no cuts (most invitationals) parses to all-null, andqualifyingStandards()returns[]. The two file flavours store those three columns in different orders — the.hyvrotates them to start at the meet's own course — so read them from here, keyed by course, rather than by column position.event.courseis the event's own course where the file states one (.ev3col 25), and the meet's otherwise.event.eventKeyis the stable identity to join on — the result adapters emit the same key for the same event.descriptionis display text and its distance unit follows the course (50yvs50m), so never join on it.meet.qualifyingSinceis the start of the period a cut may be swum in (.ev3only). Inferred from the files rather than from a spec — seesrc/setup.js.event.roundisfinalsorprelims;roundsis 1 for timed finals, 2 for prelims-plus-finals (.ev3only).sessionsis the day/start-time schedule, collapsed out of the per-event stamps. A session id may be alphanumeric ("2G"— session 2, girls)..hyvcarries no schedule, so it parses to[].- Event numbers keep their age-group letter (
"1A","1B","1C").
ev3 vs hyv
Meet Manager exports both together in one zip and they describe the same events. The
.ev3 is the richer file — sessions, day, event order, start times, relay legs,
sanction number, venue address, entry deadline. The .hyv is the Team Manager import
file: events, ages, fees and cuts only. swimparse parses both, and the test suite
asserts they agree event-for-event.
Note that SDIF also defines an .ev3 meet-events file, which is fixed-width and a
different format. Detection sniffs content, so a fixed-width .ev3 still routes to
the SDIF adapter.
Privacy
The lossless rule means output carries swimmers[].birthDate and usasId whenever the
file does. At a youth meet that is PII for minors, so treat every parse result as
confidential until your application has stripped or aggregated it.
swimparse does not sanitize for you, on purpose: what counts as safe is a league decision (a summer league publishes age-group labels and drops birthdates; a USA-Swimming tool needs exact ages). Putting that choice in the parser would force one answer on everyone.
Test fixtures in this repo are synthetic — every identity is a public figure with a
shifted birth year; see test/fixtures/README.md.
Meet-setup files are the exception: they contain no swimmers at all, so a
NormalizedMeetSetup is safe to publish as-is.
Rounds
The two formats disagree about what a record is: HY3 writes an E1/E2 pair per round
and tags it, SDIF writes one D0 per swimmer-event with a separate time slot for the
prelim, the swim-off and the final. swimparse takes the finer grain — a result is a
swim — so SDIF fans a record out into as many results as it holds rounds, and the same
swim reads identically from either file:
const prelim = event.results.find((r) => r.swimmerName === name && r.round === 'prelim');A round with no time in it produces no result at all; a blank swim-off is absent, not zero. The one thing the formats still disagree about is a disqualified swim, where HY3 keeps the time it recorded and SDIF nulls it — that is the file, not the parser.
Format references
Hy-Tek publishes no specification, so the layouts here come from reading real files. Two references corroborate that work, and are worth having open when changing an adapter:
| Format | Reference |
|---|---|
| SDIF v3 (.sd3/.cl2) | swim-admin/sdif — the spec itself |
| SDIF v3, annotated | ajoe2/tunas docs/formats/cl2_format.md — field tables plus how real files depart from the spec |
| Hy-Tek .hy3 | ajoe2/tunas docs/formats/hy3_format.md — the closest thing to a spec that exists |
| Hy-Tek .ev3 / .hyv | None. See the header of src/setup.js |
Every offset in src/sdif.js and src/hy3.js was derived independently from real
files and then found to agree with those references. Where a reference and a real file
disagree, the file wins — and the disagreement belongs in a comment.
Tests
node --test # golden snapshots + SDIF↔HY3 and EV3↔HYV cross-agreementLicense
MIT
