A zero-dependency library for structured medication dosing frequency encoding, parsing, and FHIR R4 Timing conversion.
Maps clinical shorthand (BD, TDS, PRN, STAT, etc.) to structured timing data and FHIR resources, with locale support for UK and US conventions.
Author: Akanimoh Osutuk — Open Nucleus Licence: Apache 2.0
Every prescribing system deals with dosing frequencies — BD, TDS, OD, QDS, PRN, STAT, nocte, mane — yet there is no open-source, standalone library that maps these to structured timing data with bidirectional FHIR conversion.
The UK says BD, the US says BID. Both mean "twice daily, every 12 hours, typically at 08:00 and 20:00." Clinicians think in shorthand. FHIR thinks in { "frequency": 2, "period": 1, "periodUnit": "d" }. This library bridges the gap.
Every code in this library is traceable to an authoritative source. No codes are invented.
| Source | What it provides | Reference |
|---|---|---|
| HL7 FHIR v3-GTSAbbreviation | Formal coded timing abbreviations (QD, BID, TID, QID, Q1H–Q8H, QOD, AM, BED, WK, MO) | terminology.hl7.org |
| FHIR R4 EventTiming | Timing.repeat.when codes (MORN, NIGHT, AC, PC, HS, C) |
hl7.org/fhir/R4 |
| NHS Dose Syntax Implementation Guide | UK guidance for populating FHIR Dosage structures | nhsconnect.github.io |
| NHS Dose Syntax API Standards | National API standard for dose syntax in prescribing systems | digital.nhs.uk |
| NHS App Medical Records | Official abbreviations list (b.d., t.d.s., q.d.s., o.d., p.r.n., nocte, stat, a.c., p.c.) | nhs.uk |
| NHS Scotland Dose Syntax Recommendations | Structured dose instruction standard for Scottish prescribing (2015) | scimp.scot.nhs.uk |
Each frequency code is classified by source tier:
- S1 — HL7 GTS: Code exists in the HL7 v3-GTSAbbreviation CodeSystem (formal international standard)
- S2 — NHS Convention: Documented in NHS prescribing guidance or the NHS App abbreviations list
- S3 — Clinical Extension: Common prescribing pattern not in S1/S2; sourced from BNF usage and Latin tradition
33 dosing frequency codes across 7 categories:
| Code | Display | Category | Source | FHIR Code |
|---|---|---|---|---|
| OD | Once daily | Regular | S1+S2 | QD |
| BD | Twice daily | Regular | S1+S2 | BID |
| TDS | Three times daily | Regular | S1+S2 | TID |
| QDS | Four times daily | Regular | S1+S2 | QID |
| 5X_DAILY | Five times daily | Regular | S3 | — |
| Q1H | Every 1 hour | Interval | S1+S2 | Q1H |
| Q2H | Every 2 hours | Interval | S1+S2 | — |
| Q4H | Every 4 hours | Interval | S1+S2 | Q4H |
| Q6H | Every 6 hours | Interval | S1+S2 | Q6H |
| Q8H | Every 8 hours | Interval | S1+S2 | — |
| Q12H | Every 12 hours | Interval | S3 | — |
| Q24H | Every 24 hours | Interval | S3 | — |
| Q36H | Every 36 hours | Interval | S3 | — |
| Q48H | Every 48 hours | Interval | S3 | — |
| Q72H | Every 72 hours | Interval | S3 | — |
| MANE | In the morning | Time of Day | S1+S2 | AM |
| NOCTE | At night | Time of Day | S1+S2 | — |
| MIDI | At midday | Time of Day | S3 | — |
| AM_PM | Morning and evening | Time of Day | S3 | — |
| AC | Before meals | Meal Relative | S2 | — |
| PC | After meals | Meal Relative | S2 | — |
| CC | With meals | Meal Relative | S3 | — |
| AC_HS | Before meals and at bedtime | Meal Relative | S3 | — |
| PRN | As needed | PRN | S2 | — |
| PRN_Q4H | As needed, max every 4 hours | PRN | S3 | — |
| PRN_Q6H | As needed, max every 6 hours | PRN | S3 | — |
| SOS | If needed (single use) | PRN | S3 | — |
| STAT | Immediately | One-Off | S2 | — |
| ONCE | Single dose | One-Off | S3 | — |
| QOD | Every other day | Extended | S1+S2 | QOD |
| WEEKLY | Once weekly | Extended | S1 | — |
| BIWEEKLY | Every 2 weeks | Extended | S3 | — |
| MONTHLY | Once monthly | Extended | S1 | — |
go get github.com/Open-Nucleus/open-pharm-dosingdart pub add open_pharma_dosingpip install open-pharma-dosingimport dosing "github.com/Open-Nucleus/open-pharm-dosing"
// Parse accepts canonical codes, aliases, mixed case, and punctuation variants
fc, _ := dosing.Parse("BD") // → FrequencyCode{Code: "BD", ...}
fc, _ = dosing.Parse("b.i.d.") // → FrequencyCode{Code: "BD", ...}
fc, _ = dosing.Parse("twice daily") // → FrequencyCode{Code: "BD", ...}
fc, _ = dosing.Parse("every 4 hours") // → FrequencyCode{Code: "Q4H", ...}
fc, _ = dosing.Parse("TID") // → FrequencyCode{Code: "TDS", ...}
fc, _ = dosing.Parse("p.r.n.") // → FrequencyCode{Code: "PRN", ...}// Get by canonical code
fc, err := dosing.Get("TDS")
fmt.Println(fc.Frequency) // 3
fmt.Println(fc.IntervalHours) // 8
fmt.Println(fc.DefaultTimes) // ["08:00", "14:00", "20:00"]
fmt.Println(fc.Latin) // "ter die sumendus"
// List all codes (sorted by SortOrder)
all := dosing.List()
// Filter by category
prn := dosing.List(dosing.WithCategory(dosing.CategoryPRN))
// Search by partial match on code, alias, or display text
results := dosing.Search("daily") // matches OD, BD, TDS, QDS, 5X_DAILY
results = dosing.Search("morning") // matches MANE, AM_PM// Frequency code → FHIR Timing JSON
timingJSON, _ := dosing.ToFhirTiming("BD")
// Output:
// {
// "repeat": {
// "frequency": 2,
// "period": 1,
// "periodUnit": "d",
// "timeOfDay": ["08:00", "20:00"]
// },
// "code": {
// "coding": [{
// "system": "http://terminology.hl7.org/CodeSystem/v3-GTSAbbreviation",
// "code": "BID"
// }]
// }
// }
// FHIR Timing JSON → frequency code
fc, _ := dosing.FromFhirTiming(timingJSON)
fmt.Println(fc.Code) // "BD"// Build a dosing instruction
instruction := &dosing.DosingInstruction{
Frequency: fc,
Dose: &dosing.Dose{Value: 500, Unit: "mg"},
Route: "PO",
}
// Convert to FHIR Dosage
dosageJSON, _ := dosing.ToFhirDosage(instruction)
// Convert back
result, _ := dosing.FromFhirDosage(dosageJSON)
fmt.Println(result.Frequency.Code) // "BD"
fmt.Println(result.Dose.Value) // 500
fmt.Println(result.Route) // "PO"// Frequency code → display text (locale-aware)
text, _ := dosing.ToText("BD", dosing.LocaleEnGB) // "Twice daily"
text, _ = dosing.ToText("BID", dosing.LocaleEnUS) // "Twice daily"
text, _ = dosing.ToText("PRN", dosing.LocaleEnGB) // "As needed"
// Frequency code → locale-preferred short label
label, _ := dosing.ToLabel("BD", dosing.LocaleEnGB) // "BD"
label, _ = dosing.ToLabel("BD", dosing.LocaleEnUS) // "BID"
label, _ = dosing.ToLabel("TDS", dosing.LocaleEnUS) // "TID"
// Full instruction → readable text
fc, _ := dosing.Parse("BD")
ac, _ := dosing.Parse("AC")
text, _ = dosing.InstructionToText(&dosing.DosingInstruction{
Frequency: fc,
MealModifier: ac,
Dose: &dosing.Dose{Value: 500, Unit: "mg"},
Route: "PO",
Duration: &dosing.Duration{Value: 7, Unit: dosing.PeriodDay},
}, dosing.LocaleEnGB)
// "500mg twice daily by mouth for 7 days, before meals"start := time.Date(2025, 1, 15, 9, 30, 0, 0, time.UTC)
// Fixed-time schedule (skips already-passed times on day 1)
times, _ := dosing.Schedule("BD", start, 3)
// Day 1: 20:00 (08:00 skipped — before 09:30)
// Day 2: 08:00, 20:00
// Day 3: 08:00, 20:00
// Rolling interval (Q1H, Q2H, Q36H, Q72H — no fixed daily times)
times, _ = dosing.Schedule("Q1H", start, 1)
// 24 times: 09:30, 10:30, 11:30, ...
// One-off
times, _ = dosing.Schedule("STAT", start, 1) // [2025-01-15 09:30]
// Extended intervals (correct month arithmetic)
times, _ = dosing.Schedule("MONTHLY", start, 90)
// Jan 15, Feb 15, Mar 15 at 08:00
// Custom administration times
times, _ = dosing.ScheduleWithTimes("BD", start, 1, []string{"07:00", "19:00"})
// PRN and pure meal modifiers return explicit errors
_, err := dosing.Schedule("PRN", start, 1) // ErrPRNNoSchedule
_, err = dosing.Schedule("AC", start, 1) // ErrMealRelNoSchedule// Validate a frequency code
err := dosing.Validate("BD") // nil
err = dosing.Validate("XYZZY") // error
// Validate a full instruction (returns multi-level findings)
fc, _ := dosing.Parse("PRN")
warnings := dosing.ValidateInstruction(&dosing.DosingInstruction{
Frequency: fc,
Dose: &dosing.Dose{Value: 500, Unit: "mg"},
})
// warnings includes:
// {Field: "max_dose", Level: "warning", Message: "PRN frequency without maximum dose limit"}
// {Field: "route", Level: "info", Message: "route not specified"}A key invariant of this library:
FromFhirTiming(ToFhirTiming(code)) == code
This holds for all 33 supported frequency codes. Three codes have documented structural ambiguities where the roundtrip resolves to an equivalent code:
| Input | Roundtrip Result | Reason |
|---|---|---|
| ONCE | STAT | Both produce count: 1; STAT is the default |
| AM_PM | BD | Both produce frequency: 2, period: 1/d, times: [08:00, 20:00] |
| SOS | PRN | Both produce asNeeded: true without period constraints |
These are structurally indistinguishable in FHIR and resolve to the more common code.
open-pharma-dosing/
├── README.md
├── LICENSE
├── spec.md # Full specification
├── CLAUDE.md # AI assistant instructions
├── data/
│ └── frequencies.json # Canonical registry (reference for ports)
├── go/ # Go implementation (canonical)
│ ├── go.mod
│ ├── dosing.go # Types, constants, embedded registry
│ ├── parse.go # Parser
│ ├── fhir.go # FHIR R4 converter
│ ├── text.go # Human-readable text generation
│ ├── schedule.go # Concrete schedule generation
│ ├── validate.go # Clinical validation
│ ├── dosing_test.go
│ ├── parse_test.go
│ ├── fhir_test.go
│ ├── text_test.go
│ ├── schedule_test.go
│ └── validate_test.go
├── dart/ # Dart/Flutter port (planned)
├── python/ # Python port (planned)
└── docs/ # Documentation (planned)
Zero dependencies. The library has no external dependencies in any language implementation. The frequency registry is embedded as compiled data, not loaded from files at runtime.
NHS-first, internationally compatible. Canonical codes use UK convention (BD, TDS, QDS, OD) with US aliases (BID, TID, QID, QD). The parser accepts both. The FHIR converter uses the HL7 GTS codes (BID, TID, QID) which are the international standard.
Registry as code, not config. The 33 frequency entries are hand-written Go struct literals, type-checked at compile time. The data/frequencies.json file is an export for documentation and for the Dart/Python ports — the Go implementation never reads it.
FHIR types are internal. The library uses internal Go structs for FHIR Timing/Dosage construction but exposes []byte (JSON) at the public API boundary. This keeps the API simple and avoids depending on any FHIR library.
cd go && go build ./... # Build
cd go && go vet ./... # Lint
cd go && go test ./... # Run all tests
cd go && go test -v -run TestFhirRoundtripAllCodes # FHIR roundtrip invariant- Phase 1: Core Go library — types, registry, parser, FHIR converter
- Phase 2: Text generation, schedule generator, validation
- Phase 3: Dart and Python ports
- Phase 4: Locale support (en-GB, en-US, fr, es, sw, ha, yo), ParseInstruction
- Phase 5: Complex regimens (tapering, split-dose, cyclical), EPMA integration
Contributions are welcome, particularly:
- Clinical review — are the default administration times sensible?
- Locale translations — especially African languages (Swahili, Hausa, Yoruba)
- Missing frequency codes — if your prescribing system uses codes not in the registry
- FHIR edge cases — unusual Timing structures that should map to a code
- HL7 FHIR v3-GTSAbbreviation CodeSystem — terminology.hl7.org/CodeSystem/v3-GTSAbbreviation
- FHIR R4 TimingAbbreviation ValueSet — hl7.org/fhir/R4/valueset-timing-abbreviation.html
- FHIR R4 EventTiming ValueSet — hl7.org/fhir/R4/valueset-event-timing.html
- NHS Dose Syntax Implementation Guide — nhsconnect.github.io/Dose-Syntax-Implementation
- NHS Dose Syntax API Standards — digital.nhs.uk/developer/api-catalogue/dose-syntax-standards
- NHS Scotland Dose Syntax Recommendations (2015) — scimp.scot.nhs.uk
- NHS App Medical Records Abbreviations — nhs.uk/nhs-app/help/health-records
- Community Pharmacy England — Dose Syntax Interoperability — psnc.org.uk
open-pharm-dosing — Open Nucleus Because "BD" shouldn't need 47 lines of FHIR XML.