Add complete specification document for the JSON-driven award calculation system. Documents all rule types, filter operators, QSO schema, and implementation guidance suitable for porting to any programming language. Co-Authored-By: Claude <noreply@anthropic.com>
30 KiB
Award System Specification
Overview
This specification describes a JSON-driven award calculation system for amateur radio QSO (contact) tracking applications. The system calculates progress toward awards based on QSO data stored in a database.
The award definitions are externalized as JSON files, allowing awards to be added, modified, or extended without changing application code.
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────┐
│ AWARD SYSTEM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Award Definitions│ <20>───> │ Rule Processor │ ──> │ QSO Database │ │
│ │ (JSON files) │ │ (Engine Core) │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │ │ │
│ │ │ │
│ v v │
│ ┌──────────────────┐ ┌──────────────┐ │
│ │ Award Metadata │ │ QSO Records │ │
│ │ - id, name │ │ - callsign │ │
│ │ - description │ │ - qsoDate │ │
│ │ - caption │ │ - band │ │
│ │ - category │ │ - mode │ │
│ │ - rules │ │ - entityId │ │
│ └──────────────────┘ │ - state │ │
│ │ - grid │ │
│ │ - darcDok │ │
│ │ - satName │ │
│ │ - lotw... │ │
│ │ - dclQsl... │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Award Definition JSON Schema
Root Object Structure
Every award definition is a JSON object with the following structure:
{
"id": "string (required, unique identifier)",
"name": "string (required, display name)",
"description": "string (required, short description)",
"caption": "string (required, detailed explanation)",
"category": "string (required, grouping category)",
"rules": {
"type": "string (required, rule type)",
"...": "rule-specific properties"
}
}
Root Properties
| Property | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Unique identifier for the award (e.g., "dxcc-mixed", "dld-80m") |
name |
string | Yes | Display name shown in UI (e.g., "DXCC Mixed Mode", "DLD 80m") |
description |
string | Yes | Short description for list views |
caption |
string | Yes | Detailed explanation of award requirements |
category |
string | Yes | Grouping category for UI organization (e.g., "dxcc", "darc", "vucc") |
rules |
object | Yes | Award calculation rules (see Rule Types below) |
Rule Types
The system supports 5 rule types. Each type defines a different calculation method.
1. Entity Rule Type ("type": "entity")
Counts unique entities (DXCC countries, states, grid squares, callsigns) from QSOs.
Properties
| Property | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Must be "entity" |
entityType |
string | Yes | What to count: "dxcc", "state", "grid", "callsign" |
target |
number | Yes | Number required to complete award |
displayField |
string | No | Field to display as entity name (defaults to entity value) |
filters |
object | No | Filter criteria (see Filters section) |
Entity Type Mappings
entityType Value |
Source Field | Notes |
|---|---|---|
"dxcc" |
entityId |
Numeric DXCC entity ID |
"state" |
state |
US state or other regional code |
"grid" |
grid |
First 4 characters only (e.g., "FN31") |
"callsign" |
callsign |
Station callsign |
Calculation Logic
worked = count of unique entities where QSO exists
confirmed = count of unique entities where lotwQslRstatus === 'Y'
Example: DXCC Mixed Mode
{
"id": "dxcc-mixed",
"name": "DXCC Mixed Mode",
"description": "Confirm 100 DXCC entities on any band/mode",
"caption": "Contact and confirm 100 different DXCC entities. Any band and mode combination counts.",
"category": "dxcc",
"rules": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity"
}
}
Example: VUCC Satellite (with filter)
{
"id": "vucc-satellite",
"name": "VUCC Satellite",
"description": "Confirm 100 unique grid squares via satellite",
"caption": "Contact and confirm 100 unique 4-character grid squares via satellite.",
"category": "vucc",
"rules": {
"type": "entity",
"entityType": "grid",
"target": 100,
"displayField": "grid",
"filters": {
"operator": "AND",
"filters": [
{ "field": "satellite", "operator": "eq", "value": true }
]
}
}
}
2. DOK Rule Type ("type": "dok")
Counts unique DOK (DARC Ortsverband Kennung) combinations for German amateur radio awards.
Properties
| Property | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Must be "dok" |
target |
number | Yes | Number required to complete award |
confirmationType |
string | Yes | Must be "dcl" (DARC Community Logbook) |
displayField |
string | No | Field to display (typically "darcDok") |
filters |
object | No | Filter criteria (band, mode, etc.) |
Calculation Logic
For each QSO with darcDok:
combination = darcDok + "/" + band + "/" + mode
Track unique combinations
worked = count of unique (DOK, band, mode) combinations
confirmed = count of combinations where dclQslRstatus === 'Y'
Key Behavior: Each unique DOK on each unique band/mode counts separately.
Example: DLD (Deutschland Diplom)
{
"id": "dld",
"name": "DLD",
"description": "Deutschland Diplom - Confirm 100 unique DOKs on different bands/modes",
"caption": "Contact and confirm stations with 100 unique DOKs on different band/mode combinations.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok"
}
}
Example: DLD 80m (with filter)
{
"id": "dld-80m",
"name": "DLD 80m",
"description": "Confirm 100 unique DOKs on 80m",
"caption": "Contact and confirm 100 unique DOKs on the 80m band.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" }
]
}
}
}
3. Points Rule Type ("type": "points")
Awards points for contacting specific stations, with three counting modes.
Properties
| Property | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Must be "points" |
target |
number | Yes | Points required to complete award |
countMode |
string | No | "perStation", "perBandMode", or "perQso" (default: "perStation") |
stations |
array | Yes | Array of station definitions |
Station Array Format
"stations": [
{ "callsign": "STATION1", "points": 10 },
{ "callsign": "STATION2", "points": 5 }
]
Count Modes
| Mode | Description |
|---|---|
"perStation" |
Each unique station earns points once (if confirmed) |
"perBandMode" |
Each unique (callsign, band, mode) combination earns points |
"perQso" |
Every confirmed QSO earns points |
Calculation Logic
For each QSO with callsign matching a station:
points = station.points for that callsign
if countMode === "perStation":
Count unique callsigns (if confirmed)
else if countMode === "perBandMode":
Count unique (callsign, band, mode) combinations (if confirmed)
else if countMode === "perQso":
Count every confirmed QSO
totalPoints = sum(points for confirmed entries)
Example: Wavelog Award (perBandMode)
{
"id": "wavelog-award",
"name": "Wavelog Award",
"description": "Contact special stations on multiple bands and modes to earn points",
"caption": "Contact special stations to earn points. Points awarded for each unique band/mode combination.",
"category": "special",
"rules": {
"type": "points",
"target": 50,
"countMode": "perBandMode",
"stations": [
{ "callsign": "DF2ET", "points": 10 },
{ "callsign": "DJ7NT", "points": 10 },
{ "callsign": "HB9HIL", "points": 10 },
{ "callsign": "DB4SCW", "points": 5 }
]
}
}
4. Filtered Rule Type ("type": "filtered")
Creates a filtered variant of another award by applying additional criteria.
Properties
| Property | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Must be "filtered" |
baseRule |
object | Yes | The base entity rule to filter |
filters |
object | Yes | Additional filters to apply |
Base Rule Format
"baseRule": {
"type": "entity",
"entityType": "dxcc|state|grid|callsign",
"target": number,
"displayField": "string"
}
Example: DXCC CW
{
"id": "dxcc-cw",
"name": "DXCC CW",
"description": "Confirm 100 DXCC entities using CW mode",
"caption": "Contact and confirm 100 different DXCC entities using CW mode only.",
"category": "dxcc",
"rules": {
"type": "filtered",
"baseRule": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity"
},
"filters": {
"operator": "AND",
"filters": [
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}
Note: At runtime, this is internally converted to an entity rule with the filters applied.
5. Counter Rule Type ("type": "counter")
Counts QSOs or unique callsigns matching filter criteria.
Properties
| Property | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | Must be "counter" |
target |
number | Yes | Number required to complete award |
countBy |
string | Yes | "qso" (QSOs) or "callsign" (unique callsigns) |
displayField |
string | No | Field to display |
filters |
object | No | Filter criteria |
Example: RS-44 Satellite
{
"id": "sat-rs44",
"name": "RS-44 Satellite",
"description": "Work 44 QSOs on satellite RS-44",
"caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts.",
"category": "custom",
"rules": {
"type": "counter",
"target": 44,
"countBy": "qso",
"displayField": "callsign",
"filters": {
"operator": "AND",
"filters": [
{ "field": "satName", "operator": "eq", "value": "RS-44" }
]
}
}
}
Note: At runtime, counter rules are internally converted to entity rules with entityType: "callsign".
Filters
Filters allow awards to apply criteria based on QSO fields.
Filter Structure
"filters": {
"operator": "AND|OR",
"filters": [
{ "field": "fieldName", "operator": "operator", "value": "value" },
...
]
}
Filter Operators
| Operator | Description | Example |
|---|---|---|
"eq" |
Equals | { "field": "band", "operator": "eq", "value": "20m" } |
"ne" |
Not equals | { "field": "mode", "operator": "ne", "value": "FM" } |
"in" |
In array | { "field": "band", "operator": "in", "value": ["20m", "40m"] } |
"nin" |
Not in array | { "field": "mode", "operator": "nin", "value": ["FM", "AM"] } |
"contains" |
Contains substring | { "field": "callsign", "operator": "contains", "value": "DL" } |
Available Filter Fields
Any QSO database field can be filtered:
| Field | Type | Example |
|---|---|---|
band |
string | "80m", "20m", "2m" |
mode |
string | "CW", "SSB", "FT8" |
callsign |
string | "DJ7NT" |
entity |
string | "Germany" |
entityId |
number | 230 |
state |
string | "CA", "NY" |
grid |
string | "FN31pr" |
satName |
string | "AO-73", "RS-44" |
satellite |
boolean | true (has satellite name) |
Special Field: satellite
The satellite field is a computed field:
- Returns
trueif QSO has a non-emptysatName - Returns
falseotherwise
Compound Filters
Multiple filters can be combined with AND or OR:
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" },
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
This creates: DLD 80m CW (only QSOs on 80m band AND CW mode count).
QSO Database Schema
The award system relies on QSO records with specific fields.
Required QSO Fields
| Field | Type | Description |
|---|---|---|
userId |
number | User/owner identifier |
id |
number | Unique QSO identifier |
callsign |
string | Station callsign contacted |
qsoDate |
string | QSO date (YYYY-MM-DD) |
timeOn |
string | QSO time (HH:MM) |
band |
string | Amateur band (e.g., "20m", "80m") |
mode |
string | Mode (e.g., "CW", "SSB", "FT8") |
DXCC Fields
| Field | Type | Description |
|---|---|---|
entityId |
number | DXCC entity ID (numeric) |
entity |
string | DXCC entity name (e.g., "Germany") |
US State Fields
| Field | Type | Description |
|---|---|---|
state |
string | US state code (e.g., "CA", "NY") |
Grid Square Fields
| Field | Type | Description |
|---|---|---|
grid |
string | Maidenhead grid square (e.g., "FN31pr") |
DOK Fields (German Awards)
| Field | Type | Description |
|---|---|---|
darcDok |
string | DARC Ortsverband Kennung (e.g., "F03", "P30") |
Satellite Fields
| Field | Type | Description |
|---|---|---|
satName |
string | Satellite name (e.g., "AO-73", "RS-44") |
LoTW Confirmation Fields
| Field | Type | Description |
|---|---|---|
lotwQslRstatus |
string | LoTW QSL received status ("Y" = confirmed) |
lotwQslRdate |
string | LoTW QSL received date (YYYY-MM-DD) |
DCL Confirmation Fields
| Field | Type | Description |
|---|---|---|
dclQslRstatus |
string | DCL QSL received status ("Y" = confirmed) |
dclQslRdate |
string | DCL QSL received date (YYYY-MM-DD) |
Progress Calculation Result
The award calculation returns a standardized result object:
Base Result Structure
{
worked: number, // Count of worked entities
confirmed: number, // Count of confirmed entities
target: number, // Award target
percentage: number, // Progress percentage (0-100)
workedEntities: string[], // Array of worked entity identifiers
confirmedEntities: string[] // Array of confirmed entity identifiers
}
Detail Result Structure (with includeDetails: true)
{
// ... base fields ...
award: {
id: string,
name: string,
description: string,
caption: string,
target: number
},
entities: [
{
qsoId: number, // Reference QSO ID
entity: string, // Entity identifier
entityId: number, // DXCC entity ID (if applicable)
entityName: string, // Display name
worked: boolean,
confirmed: boolean,
qsoDate: string,
band: string,
mode: string,
callsign: string,
lotwQslRdate?: string, // Present if confirmed
dclQslRdate?: string, // Present if DCL confirmed
satName?: string, // For satellite awards
points?: number // For points-based awards
}
],
total: number, // Total entities
confirmed: number // Confirmed count
}
Implementation Guide
Core Algorithm
1. Load award definition from JSON file
2. Query QSOs for user from database
3. Apply filters (if defined in rules)
4. Based on rule type:
- entity: Count unique entity values
- dok: Count unique (DOK, band, mode) combinations
- points: Sum points by countMode
- filtered: Apply filters to base entity rule
- counter: Count QSOs or callsigns
5. Return progress with worked/confirmed counts
Rule Type Resolution
1. If rules.type === "filtered":
Convert to entity rule with baseRule + filters
2. If rules.type === "counter":
Convert to entity rule with entityType = "callsign"
3. If rules.type === "dok":
Use dedicated DOK calculation
4. If rules.type === "points":
Use dedicated points calculation
5. If rules.type === "entity":
Use standard entity calculation
Filter Application
function applyFilters(qsos, filters) {
if filters.operator === "AND":
return qsos where ALL filter conditions match
else if filters.operator === "OR":
return qsos where ANY filter condition matches
}
function matchesFilter(qso, filter) {
value = qso[filter.field]
switch filter.operator:
case "eq": return value === filter.value
case "ne": return value !== filter.value
case "in": return filter.value.includes(value)
case "nin": return !filter.value.includes(value)
case "contains": return value.includes(filter.value)
}
Entity Value Extraction
function getEntityValue(qso, entityType) {
switch entityType:
case "dxcc": return qso.entityId
case "state": return qso.state
case "grid": return qso.grid.substring(0, 4) // First 4 chars
case "callsign": return qso.callsign
}
Confirmation Systems
LoTW (Logbook of The World)
- Confirmation field:
lotwQslRstatus === 'Y' - Date field:
lotwQslRdate - Used for: DXCC, WAS, VUCC, most entity-based awards
DCL (DARC Community Logbook)
- Confirmation field:
dclQslRstatus === 'Y' - Date field:
dclQslRdate - Required for: DLD award
- Provides: DOK fields (
darcDok)
Award Variants
Using filters, you can create award variants from a base award:
| Base Award | Filter | Variant |
|---|---|---|
| DLD | band = "80m" |
DLD 80m |
| DLD | mode = "CW" |
DLD CW |
| DLD | band = "80m" AND mode = "CW" |
DLD 80m CW |
| DXCC | mode = "CW" |
DXCC CW |
| DXCC | band = "20m" |
DXCC 20m |
| DXCC | satellite = true |
DXCC Satellite |
Example Awards
1. DXCC Mixed Mode
{
"id": "dxcc-mixed",
"name": "DXCC Mixed Mode",
"description": "Confirm 100 DXCC entities on any band/mode",
"caption": "Contact and confirm 100 different DXCC entities. Any band and mode combination counts.",
"category": "dxcc",
"rules": {
"type": "entity",
"entityType": "dxcc",
"target": 100,
"displayField": "entity"
}
}
2. WAS (Worked All States)
{
"id": "was-mixed",
"name": "WAS Mixed Mode",
"description": "Confirm all 50 US states",
"caption": "Contact and confirm all 50 US states.",
"category": "was",
"rules": {
"type": "entity",
"entityType": "state",
"target": 50,
"displayField": "state",
"filters": {
"operator": "AND",
"filters": [
{ "field": "entityId", "operator": "eq", "value": 291 }
]
}
}
}
3. VUCC Satellite
{
"id": "vucc-satellite",
"name": "VUCC Satellite",
"description": "Confirm 100 unique grid squares via satellite",
"caption": "Contact and confirm 100 unique 4-character grid squares via satellite.",
"category": "vucc",
"rules": {
"type": "entity",
"entityType": "grid",
"target": 100,
"displayField": "grid",
"filters": {
"operator": "AND",
"filters": [
{ "field": "satellite", "operator": "eq", "value": true }
]
}
}
}
4. 73 on 73 (Satellite)
{
"id": "73-on-73",
"name": "73 on 73",
"description": "Confirm 73 unique QSO partners on satellite AO-73",
"caption": "Contact and confirm 73 different stations via the AO-73 satellite.",
"category": "satellite",
"rules": {
"type": "entity",
"entityType": "callsign",
"target": 73,
"displayField": "callsign",
"filters": {
"operator": "AND",
"filters": [
{ "field": "satName", "operator": "eq", "value": "AO-73" }
]
}
}
}
5. DLD 80m CW (Combined Filters)
{
"id": "dld-80m-cw",
"name": "DLD 80m CW",
"description": "Confirm 100 unique DOKs on 80m using CW",
"caption": "Contact and confirm 100 unique DOKs on 80m band using CW mode.",
"category": "darc",
"rules": {
"type": "dok",
"target": 100,
"confirmationType": "dcl",
"displayField": "darcDok",
"filters": {
"operator": "AND",
"filters": [
{ "field": "band", "operator": "eq", "value": "80m" },
{ "field": "mode", "operator": "eq", "value": "CW" }
]
}
}
}
Pseudocode Implementation
Main Calculation Function
function calculateAwardProgress(userId, award, includeDetails = false):
rules = normalizeRules(award.rules)
// Get user's QSOs
qsos = database.getQSOs(userId)
// Apply filters
if rules.filters:
qsos = applyFilters(qsos, rules.filters)
// Calculate based on rule type
switch rules.type:
case "entity":
return calculateEntityProgress(qsos, rules, includeDetails)
case "dok":
return calculateDOKProgress(qsos, rules, includeDetails)
case "points":
return calculatePointsProgress(qsos, rules, includeDetails)
Entity Progress Calculation
function calculateEntityProgress(qsos, rules, includeDetails):
workedEntities = new Set()
confirmedEntities = new Set()
entityDetails = []
for qso in qsos:
entity = getEntityValue(qso, rules.entityType)
if entity is null: continue
workedEntities.add(entity)
if qso.lotwQslRstatus === 'Y':
confirmedEntities.add(entity)
if includeDetails and entity not in entityDetails:
entityDetails.push({
qsoId: qso.id,
entity: entity,
entityName: getDisplayName(qso, rules.displayField),
worked: true,
confirmed: true,
qsoDate: qso.qsoDate,
band: qso.band,
mode: qso.mode,
callsign: qso.callsign
})
result = {
worked: workedEntities.size,
confirmed: confirmedEntities.size,
target: rules.target,
percentage: (confirmedEntities.size / rules.target) * 100
}
if includeDetails:
result.entities = entityDetails
result.total = entityDetails.length
return result
DOK Progress Calculation
function calculateDOKProgress(qsos, rules, includeDetails):
combinations = new Map() // Key: "DOK/band/mode"
for qso in qsos:
dok = qso.darcDok
if dok is null: continue
band = qso.band || "Unknown"
mode = qso.mode || "Unknown"
key = `${dok}/${band}/${mode}`
if not combinations.has(key):
combinations.set(key, {
qsoId: qso.id,
entity: dok,
entityName: dok,
band: band,
mode: mode,
callsign: qso.callsign,
worked: false,
confirmed: false
})
detail = combinations.get(key)
detail.worked = true
if qso.dclQslRstatus === 'Y':
detail.confirmed = true
detail.dclQslRdate = qso.dclQslRdate
workedDOKs = new Set()
confirmedDOKs = new Set()
for [key, detail] in combinations:
workedDOKs.add(detail.entity)
if detail.confirmed:
confirmedDOKs.add(detail.entity)
result = {
worked: workedDOKs.size,
confirmed: confirmedDOKs.size,
target: rules.target,
percentage: (confirmedDOKs.size / rules.target) * 100
}
if includeDetails:
result.entities = Array.from(combinations.values())
result.total = result.entities.length
return result
Points Progress Calculation
function calculatePointsProgress(qsos, rules, includeDetails):
stationPoints = new Map()
for station in rules.stations:
stationPoints.set(station.callsign.toUpperCase(), station.points)
workedStations = new Set()
totalPoints = 0
stationDetails = []
if rules.countMode === "perBandMode":
combinationMap = new Map()
for qso in qsos:
callsign = qso.callsign.toUpperCase()
points = stationPoints.get(callsign)
if not points: continue
band = qso.band || "Unknown"
mode = qso.mode || "Unknown"
key = `${callsign}/${band}/${mode}`
if not combinationMap.has(key):
combinationMap.set(key, {
qsoId: qso.id,
callsign: callsign,
band: band,
mode: mode,
points: points,
worked: true,
confirmed: false
})
if qso.lotwQslRstatus === 'Y':
detail = combinationMap.get(key)
if not detail.confirmed:
detail.confirmed = true
details = Array.from(combinationMap.values())
totalPoints = details.filter(d => d.confirmed).reduce(sum, d.points)
result = {
worked: workedStations.size,
totalPoints: totalPoints,
target: rules.target,
percentage: (totalPoints / rules.target) * 100
}
if includeDetails:
result.entities = details.map(formatEntity)
result.total = details.length
return result
Language Implementation Notes
Data Structure Recommendations
- Use Sets for uniqueness: When counting unique entities, use hash sets for O(1) lookups
- Use Maps for combinations: When tracking (DOK, band, mode) combinations, use composite keys
- Normalize early: Convert filtered/counter rules to entity rules at load time
Performance Considerations
- Cache award definitions: Load once at startup, not per calculation
- Database queries: Apply filters at database level when possible (SQL WHERE clauses)
- Pagination: For large QSO datasets, process in batches
- Index fields: Ensure database indexes on userId, entityId, band, mode, callsign
Confirmation Field Handling
// Helper function for unified confirmation checking
function isConfirmed(qso): boolean {
return qso.lotwQslRstatus === 'Y' || qso.dclQslRstatus === 'Y'
}
Extension Points
Adding New Rule Types
To add a new rule type:
- Define the rule schema (properties, required fields)
- Implement calculation function
- Add case to main calculation switch
- Add example award definition
Adding New Entity Types
To add a new entity type:
- Add to
entityTypeenum in entity rule - Add case to
getEntityValue()function - Ensure QSO database has the field
- Add display field mapping if needed
Adding New Filter Operators
To add a new filter operator:
- Add operator name to filter schema
- Implement comparison logic in
matchesFilter() - Add documentation with examples
Summary
The award system is a flexible, JSON-driven framework for calculating amateur radio award progress. Key design principles:
- Externalized definitions: Awards defined as JSON, not code
- Rule-based calculation: Different rule types for different award patterns
- Filter-based variants: Create awards variants without new rule types
- Standardized output: Consistent progress format across all award types
- Confirmation system aware: Supports LoTW and DCL separately
This specification provides all information needed to implement the system in any programming language.