Add the core, contest, storage and format layers
Frequencies, bands, modes, callsigns, grid squares and the country file live in Nonemm.Core. Nonemm.Contests holds the scoring engine and CQ WW and CQ WPX. Nonemm.Storage writes N1MM's DXLOG schema, and Nonemm.Formats writes Cabrillo 3.0 and reads and writes ADIF. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
338
.claude/skills/code-style/SKILL.md
Normal file
338
.claude/skills/code-style/SKILL.md
Normal file
@@ -0,0 +1,338 @@
|
||||
---
|
||||
name: code-style
|
||||
description: Language-neutral code style rules - comments, file and folder layout, module dependencies, constants vs literals, OOP vs FP, errors and async, naming, language-specific notes, tests. Load when writing or reviewing code in any language.
|
||||
---
|
||||
|
||||
# Code style
|
||||
|
||||
Sections 2 to 10 of the project style guide. Section 1 (prose) and section 11
|
||||
(the finishing checklist) are in `CLAUDE.md` and load every session.
|
||||
|
||||
Examples are JS-flavoured pseudocode using one running scenario: a service that
|
||||
serves a product catalog over HTTP, backed by a database and a cache. The rules
|
||||
are language-neutral. Translate the syntax; keep the rule.
|
||||
|
||||
---
|
||||
|
||||
## 2. Comments
|
||||
|
||||
Default: **no comment**. Code that reads clearly needs none. Write one only when
|
||||
one of these is true:
|
||||
|
||||
- The *why* isn't visible from the code (a workaround, a spec quirk, a
|
||||
performance trade-off).
|
||||
- The mechanics are genuinely non-obvious (a subtle index, an ordering
|
||||
requirement).
|
||||
- The signature needs a type or unit the code doesn't state.
|
||||
- It's a `TODO` / `FIXME` with an actual next step.
|
||||
|
||||
Exported symbols should have a one-line doc comment when it tells the reader
|
||||
something the name and signature don't. A should, not a must: don't write
|
||||
`/** Returns the base URL. */` above `getBaseUrl()`.
|
||||
|
||||
Keep comments to one line where possible. Lowercase, informal and short is fine:
|
||||
|
||||
```
|
||||
this.pending = []; // indexed by position in the batch
|
||||
|
||||
// group by host so we send one request per host
|
||||
const perHost = new Map();
|
||||
```
|
||||
|
||||
Never write a comment that narrates your thought process, restates the line
|
||||
below it, or explains a design philosophy. Those belong in a design note under
|
||||
`docs/` or in the PR description, not in the source.
|
||||
|
||||
```
|
||||
// BAD — restates the code
|
||||
// Loop over the users and collect their IDs
|
||||
const ids = users.map(u => u.id);
|
||||
|
||||
// BAD — internal monologue
|
||||
// The user will most likely be scanning this list top to bottom, so what they
|
||||
// really want here is for the newest items to feel immediately reachable, which
|
||||
// means we sort descending and let the eye land on the first row.
|
||||
sortDescending(items, 'createdAt');
|
||||
|
||||
// GOOD — explains a non-obvious constraint
|
||||
// the API returns oldest-first; the UI expects the opposite
|
||||
sortDescending(items, 'createdAt');
|
||||
```
|
||||
|
||||
Doc comments only where they add something the declaration doesn't. In a typed
|
||||
language the signature already states most of it, so delete any parameter doc
|
||||
that repeats the type. Keep one like this — the type `string` doesn't say the
|
||||
value is a root URL, and the example shows the expected format:
|
||||
|
||||
```
|
||||
/**
|
||||
* @param baseUrl the site root, e.g. https://example.com/
|
||||
*/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Files and folders
|
||||
|
||||
One folder per concern. Extensions vary by language; the shape doesn't:
|
||||
|
||||
```
|
||||
src/
|
||||
main entry point, process setup
|
||||
http/
|
||||
index creates the server, registers the routes
|
||||
AuthMiddleware
|
||||
catalog/
|
||||
Product
|
||||
ProductRepository
|
||||
db/
|
||||
index
|
||||
util/
|
||||
hashing
|
||||
```
|
||||
|
||||
- **Group folders by subject, not by shape.** `catalog/` holds everything about
|
||||
products. Don't create `helpers/` or `constants/` folders.
|
||||
- **A shared types or declarations folder is for declarations, not domain
|
||||
types.** Ambient declarations, generated API clients and types used across
|
||||
several folders belong there. A type used by one module stays in that module.
|
||||
- **A module's entry point file is an entry point, not a barrel.** It creates
|
||||
what the module provides and exports the function the caller calls. Don't fill
|
||||
it with re-exports of the folder's internals.
|
||||
- **One class per file, named after the class.** Small related types can share a
|
||||
file, but the file name must match what's inside.
|
||||
- **Free functions go in a lowercase-named file** describing the topic
|
||||
(`hashing`, `formatting`), not a catch-all `utils`.
|
||||
- **Keep files small.** Over ~300 lines, check whether part of the file is a
|
||||
separate concern and split it out.
|
||||
- **Don't put a class, unrelated helper functions and a constants block in the
|
||||
same file.** Split them into three files.
|
||||
- **Design notes go in `docs/`.** Anything explaining why the system is shaped
|
||||
the way it is — decisions, trade-offs, rejected alternatives — goes in a file
|
||||
under `docs/`, not in a source comment.
|
||||
|
||||
If a piece of code is only ever used in relation to one class or module, put it
|
||||
there. `Product.getImageUrl()` is a method on `Product`, not a
|
||||
`buildProductImageUrl(product)` in `util/`. Don't make a file per method.
|
||||
|
||||
Move code into a shared module when it gets a second caller, not before.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dependencies between modules
|
||||
|
||||
Dependencies point in one direction: entry point → wiring → workers → the
|
||||
modules at the end of the chain.
|
||||
|
||||
```
|
||||
main → http/index → catalog/ProductRepository → util/hashing
|
||||
→ catalog/Product
|
||||
→ db/index
|
||||
```
|
||||
|
||||
- **No cycles.** If module A imports B and B imports A, fix it one of two ways:
|
||||
move the shared code into a third module both can import, or move the method
|
||||
into the module where the data it uses is defined.
|
||||
- **Give the modules at the end of the chain no project imports.** `hashing` and
|
||||
`Product` import only third-party packages, so you can move them or test them
|
||||
on their own.
|
||||
- **Pass dependencies in as constructor arguments.** `ProductRepository` takes
|
||||
the database and cache clients in its constructor; `AuthMiddleware` takes a
|
||||
`verifyToken` callback. Neither imports the config or a global singleton.
|
||||
|
||||
```
|
||||
// BAD — reaches for a module-level singleton and the global config
|
||||
class ProductRepository {
|
||||
find(id) {
|
||||
return cache.get(config.keyPrefix + id); // both imported at the top
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD — dependencies passed in by the caller
|
||||
class ProductRepository {
|
||||
constructor(cache, keyPrefix) { ... }
|
||||
|
||||
find(id) {
|
||||
return this.cache.get(this.keyPrefix + id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Import the config only in the entry point and the wiring files.** Pass the
|
||||
values down as arguments. No other module reads it.
|
||||
- **Declare each value and each rule in one file.** The list of upstream hosts is
|
||||
in `http/index` and nowhere else. If the same literal or the same logic appears
|
||||
in two files, delete one and import it from the other.
|
||||
|
||||
Single responsibility, practically. Ask: *can I describe this file's job in one
|
||||
sentence, without "and"?*
|
||||
|
||||
- `Product`: holds the fields of one catalog item.
|
||||
- `ProductRepository`: loads products from the database.
|
||||
- `AuthMiddleware`: checks the Authorization header.
|
||||
- `hashing`: turns an object into a stable hash.
|
||||
|
||||
Not everything needs its own module. A helper called from one place can stay a
|
||||
local function in that file. Split a file when it covers two subjects that don't
|
||||
share state.
|
||||
|
||||
---
|
||||
|
||||
## 5. Constants vs literals
|
||||
|
||||
Extract a value when it is **repeated**, **configurable**, or **not
|
||||
understandable from the value alone**. Otherwise inline it.
|
||||
|
||||
```
|
||||
// BAD
|
||||
const METRES_PER_KILOMETRE = 1000;
|
||||
const CACHE_KEY_PREFIX = 'product:';
|
||||
|
||||
// GOOD — used once, obvious in place
|
||||
const km = metres / 1000;
|
||||
const key = 'product:' + hashObject(query);
|
||||
```
|
||||
|
||||
- Unit conversions, `0`, `1`, `-1`, `100` for percent: inline.
|
||||
- A magic number whose meaning isn't obvious (`0o666`, `86_400`, a retry limit
|
||||
someone will want to tune): name it, or add a short comment.
|
||||
- A value used in three places: declare it once in the module that uses it most,
|
||||
and import it from there. Not in a global constants file.
|
||||
- A value that must stay in sync with something outside the file (a server limit,
|
||||
a CSS breakpoint): name it, and say in a comment what it matches.
|
||||
- Anything deployment-specific (URLs, ports, keys, limits): config file.
|
||||
|
||||
---
|
||||
|
||||
## 6. OOP and FP
|
||||
|
||||
Applies in every language that has both. Pick one per module and stay with it.
|
||||
|
||||
- **Class** when there is state that persists between calls, or several methods
|
||||
over the same data: a queue that holds pending items, a client that holds a
|
||||
connection.
|
||||
- **Plain function** when it's a transform with no state: `hashObject`,
|
||||
`formatPrice`.
|
||||
- **Don't mix them in one file.** Move exported free functions out of a file that
|
||||
defines a class.
|
||||
- **No class with only static methods.** Export the functions from a module, or
|
||||
use whatever the language offers for free functions.
|
||||
- **No inheritance unless the subclass is a kind of the base class.** Otherwise
|
||||
pass a function or an object in, as `AuthMiddleware` takes `verifyToken`.
|
||||
|
||||
### State and mutation
|
||||
|
||||
- Don't mutate function arguments. Return a new value instead.
|
||||
- Don't export mutable module-level or global state. If state is shared, put it
|
||||
in a class and pass the instance to whoever needs it.
|
||||
- Know which of your language's collection operations mutate in place and which
|
||||
return a copy. Sorting and reversing usually mutate: `sort()`, `reverse()`,
|
||||
`splice()` and `push()` in JS, `list.sort()` and `list.append()` in Python,
|
||||
`std::sort` in C++, `Collections.sort` in Java. Copy first when the caller
|
||||
still needs the original.
|
||||
- Watch for shared references when creating collections. Filling n slots with one
|
||||
empty collection puts the *same* object in every slot, so writing to one writes
|
||||
to all. This bites in JS (`new Array(n).fill([])`), Python (`[[]] * n`) and
|
||||
anywhere else that copies the reference rather than the value. Construct each
|
||||
element separately.
|
||||
|
||||
---
|
||||
|
||||
## 7. Errors and async
|
||||
|
||||
- **Don't swallow errors.** A `catch` block either handles the failure (retry,
|
||||
documented fallback) or rethrows. Logging and continuing is not handling it.
|
||||
- **The caller must be able to tell a failure from an empty result.** Don't
|
||||
return an empty list, null or nothing for both. Throw, or return a result type
|
||||
that states which happened.
|
||||
- **Catch around the statement that can fail**, not around the whole function
|
||||
body.
|
||||
- **Put the values needed to debug in the message**: the URL, the id, the status
|
||||
code. Not `'Request failed'`.
|
||||
- **Rethrow with added context or not at all.** `catch (e) { throw e; }` is
|
||||
noise. Preserve the original error as the cause where the language supports it.
|
||||
- **No unobserved async results.** Every promise, future or task is awaited,
|
||||
returned, or has an error handler attached. Turn on the linter rule for this if
|
||||
the language has one.
|
||||
- **Don't mix callbacks and promises** in one code path.
|
||||
|
||||
```
|
||||
// BAD — the caller gets nothing back and cannot tell why
|
||||
async function fetchProducts(url) {
|
||||
try {
|
||||
return await post(url, body);
|
||||
} catch (e) {
|
||||
console.error('fetch rejected', e);
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD — the error propagates, with the context to debug it
|
||||
async function fetchProducts(url) {
|
||||
try {
|
||||
return await post(url, body);
|
||||
} catch (cause) {
|
||||
throw new Error(`product request to ${url} failed`, { cause });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Naming
|
||||
|
||||
Follow the dominant convention of the language you're in: `PascalCase` classes
|
||||
with `camelCase` members in TS, Java and C#; `snake_case` in Python, Rust and C.
|
||||
Don't import another language's casing. On top of that:
|
||||
|
||||
- No Hungarian notation and no `I` prefix on interfaces.
|
||||
- Constant casing only for values that never change.
|
||||
- File names match the main export: the class name for a class, a topic name for
|
||||
a group of functions.
|
||||
- Functions start with a verb: `buildUrl()`, not `urlBuilder()`. Booleans start
|
||||
with `is`, `has` or `should`.
|
||||
- Don't repeat the containing type in a member name: `Product.getImageUrl()`, not
|
||||
`Product.getProductImageUrl()`.
|
||||
- No invented abbreviations. `id`, `url`, `db`, `req`/`res` are fine because
|
||||
everyone reads them; `prdct` or `rqSndr` are not.
|
||||
|
||||
---
|
||||
|
||||
## 9. Language-specific notes
|
||||
|
||||
Replace this section with the rules for the language in use. TypeScript, as a
|
||||
worked example:
|
||||
|
||||
- No `any`. Use `unknown` at boundaries and narrow.
|
||||
- Define types in the module that uses them. A shared `types.ts` only for types
|
||||
genuinely used across several folders.
|
||||
- Prefer `type` for unions and object shapes, `interface` for things meant to be
|
||||
implemented.
|
||||
- Don't add a wrapper type until it prevents a real mistake. `string` is fine for
|
||||
a URL until you have two kinds of string that could be swapped.
|
||||
- `strict: true` in `tsconfig.json`. Don't disable a check for one file.
|
||||
- No `as` casts to force a shape. Parse external data into a typed value at the
|
||||
boundary and let the check fail there.
|
||||
- No non-null assertion (`!`) without a comment saying why the value can't be
|
||||
null.
|
||||
- `readonly` on fields and parameters that are never reassigned; `as const` for
|
||||
literal tables.
|
||||
- Exhaustive `switch` on a union: add a `default` that assigns to `never`, so
|
||||
adding a case breaks the build.
|
||||
|
||||
The equivalents elsewhere: `const` correctness and RAII in C++, `final` and
|
||||
`Optional` in Java, strict types and typed properties in PHP, type hints checked
|
||||
by a type checker in Python.
|
||||
|
||||
---
|
||||
|
||||
## 10. Tests
|
||||
|
||||
- Test through the module's entry point, not its internals. A test that calls a
|
||||
private method breaks on every refactor.
|
||||
- One behavior per test, named after what it asserts.
|
||||
- No mocking framework for our own code. Pass a fake in through the constructor.
|
||||
That is what section 4 is for.
|
||||
- Test files sit next to the code they test, following the language's convention
|
||||
for test file names.
|
||||
- A bug fix includes the test that reproduces the bug.
|
||||
- Don't test getters, types, or that a library works.
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
/N1MM/
|
||||
92
CLAUDE.md
Normal file
92
CLAUDE.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Rules for writing code or prose in this project.
|
||||
|
||||
These are language-neutral: they apply to TypeScript, C++, Java, PHP, Python and anything else.
|
||||
|
||||
Section 1 covers prose and is loaded every session. Sections 2 to 10 cover code
|
||||
and live in `.claude/skills/code-style/SKILL.md`, which loads on demand.
|
||||
|
||||
IMPORTANT: when compacting, keep section 1 in full.
|
||||
|
||||
## 1. How to write prose
|
||||
|
||||
This applies to chat replies, commit messages, PR descriptions, docs and
|
||||
comments.
|
||||
|
||||
Write like a senior engineer who is tired and hates long emails. Plain English,
|
||||
short sentences, to an intelligent colleague who knows how to program.
|
||||
Use ASD-STE100 Simplified Technical English for prose, including casual replies.
|
||||
|
||||
**Optimize for comprehension, not for style.** The reader should understand
|
||||
the text with the least effort. If you have to re-read a sentence to parse it,
|
||||
rewrite it.
|
||||
|
||||
### Banned patterns
|
||||
|
||||
| Pattern | Example | Instead |
|
||||
| --- | --- | --- |
|
||||
| "is X, never Y" constructions | "the catalogue is walked off disk, never listed" | "it scans the directory instead of reading a list" |
|
||||
| Personified code | "every specimen owes every applicable feature" | "each test case must cover every feature" |
|
||||
| Blog-speak | "footgun", "load-bearing", "this lands", "source of truth", "by design" | say what actually happens |
|
||||
| Em-dash pile-ups and aphorisms | "an undeclared specimen fails rather than being skipped — and a suite that skipped a file looks exactly like one that passed it" | "unknown files fail the suite, so a skipped file can't be mistaken for a passing one" |
|
||||
| Invented nouns | "specimen", "matrix", "catalogue" for ordinary things | "test file", "config table", "directory" |
|
||||
| Ownership metaphors | "the module that owns this data", "a single owner for the value" | "the module where the data is defined" |
|
||||
| Virtue and morality | "keeps the invariant honest", "respects the contract", "a well-behaved caller" | "checks the count matches before returning" |
|
||||
| Intent and knowledge verbs | "the matrix decides", "the runner knows about", "the module wants", "the config owes" | "the matrix selects", "the runner reads", "the module requires", "the config must define" |
|
||||
|
||||
### The literal test
|
||||
|
||||
Read each sentence literally. If the subject cannot literally perform the verb,
|
||||
rewrite it. Modules don't want, know, own, decide, care or owe. Invariants
|
||||
aren't honest. Code isn't well-behaved.
|
||||
|
||||
This bans intent, desire, knowledge and virtue. It does not ban ordinary
|
||||
mechanical vocabulary: a function returns, throws, reads, writes and requires;
|
||||
a parser expects an argument; a compiler reports an error. Those describe what
|
||||
actually happens.
|
||||
|
||||
### Required
|
||||
|
||||
- Use lists and tables where the content is a list or a table.
|
||||
- Name things by their real names: file names, function names, types.
|
||||
- State the conclusion first, then the reasoning. Don't build up to it.
|
||||
- If something is uncertain, say "I'm not sure" and say why.
|
||||
|
||||
### Length
|
||||
|
||||
- Answer in the fewest sentences that fully answer. A one-fact answer is one sentence.
|
||||
- Don't restate the question before answering it.
|
||||
- Don't pad a short answer to make it look thorough.
|
||||
|
||||
### State rules as instructions, not slogans
|
||||
|
||||
A rule must say what to do. If the reader has to decode a metaphor to work out
|
||||
the action, rewrite it as an instruction. This applies to this file too: check
|
||||
each line against "could I hand this to someone and have them do it?"
|
||||
|
||||
| Slogan | Instruction |
|
||||
| --- | --- |
|
||||
| "if two modules need each other, a third thing wants to exist" | "move the shared code into a module both can import" |
|
||||
| "that's a module with extra steps" | "export the functions from a module instead" |
|
||||
| "one of them is wrong" | "delete one and import it from the other" |
|
||||
| "that comment earns its place" | "keep that comment: the type doesn't say the value is a root URL" |
|
||||
| "the worker reaches for global state" | "the class imports a module-level singleton instead of taking it as an argument" |
|
||||
|
||||
The same applies to judgments. Don't call something "clean", "solid",
|
||||
"idiomatic" or "the right call". Say what it does, or what breaks without it.
|
||||
|
||||
### Example
|
||||
|
||||
Bad:
|
||||
|
||||
> Every specimen owes every applicable feature, and the matrix decides which.
|
||||
> The smoke-repo catalogue is walked off disk, never listed, so new syntax
|
||||
> cannot arrive untested.
|
||||
|
||||
Good:
|
||||
|
||||
> The test runner scans `tests/smoke/` at startup rather than reading a
|
||||
> hardcoded list, so a new test file is picked up automatically. Files that
|
||||
> aren't registered in the test matrix fail instead of being skipped.
|
||||
|
||||
12
Directory.Build.props
Normal file
12
Directory.Build.props
Normal file
@@ -0,0 +1,12 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
19
Nonemm.slnx
Normal file
19
Nonemm.slnx
Normal file
@@ -0,0 +1,19 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Nonemm.Core/Nonemm.Core.csproj" />
|
||||
<Project Path="src/Nonemm.Contests/Nonemm.Contests.csproj" />
|
||||
<Project Path="src/Nonemm.Formats/Nonemm.Formats.csproj" />
|
||||
<Project Path="src/Nonemm.Storage/Nonemm.Storage.csproj" />
|
||||
<Project Path="src/Nonemm.Rig/Nonemm.Rig.csproj" />
|
||||
<Project Path="src/Nonemm.Spotting/Nonemm.Spotting.csproj" />
|
||||
<Project Path="src/Nonemm.Network/Nonemm.Network.csproj" />
|
||||
<Project Path="src/Nonemm.Keying/Nonemm.Keying.csproj" />
|
||||
<Project Path="src/Nonemm.Session/Nonemm.Session.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Nonemm.Core.Tests/Nonemm.Core.Tests.csproj" />
|
||||
<Project Path="tests/Nonemm.Contests.Tests/Nonemm.Contests.Tests.csproj" />
|
||||
<Project Path="tests/Nonemm.Formats.Tests/Nonemm.Formats.Tests.csproj" />
|
||||
<Project Path="tests/Nonemm.Storage.Tests/Nonemm.Storage.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
48
OBJECTIVE.md
Normal file
48
OBJECTIVE.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Objective
|
||||
|
||||
Nonemm is a contest logger for amateur radio: a from-scratch reimplementation of N1MM Logger+ in C#, running on Linux and Windows.
|
||||
|
||||
This is what the finished program has to do.
|
||||
|
||||
## Scope
|
||||
|
||||
Contest rules, log formats and the database schema are written from their published definitions and are fully compatible with N1MM.
|
||||
|
||||
## Requirements
|
||||
|
||||
**N1MM file compatibility.** The log database is N1MM's `.s3db`, in N1MM's schema. User-defined
|
||||
contests are `.udc` files. Macros and message files are N1MM's. A log written by either program
|
||||
opens in the other. Columns N1MM caches and we compute — points, multiplier flags — get written
|
||||
anyway so N1MM is happy, and are ignored on read. New storage extends N1MM's tables instead of
|
||||
changing them. Keep the store behind an interface; it may be a hosted database later, but the
|
||||
file on disk is N1MM's format.
|
||||
|
||||
**No N1MM code in the repo.** N1MM decompiles, and it's the reference when the published rules
|
||||
are unclear or easy to get wrong. Check against it in a scratchpad; what lands in the repo is
|
||||
written from the published rule.
|
||||
|
||||
**Where N1MM is wrong, don't copy the bug.** Follow the published rule and add a test that says
|
||||
what N1MM returns and why we don't.
|
||||
|
||||
**Work without the optional inputs.** The country file and callsign database aren't bundled;
|
||||
they're downloaded from the same places N1MM gets them, and validated before anything is
|
||||
replaced. A failed download leaves the old file alone. Without a country file, country- and
|
||||
continent-scored contests lose accuracy but still run. With no radio connected, frequency and
|
||||
mode stay wherever they were last typed.
|
||||
|
||||
**Leave out what can't be done properly.** A check-window column with no data behind it stays
|
||||
out rather than showing a guess.
|
||||
|
||||
**Logic is testable without a UI.** What space does, when a dupe fires, what a QSO scores — that
|
||||
lives in a project with no UI framework reference and is covered by unit tests.
|
||||
|
||||
**Reimplement most of N1MM's features.** It's fine if some are left out for simplicity, but the main
|
||||
core must be preserved -- it's a fully ready-to-use networked (multi-station) contest logger, with
|
||||
all the features necessary for efficient contest operation -- dupe checks, partial matching, telnet cluster,
|
||||
band view, no wasted space.
|
||||
|
||||
## Done
|
||||
|
||||
An operator works a contest end to end — dupes and mults flagged as they type, bandmap and
|
||||
cluster feeding stations, radio and log following each other — exports a Cabrillo the sponsor
|
||||
accepts, and can open the log in N1MM.
|
||||
7
build.sh
Executable file
7
build.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Build or test the solution with the SDK this machine keeps in ~/.dotnet.
|
||||
export DOTNET_ROOT="$HOME/.dotnet"
|
||||
export PATH="$HOME/.dotnet:$HOME/.dotnet/tools:$PATH"
|
||||
export DOTNET_NOLOGO=1 DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
exec dotnet "$@"
|
||||
11
src/Nonemm.Contests/CabrilloExchange.cs
Normal file
11
src/Nonemm.Contests/CabrilloExchange.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// One column of a Cabrillo QSO line. Sponsors read these by column, so each
|
||||
/// carries the width it is padded to.
|
||||
public sealed record CabrilloField(string Value, int Width);
|
||||
|
||||
/// The exchange columns of one Cabrillo QSO line, in the order the sponsor's
|
||||
/// template lists them.
|
||||
public sealed record CabrilloExchange(
|
||||
IReadOnlyList<CabrilloField> Sent,
|
||||
IReadOnlyList<CabrilloField> Received);
|
||||
41
src/Nonemm.Contests/Contest.cs
Normal file
41
src/Nonemm.Contests/Contest.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// One contest's rules: what is exchanged, when a station may be worked again,
|
||||
/// what a contact scores and which multipliers it brings in.
|
||||
public interface Contest
|
||||
{
|
||||
/// The short name the log stores, e.g. `CQWW`.
|
||||
string Name { get; }
|
||||
|
||||
string DisplayName { get; }
|
||||
|
||||
/// The name the sponsor's Cabrillo header asks for.
|
||||
string CabrilloName { get; }
|
||||
|
||||
IReadOnlyList<ExchangeField> ExchangeFields { get; }
|
||||
|
||||
/// Up to three names, in the order the score summary shows them.
|
||||
IReadOnlyList<string> MultiplierNames { get; }
|
||||
|
||||
DupeScope DupeScope { get; }
|
||||
|
||||
/// True when serial numbers count up across the whole contest rather than
|
||||
/// per band.
|
||||
bool HasSerialNumbers { get; }
|
||||
|
||||
/// The modes the contest runs; empty means any.
|
||||
IReadOnlyList<ModeCategory> Modes { get; }
|
||||
|
||||
string SentExchangeFor(StationInfo me);
|
||||
|
||||
int PointsFor(QsoContext qso);
|
||||
|
||||
IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso);
|
||||
|
||||
int TotalScore(ScoreTally tally);
|
||||
|
||||
/// The exchange columns of this contact's Cabrillo line.
|
||||
CabrilloExchange CabrilloExchange(Qso qso, StationInfo me);
|
||||
}
|
||||
150
src/Nonemm.Contests/ContestLog.cs
Normal file
150
src/Nonemm.Contests/ContestLog.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Country;
|
||||
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// The contacts of one contest, with the indexes that answer "worked before?"
|
||||
/// and "new multiplier?" while the operator types.
|
||||
public sealed class ContestLog
|
||||
{
|
||||
private readonly Contest contest;
|
||||
private readonly StationInfo me;
|
||||
private readonly CountryFile? countries;
|
||||
private readonly List<Qso> qsos = [];
|
||||
private readonly HashSet<string> workedKeys = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> claimedMultipliers = new(StringComparer.Ordinal);
|
||||
private ScoreTally tally = new();
|
||||
|
||||
public ContestLog(Contest contest, StationInfo me, CountryFile? countries)
|
||||
{
|
||||
this.contest = contest;
|
||||
this.me = me;
|
||||
this.countries = countries;
|
||||
}
|
||||
|
||||
public Contest Contest => contest;
|
||||
|
||||
public IReadOnlyList<Qso> Qsos => qsos;
|
||||
|
||||
public ScoreTally Tally => tally;
|
||||
|
||||
public int TotalScore => contest.TotalScore(tally);
|
||||
|
||||
/// What logging this contact right now would do, without logging it.
|
||||
public Verdict Judge(Qso candidate)
|
||||
{
|
||||
QsoContext context = ContextFor(candidate);
|
||||
if (workedKeys.Contains(DupeKey(candidate)))
|
||||
{
|
||||
return Verdict.Dupe;
|
||||
}
|
||||
List<Multiplier> newOnes = [];
|
||||
foreach (Multiplier multiplier in contest.MultipliersFor(context))
|
||||
{
|
||||
if (!claimedMultipliers.Contains(MultiplierKey(multiplier)))
|
||||
{
|
||||
newOnes.Add(multiplier);
|
||||
}
|
||||
}
|
||||
return new Verdict(false, contest.PointsFor(context), newOnes);
|
||||
}
|
||||
|
||||
/// Adds the contact and returns it with points and multiplier flags filled in.
|
||||
public Qso Add(Qso qso)
|
||||
{
|
||||
Verdict verdict = Judge(qso);
|
||||
Qso scored = ApplyVerdict(qso, verdict);
|
||||
qsos.Add(scored);
|
||||
Index(scored, verdict);
|
||||
return scored;
|
||||
}
|
||||
|
||||
/// Loads contacts read back from storage. Points and multiplier flags in
|
||||
/// the stored rows are ignored and worked out again from the rules.
|
||||
public void Restore(IEnumerable<Qso> stored)
|
||||
{
|
||||
qsos.AddRange(stored);
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public void Remove(string id)
|
||||
{
|
||||
qsos.RemoveAll(q => q.Id == id);
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public void Replace(Qso qso)
|
||||
{
|
||||
int at = qsos.FindIndex(q => q.Id == qso.Id);
|
||||
if (at < 0)
|
||||
{
|
||||
throw new InvalidOperationException($"no contact with id {qso.Id} in the log");
|
||||
}
|
||||
qsos[at] = qso;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public bool IsWorked(Qso candidate) => workedKeys.Contains(DupeKey(candidate));
|
||||
|
||||
/// Every contact with this call, newest first.
|
||||
public IReadOnlyList<Qso> WorkedBefore(string call) =>
|
||||
qsos.Where(q => string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(q => q.TimestampUtc)
|
||||
.ToList();
|
||||
|
||||
private QsoContext ContextFor(Qso qso) =>
|
||||
new(qso, countries?.Find(qso.Call), me);
|
||||
|
||||
private Qso ApplyVerdict(Qso qso, Verdict verdict) => qso with
|
||||
{
|
||||
Points = verdict.Points,
|
||||
IsMultiplier1 = verdict.NewMultipliers.Any(m => m.Index == 1),
|
||||
IsMultiplier2 = verdict.NewMultipliers.Any(m => m.Index == 2),
|
||||
IsMultiplier3 = verdict.NewMultipliers.Any(m => m.Index == 3),
|
||||
};
|
||||
|
||||
private void Index(Qso qso, Verdict verdict)
|
||||
{
|
||||
workedKeys.Add(DupeKey(qso));
|
||||
tally.AddQso(verdict.Points);
|
||||
foreach (Multiplier multiplier in verdict.NewMultipliers)
|
||||
{
|
||||
claimedMultipliers.Add(MultiplierKey(multiplier));
|
||||
tally.AddMultiplier(multiplier.Index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rescores the whole log. A removed or edited contact can hand its
|
||||
/// multiplier to a later one, which only a full pass gets right.
|
||||
private void Rebuild()
|
||||
{
|
||||
workedKeys.Clear();
|
||||
claimedMultipliers.Clear();
|
||||
tally = new ScoreTally();
|
||||
List<Qso> ordered = qsos.OrderBy(q => q.TimestampUtc).ToList();
|
||||
qsos.Clear();
|
||||
foreach (Qso qso in ordered)
|
||||
{
|
||||
Verdict verdict = Judge(qso);
|
||||
Qso scored = ApplyVerdict(qso, verdict);
|
||||
qsos.Add(scored);
|
||||
Index(scored, verdict);
|
||||
}
|
||||
}
|
||||
|
||||
private string DupeKey(Qso qso)
|
||||
{
|
||||
string call = qso.Call.Text.ToUpperInvariant();
|
||||
return contest.DupeScope switch
|
||||
{
|
||||
DupeScope.Once => call,
|
||||
DupeScope.PerBand => $"{call}|{qso.Band?.Name}",
|
||||
DupeScope.PerMode => $"{call}|{qso.Mode.Category}",
|
||||
DupeScope.PerBandAndMode => $"{call}|{qso.Band?.Name}|{qso.Mode.Category}",
|
||||
_ => call,
|
||||
};
|
||||
}
|
||||
|
||||
private static string MultiplierKey(Multiplier multiplier) =>
|
||||
$"{multiplier.Index}|{multiplier.Value}|{multiplier.Scope}";
|
||||
}
|
||||
17
src/Nonemm.Contests/DupeScope.cs
Normal file
17
src/Nonemm.Contests/DupeScope.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// When a station may be worked again.
|
||||
public enum DupeScope
|
||||
{
|
||||
/// Once in the whole contest.
|
||||
Once,
|
||||
|
||||
/// Once per band, whatever the mode.
|
||||
PerBand,
|
||||
|
||||
/// Once per band and mode.
|
||||
PerBandAndMode,
|
||||
|
||||
/// Once per mode, whatever the band.
|
||||
PerMode,
|
||||
}
|
||||
27
src/Nonemm.Contests/ExchangeField.cs
Normal file
27
src/Nonemm.Contests/ExchangeField.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// One box in the entry window's exchange.
|
||||
public sealed record ExchangeField(
|
||||
string Label,
|
||||
ExchangeSlot Slot,
|
||||
ExchangeFieldKind Kind,
|
||||
bool IsRequired = true)
|
||||
{
|
||||
/// How wide the box needs to be, in characters.
|
||||
public int Width { get; init; } = DefaultWidth(Kind);
|
||||
|
||||
private static int DefaultWidth(ExchangeFieldKind kind) => kind switch
|
||||
{
|
||||
ExchangeFieldKind.Report => 3,
|
||||
ExchangeFieldKind.Number => 5,
|
||||
ExchangeFieldKind.CqZone => 2,
|
||||
ExchangeFieldKind.ItuZone => 2,
|
||||
ExchangeFieldKind.UsStateOrCanadianProvince => 3,
|
||||
ExchangeFieldKind.ArrlSection => 3,
|
||||
ExchangeFieldKind.Grid => 6,
|
||||
ExchangeFieldKind.Precedence => 1,
|
||||
ExchangeFieldKind.Check => 2,
|
||||
ExchangeFieldKind.Power => 4,
|
||||
_ => 8,
|
||||
};
|
||||
}
|
||||
18
src/Nonemm.Contests/ExchangeFieldKind.cs
Normal file
18
src/Nonemm.Contests/ExchangeFieldKind.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// What an exchange box accepts, which decides how it is checked and what the
|
||||
/// entry window offers while it is being typed.
|
||||
public enum ExchangeFieldKind
|
||||
{
|
||||
Report,
|
||||
Number,
|
||||
Text,
|
||||
UsStateOrCanadianProvince,
|
||||
ArrlSection,
|
||||
CqZone,
|
||||
ItuZone,
|
||||
Grid,
|
||||
Power,
|
||||
Precedence,
|
||||
Check,
|
||||
}
|
||||
20
src/Nonemm.Contests/ExchangeSlot.cs
Normal file
20
src/Nonemm.Contests/ExchangeSlot.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// Which field of a QSO an exchange box fills in. The names match the log
|
||||
/// columns so an exchange lands in the same place N1MM would put it.
|
||||
public enum ExchangeSlot
|
||||
{
|
||||
ReceivedReport,
|
||||
SerialNumber,
|
||||
Zone,
|
||||
Section,
|
||||
Check,
|
||||
Precedence,
|
||||
Exchange1,
|
||||
MiscText,
|
||||
Name,
|
||||
Qth,
|
||||
GridSquare,
|
||||
Power,
|
||||
Comment,
|
||||
}
|
||||
8
src/Nonemm.Contests/Multiplier.cs
Normal file
8
src/Nonemm.Contests/Multiplier.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// One multiplier a QSO claims. `Scope` is what makes it distinct: a country
|
||||
/// counted once per band carries the band, one counted once carries nothing.
|
||||
public sealed record Multiplier(int Index, string Value, string Scope)
|
||||
{
|
||||
public override string ToString() => Scope.Length == 0 ? Value : $"{Value}/{Scope}";
|
||||
}
|
||||
13
src/Nonemm.Contests/Nonemm.Contests.csproj
Normal file
13
src/Nonemm.Contests/Nonemm.Contests.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
25
src/Nonemm.Contests/QsoContext.cs
Normal file
25
src/Nonemm.Contests/QsoContext.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Country;
|
||||
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// A QSO together with what the country file says about it, so a contest can
|
||||
/// score by country, zone or continent without looking anything up itself.
|
||||
public sealed record QsoContext(Qso Qso, CountryLookup? Country, StationInfo Me)
|
||||
{
|
||||
public Band? Band => Qso.Band;
|
||||
|
||||
public ModeCategory ModeCategory => Qso.Mode.Category;
|
||||
|
||||
public string Continent => Country?.Continent ?? Qso.Continent;
|
||||
|
||||
public string CountryPrefix => Country?.Entity.PrimaryPrefix ?? Qso.CountryPrefix;
|
||||
|
||||
public int CqZone => Country?.CqZone ?? Qso.Zone;
|
||||
|
||||
public int ItuZone => Country?.ItuZone ?? 0;
|
||||
|
||||
public bool IsSameCountry => CountryPrefix.Length > 0 && CountryPrefix == Me.CountryPrefix;
|
||||
|
||||
public bool IsSameContinent => Continent.Length > 0 && Continent == Me.Continent;
|
||||
}
|
||||
79
src/Nonemm.Contests/Rules/CqWorldWide.cs
Normal file
79
src/Nonemm.Contests/Rules/CqWorldWide.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Rules;
|
||||
|
||||
/// CQ World Wide DX, CW and SSB. Points by continent, multipliers are zones and
|
||||
/// countries counted once per band. The country list is DXCC plus WAE, which is
|
||||
/// what `wl_cty.dat` holds.
|
||||
public sealed class CqWorldWide : Contest
|
||||
{
|
||||
private readonly ModeCategory mode;
|
||||
|
||||
public CqWorldWide(ModeCategory mode) => this.mode = mode;
|
||||
|
||||
public string Name => "CQWW";
|
||||
|
||||
public string DisplayName => $"CQ World Wide DX {ModeLabel()}";
|
||||
|
||||
public string CabrilloName => mode == ModeCategory.Cw ? "CQ-WW-CW" : "CQ-WW-SSB";
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFields =>
|
||||
[
|
||||
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||
new ExchangeField("Zone", ExchangeSlot.Zone, ExchangeFieldKind.CqZone),
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames => ["Zones", "Countries"];
|
||||
|
||||
public DupeScope DupeScope => DupeScope.PerBand;
|
||||
|
||||
public bool HasSerialNumbers => false;
|
||||
|
||||
public IReadOnlyList<ModeCategory> Modes => [mode];
|
||||
|
||||
public string SentExchangeFor(StationInfo me) =>
|
||||
$"{DefaultReport()} {me.CqZone}";
|
||||
|
||||
public int PointsFor(QsoContext qso)
|
||||
{
|
||||
if (!qso.Qso.Call.CountsForEntity)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (qso.IsSameCountry)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (!qso.IsSameContinent)
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
return qso.Continent == "NA" ? 2 : 1;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
|
||||
{
|
||||
string band = qso.Band?.Name ?? "";
|
||||
List<Multiplier> found = [];
|
||||
if (qso.Qso.Zone > 0)
|
||||
{
|
||||
found.Add(new Multiplier(1, qso.Qso.Zone.ToString(), band));
|
||||
}
|
||||
if (qso.CountryPrefix.Length > 0 && qso.Qso.Call.CountsForEntity)
|
||||
{
|
||||
found.Add(new Multiplier(2, qso.CountryPrefix, band));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
[new CabrilloField(qso.SentReport, 3), new CabrilloField(me.CqZone.ToString(), 6)],
|
||||
[new CabrilloField(qso.ReceivedReport, 3), new CabrilloField(qso.Zone.ToString(), 6)]);
|
||||
|
||||
private string ModeLabel() => mode == ModeCategory.Cw ? "CW" : "SSB";
|
||||
|
||||
private string DefaultReport() => mode == ModeCategory.Cw ? "599" : "59";
|
||||
}
|
||||
85
src/Nonemm.Contests/Rules/CqWpx.cs
Normal file
85
src/Nonemm.Contests/Rules/CqWpx.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Rules;
|
||||
|
||||
/// CQ WPX, CW, SSB and RTTY. Serial numbers are exchanged and the multiplier is
|
||||
/// the prefix, counted once for the whole contest whatever the band.
|
||||
public sealed class CqWpx : Contest
|
||||
{
|
||||
/// The rules split the bands at 14 MHz rather than by band name, so 30, 17
|
||||
/// and 12 metres fall on the high side even though the contest is not run
|
||||
/// on them.
|
||||
private static readonly Frequency LowBandLimit = Frequency.FromKilohertz(14_000);
|
||||
|
||||
private readonly ModeCategory mode;
|
||||
|
||||
public CqWpx(ModeCategory mode) => this.mode = mode;
|
||||
|
||||
public string Name => "CQWPX";
|
||||
|
||||
public string DisplayName => $"CQ WPX {ModeLabel()}";
|
||||
|
||||
public string CabrilloName => mode switch
|
||||
{
|
||||
ModeCategory.Cw => "CQ-WPX-CW",
|
||||
ModeCategory.Phone => "CQ-WPX-SSB",
|
||||
_ => "CQ-WPX-RTTY",
|
||||
};
|
||||
|
||||
public IReadOnlyList<ExchangeField> ExchangeFields =>
|
||||
[
|
||||
new ExchangeField("RST", ExchangeSlot.ReceivedReport, ExchangeFieldKind.Report),
|
||||
new ExchangeField("Nr", ExchangeSlot.SerialNumber, ExchangeFieldKind.Number),
|
||||
];
|
||||
|
||||
public IReadOnlyList<string> MultiplierNames => ["Prefixes"];
|
||||
|
||||
public DupeScope DupeScope => DupeScope.PerBand;
|
||||
|
||||
public bool HasSerialNumbers => true;
|
||||
|
||||
public IReadOnlyList<ModeCategory> Modes => [mode];
|
||||
|
||||
public string SentExchangeFor(StationInfo me) => DefaultReport();
|
||||
|
||||
public int PointsFor(QsoContext qso)
|
||||
{
|
||||
bool highBand = qso.Qso.Frequency >= LowBandLimit;
|
||||
bool rtty = mode == ModeCategory.Digital;
|
||||
if (qso.IsSameCountry)
|
||||
{
|
||||
return rtty ? (highBand ? 1 : 2) : 1;
|
||||
}
|
||||
if (!qso.IsSameContinent)
|
||||
{
|
||||
return highBand ? 3 : 6;
|
||||
}
|
||||
if (rtty || qso.Continent == "NA")
|
||||
{
|
||||
return highBand ? 2 : 4;
|
||||
}
|
||||
return highBand ? 1 : 2;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Multiplier> MultipliersFor(QsoContext qso)
|
||||
{
|
||||
string? prefix = qso.Qso.Call.WpxPrefix();
|
||||
return prefix is null ? [] : [new Multiplier(1, prefix, "")];
|
||||
}
|
||||
|
||||
public int TotalScore(ScoreTally tally) => tally.Points * tally.TotalMultipliers;
|
||||
|
||||
public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me) =>
|
||||
new(
|
||||
[new CabrilloField(qso.SentReport, 3), new CabrilloField($"{qso.SentNumber:0000}", 6)],
|
||||
[new CabrilloField(qso.ReceivedReport, 3), new CabrilloField($"{qso.ReceivedNumber:0000}", 6)]);
|
||||
|
||||
private string ModeLabel() => mode switch
|
||||
{
|
||||
ModeCategory.Cw => "CW",
|
||||
ModeCategory.Phone => "SSB",
|
||||
_ => "RTTY",
|
||||
};
|
||||
|
||||
private string DefaultReport() => mode == ModeCategory.Phone ? "59" : "599";
|
||||
}
|
||||
34
src/Nonemm.Contests/ScoreTally.cs
Normal file
34
src/Nonemm.Contests/ScoreTally.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// The running score: contacts, points and how many of each multiplier.
|
||||
public sealed class ScoreTally
|
||||
{
|
||||
private readonly Dictionary<int, int> multipliers = [];
|
||||
|
||||
public int Qsos { get; private set; }
|
||||
|
||||
public int Points { get; private set; }
|
||||
|
||||
public int MultiplierCount(int index) =>
|
||||
multipliers.TryGetValue(index, out int count) ? count : 0;
|
||||
|
||||
public int TotalMultipliers => multipliers.Values.Sum();
|
||||
|
||||
public void AddQso(int points)
|
||||
{
|
||||
Qsos++;
|
||||
Points += points;
|
||||
}
|
||||
|
||||
public void RemoveQso(int points)
|
||||
{
|
||||
Qsos--;
|
||||
Points -= points;
|
||||
}
|
||||
|
||||
public void AddMultiplier(int index) =>
|
||||
multipliers[index] = MultiplierCount(index) + 1;
|
||||
|
||||
public void RemoveMultiplier(int index) =>
|
||||
multipliers[index] = MultiplierCount(index) - 1;
|
||||
}
|
||||
14
src/Nonemm.Contests/Verdict.cs
Normal file
14
src/Nonemm.Contests/Verdict.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Nonemm.Contests;
|
||||
|
||||
/// What the log says about a contact: whether it counts, what it scores, and
|
||||
/// which multipliers it brings in. Every window that colours a station reads
|
||||
/// this, so one station cannot look worked in one place and new in another.
|
||||
public sealed record Verdict(
|
||||
bool IsDupe,
|
||||
int Points,
|
||||
IReadOnlyList<Multiplier> NewMultipliers)
|
||||
{
|
||||
public static readonly Verdict Dupe = new(true, 0, []);
|
||||
|
||||
public bool IsNewMultiplier => NewMultipliers.Count > 0;
|
||||
}
|
||||
10
src/Nonemm.Core/Band.cs
Normal file
10
src/Nonemm.Core/Band.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// One amateur band. Edges are the union of the three ITU regions, so a
|
||||
/// frequency legal anywhere in the world lands on a band.
|
||||
public sealed record Band(string Name, double MegahertzLabel, Frequency Low, Frequency High)
|
||||
{
|
||||
public bool Contains(Frequency f) => f >= Low && f <= High;
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
81
src/Nonemm.Core/Bands.cs
Normal file
81
src/Nonemm.Core/Bands.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// The amateur bands, and the mapping between a frequency and its band.
|
||||
public static class Bands
|
||||
{
|
||||
public static readonly Band Band2190M = Make("2190M", 0.136, 135.7, 137.8);
|
||||
public static readonly Band Band630M = Make("630M", 0.472, 472, 479);
|
||||
public static readonly Band Band160M = Make("160M", 1.8, 1_800, 2_000);
|
||||
public static readonly Band Band80M = Make("80M", 3.5, 3_500, 4_000);
|
||||
public static readonly Band Band60M = Make("60M", 5, 5_000, 5_500);
|
||||
public static readonly Band Band40M = Make("40M", 7, 7_000, 7_300);
|
||||
public static readonly Band Band30M = Make("30M", 10, 10_100, 10_150);
|
||||
public static readonly Band Band20M = Make("20M", 14, 14_000, 14_350);
|
||||
public static readonly Band Band17M = Make("17M", 18, 18_068, 18_168);
|
||||
public static readonly Band Band15M = Make("15M", 21, 21_000, 21_450);
|
||||
public static readonly Band Band12M = Make("12M", 24, 24_890, 24_990);
|
||||
public static readonly Band Band10M = Make("10M", 28, 28_000, 29_700);
|
||||
public static readonly Band Band6M = Make("6M", 50, 50_000, 54_000);
|
||||
public static readonly Band Band4M = Make("4M", 70, 70_000, 70_500);
|
||||
public static readonly Band Band2M = Make("2M", 144, 144_000, 148_000);
|
||||
public static readonly Band Band125CM = Make("1.25M", 222, 222_000, 225_000);
|
||||
public static readonly Band Band70CM = Make("70CM", 420, 420_000, 450_000);
|
||||
public static readonly Band Band33CM = Make("33CM", 902, 902_000, 928_000);
|
||||
public static readonly Band Band23CM = Make("23CM", 1240, 1_240_000, 1_300_000);
|
||||
public static readonly Band Band13CM = Make("13CM", 2300, 2_300_000, 2_450_000);
|
||||
public static readonly Band Band9CM = Make("9CM", 3300, 3_300_000, 3_500_000);
|
||||
public static readonly Band Band6CM = Make("6CM", 5650, 5_650_000, 5_925_000);
|
||||
public static readonly Band Band3CM = Make("3CM", 10000, 10_000_000, 10_500_000);
|
||||
public static readonly Band Band125CMM = Make("1.25CM", 24000, 24_000_000, 24_250_000);
|
||||
public static readonly Band Band6MM = Make("6MM", 47000, 47_000_000, 47_200_000);
|
||||
public static readonly Band Band4MM = Make("4MM", 76000, 75_500_000, 81_000_000);
|
||||
public static readonly Band Band2P5MM = Make("2.5MM", 122250, 122_250_000, 123_000_000);
|
||||
public static readonly Band Band2MM = Make("2MM", 134000, 134_000_000, 141_000_000);
|
||||
public static readonly Band Band1MM = Make("1MM", 241000, 241_000_000, 250_000_000);
|
||||
|
||||
public static readonly IReadOnlyList<Band> All =
|
||||
[
|
||||
Band2190M, Band630M, Band160M, Band80M, Band60M, Band40M, Band30M, Band20M,
|
||||
Band17M, Band15M, Band12M, Band10M, Band6M, Band4M, Band2M, Band125CM,
|
||||
Band70CM, Band33CM, Band23CM, Band13CM, Band9CM, Band6CM, Band3CM,
|
||||
Band125CMM, Band6MM, Band4MM, Band2P5MM, Band2MM, Band1MM,
|
||||
];
|
||||
|
||||
/// The bands a contest normally runs on, in the order operators list them.
|
||||
public static readonly IReadOnlyList<Band> Contest =
|
||||
[Band160M, Band80M, Band40M, Band20M, Band15M, Band10M];
|
||||
|
||||
private static readonly Dictionary<string, Band> ByName =
|
||||
All.ToDictionary(b => b.Name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static Band Make(string name, double label, double lowKhz, double highKhz) =>
|
||||
new(name, label, Frequency.FromKilohertz(lowKhz), Frequency.FromKilohertz(highKhz));
|
||||
|
||||
/// Null when the frequency is outside every amateur allocation.
|
||||
public static Band? ForFrequency(Frequency f)
|
||||
{
|
||||
foreach (Band band in All)
|
||||
{
|
||||
if (band.Contains(f))
|
||||
{
|
||||
return band;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Band? ByLabel(double megahertzLabel)
|
||||
{
|
||||
foreach (Band band in All)
|
||||
{
|
||||
if (Math.Abs(band.MegahertzLabel - megahertzLabel) < 0.0001)
|
||||
{
|
||||
return band;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Band? Named(string name) =>
|
||||
ByName.TryGetValue(name, out Band? band) ? band : null;
|
||||
}
|
||||
118
src/Nonemm.Core/Callsign.cs
Normal file
118
src/Nonemm.Core/Callsign.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// A callsign split into the parts contest rules care about: the station's own
|
||||
/// call, a portable prefix, and modifiers such as /P or /MM.
|
||||
public sealed record Callsign
|
||||
{
|
||||
private static readonly HashSet<string> PlainModifiers =
|
||||
new(StringComparer.Ordinal) { "P", "M", "A", "AM", "MM", "QRP", "LH", "J", "R", "B", "N", "T" };
|
||||
|
||||
private Callsign(string text, string station, string? portablePrefix, IReadOnlyList<string> modifiers)
|
||||
{
|
||||
Text = text;
|
||||
Station = station;
|
||||
PortablePrefix = portablePrefix;
|
||||
Modifiers = modifiers;
|
||||
}
|
||||
|
||||
/// The whole thing as typed, upper case.
|
||||
public string Text { get; }
|
||||
|
||||
/// The operator's own callsign, with prefix and modifiers removed.
|
||||
public string Station { get; }
|
||||
|
||||
/// The prefix the operator is signing from, e.g. `KH9` in `KH9/N8BJQ`.
|
||||
public string? PortablePrefix { get; }
|
||||
|
||||
public IReadOnlyList<string> Modifiers { get; }
|
||||
|
||||
public bool IsMaritimeMobile => Modifiers.Contains("MM");
|
||||
|
||||
public bool IsAeronauticalMobile => Modifiers.Contains("AM");
|
||||
|
||||
/// Maritime and aeronautical mobile stations count for no country and no
|
||||
/// prefix, so most contests score them zero.
|
||||
public bool CountsForEntity => !IsMaritimeMobile && !IsAeronauticalMobile;
|
||||
|
||||
public static Callsign Parse(string text)
|
||||
{
|
||||
string cleaned = text.Trim().ToUpperInvariant();
|
||||
string[] parts = cleaned.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return new Callsign(cleaned, cleaned, null, []);
|
||||
}
|
||||
|
||||
List<string> modifiers = [];
|
||||
List<string> callParts = [];
|
||||
foreach (string part in parts)
|
||||
{
|
||||
if (IsPlainModifier(part) && callParts.Count > 0)
|
||||
{
|
||||
modifiers.Add(part);
|
||||
}
|
||||
else
|
||||
{
|
||||
callParts.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (callParts.Count == 0)
|
||||
{
|
||||
return new Callsign(cleaned, cleaned, null, modifiers);
|
||||
}
|
||||
if (callParts.Count == 1)
|
||||
{
|
||||
return new Callsign(cleaned, callParts[0], null, modifiers);
|
||||
}
|
||||
|
||||
// With two candidate parts the shorter one is the location prefix;
|
||||
// equal lengths are read as prefix first, which is how calls are signed now.
|
||||
(string prefix, string station) = callParts[0].Length <= callParts[1].Length
|
||||
? (callParts[0], callParts[1])
|
||||
: (callParts[1], callParts[0]);
|
||||
return new Callsign(cleaned, station, prefix, modifiers);
|
||||
}
|
||||
|
||||
/// The CQ WPX prefix, or null for a station that counts for no prefix.
|
||||
public string? WpxPrefix()
|
||||
{
|
||||
if (!CountsForEntity)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (PortablePrefix is not null)
|
||||
{
|
||||
return PortablePrefix.Any(char.IsDigit) ? PortablePrefix : PortablePrefix + "0";
|
||||
}
|
||||
string? digitModifier = Modifiers.FirstOrDefault(m => m.Length == 1 && char.IsDigit(m[0]));
|
||||
string prefix = PrefixOf(Station);
|
||||
if (digitModifier is not null && prefix.Length > 0 && char.IsDigit(prefix[^1]))
|
||||
{
|
||||
return prefix[..^1] + digitModifier;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
/// What to hand the country file: the portable prefix when there is one,
|
||||
/// because that is where the station is.
|
||||
public string EntityLookupText() =>
|
||||
PortablePrefix is null ? Station : PortablePrefix;
|
||||
|
||||
public override string ToString() => Text;
|
||||
|
||||
private static bool IsPlainModifier(string part) =>
|
||||
PlainModifiers.Contains(part) || (part.Length == 1 && char.IsDigit(part[0]));
|
||||
|
||||
/// Everything up to and including the last digit. A call with no digit at
|
||||
/// all takes a zero after its first two letters, as the WPX rules say.
|
||||
private static string PrefixOf(string call)
|
||||
{
|
||||
int lastDigit = call.LastIndexOfAny("0123456789".ToCharArray());
|
||||
if (lastDigit < 0)
|
||||
{
|
||||
return (call.Length <= 2 ? call : call[..2]) + "0";
|
||||
}
|
||||
return call[..(lastDigit + 1)];
|
||||
}
|
||||
}
|
||||
147
src/Nonemm.Core/Country/CountryFile.cs
Normal file
147
src/Nonemm.Core/Country/CountryFile.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Nonemm.Core.Country;
|
||||
|
||||
/// The country file (`cty.dat` or `wl_cty.dat`), which says which entity, zone
|
||||
/// and continent a callsign belongs to.
|
||||
public sealed class CountryFile
|
||||
{
|
||||
private readonly Dictionary<string, PrefixRule> exactCalls;
|
||||
private readonly Dictionary<string, PrefixRule> prefixes;
|
||||
private readonly int longestPrefix;
|
||||
|
||||
private CountryFile(
|
||||
IReadOnlyList<DxccEntity> entities,
|
||||
Dictionary<string, PrefixRule> exactCalls,
|
||||
Dictionary<string, PrefixRule> prefixes)
|
||||
{
|
||||
Entities = entities;
|
||||
this.exactCalls = exactCalls;
|
||||
this.prefixes = prefixes;
|
||||
longestPrefix = prefixes.Count == 0 ? 0 : prefixes.Keys.Max(k => k.Length);
|
||||
}
|
||||
|
||||
public IReadOnlyList<DxccEntity> Entities { get; }
|
||||
|
||||
/// Throws `FormatException` when the text is not a country file, so a
|
||||
/// download that returned an error page cannot replace a good file.
|
||||
public static CountryFile Parse(string text)
|
||||
{
|
||||
List<DxccEntity> entities = [];
|
||||
Dictionary<string, PrefixRule> exactCalls = new(StringComparer.Ordinal);
|
||||
Dictionary<string, PrefixRule> prefixes = new(StringComparer.Ordinal);
|
||||
DxccEntity? current = null;
|
||||
|
||||
foreach (string rawLine in text.Split('\n'))
|
||||
{
|
||||
string line = rawLine.TrimEnd('\r', ' ', '\t');
|
||||
if (line.Length == 0 || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!char.IsWhiteSpace(rawLine[0]))
|
||||
{
|
||||
current = ParseHeader(line);
|
||||
entities.Add(current);
|
||||
continue;
|
||||
}
|
||||
if (current is null)
|
||||
{
|
||||
throw new FormatException($"country file starts with an alias line: '{line}'");
|
||||
}
|
||||
foreach (string alias in line.TrimEnd(';').Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
AddAlias(current, alias.Trim(), exactCalls, prefixes);
|
||||
}
|
||||
}
|
||||
|
||||
if (entities.Count == 0)
|
||||
{
|
||||
throw new FormatException("country file holds no entities");
|
||||
}
|
||||
return new CountryFile(entities, exactCalls, prefixes);
|
||||
}
|
||||
|
||||
public CountryLookup? Find(Callsign call)
|
||||
{
|
||||
if (!call.CountsForEntity)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (exactCalls.TryGetValue(call.Text, out PrefixRule? whole))
|
||||
{
|
||||
return whole.Resolve(call.Text);
|
||||
}
|
||||
if (exactCalls.TryGetValue(call.Station, out PrefixRule? station) && call.PortablePrefix is null)
|
||||
{
|
||||
return station.Resolve(call.Station);
|
||||
}
|
||||
return FindByPrefix(call.EntityLookupText());
|
||||
}
|
||||
|
||||
public CountryLookup? Find(string call) => Find(Callsign.Parse(call));
|
||||
|
||||
private CountryLookup? FindByPrefix(string text)
|
||||
{
|
||||
for (int length = Math.Min(text.Length, longestPrefix); length > 0; length--)
|
||||
{
|
||||
if (prefixes.TryGetValue(text[..length], out PrefixRule? rule))
|
||||
{
|
||||
return rule.Resolve(text[..length]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AddAlias(
|
||||
DxccEntity entity,
|
||||
string alias,
|
||||
Dictionary<string, PrefixRule> exactCalls,
|
||||
Dictionary<string, PrefixRule> prefixes)
|
||||
{
|
||||
if (alias.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
bool isWholeCall = alias[0] == '=';
|
||||
PrefixRule rule = PrefixRule.Parse(entity, isWholeCall ? alias[1..] : alias);
|
||||
if (rule.Key.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Dictionary<string, PrefixRule> target = isWholeCall ? exactCalls : prefixes;
|
||||
target[rule.Key] = rule;
|
||||
}
|
||||
|
||||
private static DxccEntity ParseHeader(string line)
|
||||
{
|
||||
string[] fields = line.TrimEnd(':').Split(':');
|
||||
if (fields.Length != 8)
|
||||
{
|
||||
throw new FormatException($"country file header needs 8 fields, got {fields.Length}: '{line}'");
|
||||
}
|
||||
string primary = fields[7].Trim();
|
||||
bool waeOnly = primary.StartsWith('*');
|
||||
return new DxccEntity(
|
||||
Name: fields[0].Trim(),
|
||||
PrimaryPrefix: waeOnly ? primary[1..] : primary,
|
||||
CqZone: ParseInt(fields[1], line),
|
||||
ItuZone: ParseInt(fields[2], line),
|
||||
Continent: fields[3].Trim(),
|
||||
Latitude: ParseDouble(fields[4], line),
|
||||
// the file gives longitude west-positive; the rest of the program wants east-positive
|
||||
Longitude: -ParseDouble(fields[5], line),
|
||||
UtcOffset: ParseDouble(fields[6], line),
|
||||
IsWaeOnly: waeOnly);
|
||||
}
|
||||
|
||||
private static int ParseInt(string field, string line) =>
|
||||
int.TryParse(field.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||
? value
|
||||
: throw new FormatException($"country file field '{field.Trim()}' is not a number in '{line}'");
|
||||
|
||||
private static double ParseDouble(string field, string line) =>
|
||||
double.TryParse(field.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
|
||||
? value
|
||||
: throw new FormatException($"country file field '{field.Trim()}' is not a number in '{line}'");
|
||||
}
|
||||
12
src/Nonemm.Core/Country/CountryLookup.cs
Normal file
12
src/Nonemm.Core/Country/CountryLookup.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Nonemm.Core.Country;
|
||||
|
||||
/// What the country file says about one callsign. Zone and continent can differ
|
||||
/// from the entity's own when the country file overrides them for that prefix.
|
||||
public sealed record CountryLookup(
|
||||
DxccEntity Entity,
|
||||
string MatchedPrefix,
|
||||
int CqZone,
|
||||
int ItuZone,
|
||||
string Continent,
|
||||
double Latitude,
|
||||
double Longitude);
|
||||
17
src/Nonemm.Core/Country/DxccEntity.cs
Normal file
17
src/Nonemm.Core/Country/DxccEntity.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Nonemm.Core.Country;
|
||||
|
||||
/// One entity from the country file. `IsWaeOnly` marks the entities that only
|
||||
/// the WAE list counts separately, such as the Shetlands apart from Scotland.
|
||||
public sealed record DxccEntity(
|
||||
string Name,
|
||||
string PrimaryPrefix,
|
||||
int CqZone,
|
||||
int ItuZone,
|
||||
string Continent,
|
||||
double Latitude,
|
||||
double Longitude,
|
||||
double UtcOffset,
|
||||
bool IsWaeOnly)
|
||||
{
|
||||
public override string ToString() => PrimaryPrefix;
|
||||
}
|
||||
113
src/Nonemm.Core/Country/PrefixRule.cs
Normal file
113
src/Nonemm.Core/Country/PrefixRule.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Nonemm.Core.Country;
|
||||
|
||||
/// One alias from the country file: a prefix or whole call, plus the overrides
|
||||
/// the file writes after it, such as `K5(4)[7]` for a different zone.
|
||||
public sealed class PrefixRule
|
||||
{
|
||||
private readonly DxccEntity entity;
|
||||
private readonly int? cqZone;
|
||||
private readonly int? ituZone;
|
||||
private readonly string? continent;
|
||||
private readonly double? latitude;
|
||||
private readonly double? longitude;
|
||||
|
||||
private PrefixRule(
|
||||
string key,
|
||||
DxccEntity entity,
|
||||
int? cqZone,
|
||||
int? ituZone,
|
||||
string? continent,
|
||||
double? latitude,
|
||||
double? longitude)
|
||||
{
|
||||
Key = key;
|
||||
this.entity = entity;
|
||||
this.cqZone = cqZone;
|
||||
this.ituZone = ituZone;
|
||||
this.continent = continent;
|
||||
this.latitude = latitude;
|
||||
this.longitude = longitude;
|
||||
}
|
||||
|
||||
public string Key { get; }
|
||||
|
||||
public static PrefixRule Parse(DxccEntity entity, string alias)
|
||||
{
|
||||
string key = alias;
|
||||
int? cq = null;
|
||||
int? itu = null;
|
||||
string? continent = null;
|
||||
double? latitude = null;
|
||||
double? longitude = null;
|
||||
|
||||
while (key.Length > 0)
|
||||
{
|
||||
int open = key.IndexOfAny(['(', '[', '<', '{', '~']);
|
||||
if (open < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
char closer = key[open] switch
|
||||
{
|
||||
'(' => ')',
|
||||
'[' => ']',
|
||||
'<' => '>',
|
||||
'{' => '}',
|
||||
_ => '~',
|
||||
};
|
||||
int close = key.IndexOf(closer, open + 1);
|
||||
if (close < 0)
|
||||
{
|
||||
throw new FormatException($"country file override in '{alias}' is not closed");
|
||||
}
|
||||
string body = key[(open + 1)..close];
|
||||
switch (key[open])
|
||||
{
|
||||
case '(':
|
||||
cq = ParseInt(body, alias);
|
||||
break;
|
||||
case '[':
|
||||
itu = ParseInt(body, alias);
|
||||
break;
|
||||
case '{':
|
||||
continent = body.Trim();
|
||||
break;
|
||||
case '<':
|
||||
(latitude, longitude) = ParseCoordinates(body, alias);
|
||||
break;
|
||||
}
|
||||
key = key.Remove(open, close - open + 1);
|
||||
}
|
||||
|
||||
return new PrefixRule(key.Trim(), entity, cq, itu, continent, latitude, longitude);
|
||||
}
|
||||
|
||||
public CountryLookup Resolve(string matched) =>
|
||||
new(
|
||||
entity,
|
||||
matched,
|
||||
cqZone ?? entity.CqZone,
|
||||
ituZone ?? entity.ItuZone,
|
||||
continent ?? entity.Continent,
|
||||
latitude ?? entity.Latitude,
|
||||
longitude ?? entity.Longitude);
|
||||
|
||||
private static int ParseInt(string body, string alias) =>
|
||||
int.TryParse(body.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||
? value
|
||||
: throw new FormatException($"country file override '{body}' in '{alias}' is not a number");
|
||||
|
||||
private static (double Latitude, double Longitude) ParseCoordinates(string body, string alias)
|
||||
{
|
||||
string[] parts = body.Split('/');
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
throw new FormatException($"country file coordinates '{body}' in '{alias}' are not lat/long");
|
||||
}
|
||||
double latitude = double.Parse(parts[0].Trim(), CultureInfo.InvariantCulture);
|
||||
double longitude = double.Parse(parts[1].Trim(), CultureInfo.InvariantCulture);
|
||||
return (latitude, -longitude);
|
||||
}
|
||||
}
|
||||
35
src/Nonemm.Core/Frequency.cs
Normal file
35
src/Nonemm.Core/Frequency.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// A radio frequency held in hertz, so kilohertz and megahertz cannot be mixed up.
|
||||
public readonly record struct Frequency : IComparable<Frequency>
|
||||
{
|
||||
public long Hertz { get; }
|
||||
|
||||
private Frequency(long hertz) => Hertz = hertz;
|
||||
|
||||
public static readonly Frequency Zero = new(0);
|
||||
|
||||
public static Frequency FromHertz(long hertz) => new(hertz);
|
||||
|
||||
public static Frequency FromKilohertz(double kilohertz) =>
|
||||
new((long)Math.Round(kilohertz * 1_000));
|
||||
|
||||
public static Frequency FromMegahertz(double megahertz) =>
|
||||
new((long)Math.Round(megahertz * 1_000_000));
|
||||
|
||||
public double Kilohertz => Hertz / 1_000.0;
|
||||
|
||||
public double Megahertz => Hertz / 1_000_000.0;
|
||||
|
||||
public int CompareTo(Frequency other) => Hertz.CompareTo(other.Hertz);
|
||||
|
||||
public static bool operator <(Frequency a, Frequency b) => a.Hertz < b.Hertz;
|
||||
public static bool operator >(Frequency a, Frequency b) => a.Hertz > b.Hertz;
|
||||
public static bool operator <=(Frequency a, Frequency b) => a.Hertz <= b.Hertz;
|
||||
public static bool operator >=(Frequency a, Frequency b) => a.Hertz >= b.Hertz;
|
||||
|
||||
public static Frequency operator +(Frequency a, Frequency b) => new(a.Hertz + b.Hertz);
|
||||
public static Frequency operator -(Frequency a, Frequency b) => new(a.Hertz - b.Hertz);
|
||||
|
||||
public override string ToString() => Kilohertz.ToString("0.0###");
|
||||
}
|
||||
115
src/Nonemm.Core/GridSquare.cs
Normal file
115
src/Nonemm.Core/GridSquare.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// A Maidenhead locator, and the great-circle maths contests need from it.
|
||||
public readonly record struct GridSquare
|
||||
{
|
||||
private GridSquare(string text, double latitude, double longitude)
|
||||
{
|
||||
Text = text;
|
||||
Latitude = latitude;
|
||||
Longitude = longitude;
|
||||
}
|
||||
|
||||
/// The locator as given, upper case for the field and square, lower for the
|
||||
/// subsquare, which is how locators are written.
|
||||
public string Text { get; }
|
||||
|
||||
/// The centre of the square, in degrees, longitude east-positive.
|
||||
public double Latitude { get; }
|
||||
|
||||
public double Longitude { get; }
|
||||
|
||||
public static bool TryParse(string text, out GridSquare grid)
|
||||
{
|
||||
grid = default;
|
||||
string t = text.Trim();
|
||||
if (t.Length is not (4 or 6 or 8) || t.Length % 2 != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!InRange(t[0], 'A', 'R') || !InRange(t[1], 'A', 'R') ||
|
||||
!char.IsAsciiDigit(t[2]) || !char.IsAsciiDigit(t[3]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double longitude = ((Upper(t[0]) - 'A') * 20.0) + ((t[2] - '0') * 2.0);
|
||||
double latitude = ((Upper(t[1]) - 'A') * 10.0) + (t[3] - '0');
|
||||
double longitudeSize = 2.0;
|
||||
double latitudeSize = 1.0;
|
||||
|
||||
if (t.Length >= 6)
|
||||
{
|
||||
if (!InRange(t[4], 'A', 'X') || !InRange(t[5], 'A', 'X'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
longitude += (Upper(t[4]) - 'A') * (2.0 / 24.0);
|
||||
latitude += (Upper(t[5]) - 'A') * (1.0 / 24.0);
|
||||
longitudeSize = 2.0 / 24.0;
|
||||
latitudeSize = 1.0 / 24.0;
|
||||
}
|
||||
if (t.Length == 8)
|
||||
{
|
||||
if (!char.IsAsciiDigit(t[6]) || !char.IsAsciiDigit(t[7]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
longitude += (t[6] - '0') * (2.0 / 240.0);
|
||||
latitude += (t[7] - '0') * (1.0 / 240.0);
|
||||
longitudeSize = 2.0 / 240.0;
|
||||
latitudeSize = 1.0 / 240.0;
|
||||
}
|
||||
|
||||
grid = new GridSquare(
|
||||
Normalize(t),
|
||||
latitude + (latitudeSize / 2.0) - 90.0,
|
||||
longitude + (longitudeSize / 2.0) - 180.0);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Great-circle distance in kilometres.
|
||||
public double DistanceTo(GridSquare other) =>
|
||||
DistanceKm(Latitude, Longitude, other.Latitude, other.Longitude);
|
||||
|
||||
/// Initial bearing in degrees, 0 at north.
|
||||
public double BearingTo(GridSquare other) =>
|
||||
Bearing(Latitude, Longitude, other.Latitude, other.Longitude);
|
||||
|
||||
public override string ToString() => Text;
|
||||
|
||||
public static double DistanceKm(double lat1, double lon1, double lat2, double lon2)
|
||||
{
|
||||
const double earthRadiusKm = 6371.0;
|
||||
double dLat = Radians(lat2 - lat1);
|
||||
double dLon = Radians(lon2 - lon1);
|
||||
double a = (Math.Sin(dLat / 2) * Math.Sin(dLat / 2)) +
|
||||
(Math.Cos(Radians(lat1)) * Math.Cos(Radians(lat2)) *
|
||||
Math.Sin(dLon / 2) * Math.Sin(dLon / 2));
|
||||
return earthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||
}
|
||||
|
||||
public static double Bearing(double lat1, double lon1, double lat2, double lon2)
|
||||
{
|
||||
double dLon = Radians(lon2 - lon1);
|
||||
double y = Math.Sin(dLon) * Math.Cos(Radians(lat2));
|
||||
double x = (Math.Cos(Radians(lat1)) * Math.Sin(Radians(lat2))) -
|
||||
(Math.Sin(Radians(lat1)) * Math.Cos(Radians(lat2)) * Math.Cos(dLon));
|
||||
return ((Math.Atan2(y, x) * 180.0 / Math.PI) + 360.0) % 360.0;
|
||||
}
|
||||
|
||||
private static double Radians(double degrees) => degrees * Math.PI / 180.0;
|
||||
|
||||
private static bool InRange(char c, char low, char high)
|
||||
{
|
||||
char u = Upper(c);
|
||||
return u >= low && u <= high;
|
||||
}
|
||||
|
||||
private static char Upper(char c) => char.ToUpperInvariant(c);
|
||||
|
||||
private static string Normalize(string t) =>
|
||||
t.Length <= 4
|
||||
? t.ToUpperInvariant()
|
||||
: t[..4].ToUpperInvariant() + t[4..6].ToLowerInvariant() + t[6..].ToUpperInvariant();
|
||||
}
|
||||
7
src/Nonemm.Core/Mode.cs
Normal file
7
src/Nonemm.Core/Mode.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// One operating mode. `Name` is what goes in the log and must round-trip.
|
||||
public sealed record Mode(string Name, ModeCategory Category, string CabrilloCode, string AdifName)
|
||||
{
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
9
src/Nonemm.Core/ModeCategory.cs
Normal file
9
src/Nonemm.Core/ModeCategory.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// The three groups contest rules score and check dupes by.
|
||||
public enum ModeCategory
|
||||
{
|
||||
Cw,
|
||||
Phone,
|
||||
Digital,
|
||||
}
|
||||
59
src/Nonemm.Core/Modes.cs
Normal file
59
src/Nonemm.Core/Modes.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// The operating modes the logger knows, and how to read one off a text field.
|
||||
public static class Modes
|
||||
{
|
||||
public static readonly Mode Cw = new("CW", ModeCategory.Cw, "CW", "CW");
|
||||
public static readonly Mode Usb = new("USB", ModeCategory.Phone, "PH", "SSB");
|
||||
public static readonly Mode Lsb = new("LSB", ModeCategory.Phone, "PH", "SSB");
|
||||
public static readonly Mode Am = new("AM", ModeCategory.Phone, "PH", "AM");
|
||||
public static readonly Mode Fm = new("FM", ModeCategory.Phone, "FM", "FM");
|
||||
public static readonly Mode Rtty = new("RTTY", ModeCategory.Digital, "RY", "RTTY");
|
||||
public static readonly Mode Psk31 = new("PSK31", ModeCategory.Digital, "DG", "PSK31");
|
||||
public static readonly Mode Psk63 = new("PSK63", ModeCategory.Digital, "DG", "PSK63");
|
||||
public static readonly Mode Ft8 = new("FT8", ModeCategory.Digital, "DG", "FT8");
|
||||
public static readonly Mode Ft4 = new("FT4", ModeCategory.Digital, "DG", "FT4");
|
||||
public static readonly Mode Mfsk = new("MFSK", ModeCategory.Digital, "DG", "MFSK");
|
||||
public static readonly Mode Jt65 = new("JT65", ModeCategory.Digital, "DG", "JT65");
|
||||
public static readonly Mode Msk144 = new("MSK144", ModeCategory.Digital, "DG", "MSK144");
|
||||
public static readonly Mode Q65 = new("Q65", ModeCategory.Digital, "DG", "Q65");
|
||||
public static readonly Mode Digital = new("DIGI", ModeCategory.Digital, "DG", "DATA");
|
||||
|
||||
public static readonly IReadOnlyList<Mode> All =
|
||||
[
|
||||
Cw, Usb, Lsb, Am, Fm, Rtty, Psk31, Psk63, Ft8, Ft4, Mfsk, Jt65, Msk144, Q65, Digital,
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, Mode> ByName =
|
||||
BuildNameIndex();
|
||||
|
||||
/// Null for text that names no mode we know. Radios report a sideband
|
||||
/// ("USB", "LSB"); logs and contest rules often say "SSB", which resolves
|
||||
/// against the band, so callers that have a frequency should use
|
||||
/// `ForSideband` instead.
|
||||
public static Mode? Parse(string text)
|
||||
{
|
||||
string key = text.Trim();
|
||||
return ByName.TryGetValue(key, out Mode? mode) ? mode : null;
|
||||
}
|
||||
|
||||
/// The sideband convention: LSB below 10 MHz, USB above, and on 60 metres.
|
||||
public static Mode ForSideband(Frequency f) =>
|
||||
f.Hertz < 10_000_000 && !Bands.Band60M.Contains(f) ? Lsb : Usb;
|
||||
|
||||
private static Dictionary<string, Mode> BuildNameIndex()
|
||||
{
|
||||
Dictionary<string, Mode> index = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Mode mode in All)
|
||||
{
|
||||
index[mode.Name] = mode;
|
||||
index.TryAdd(mode.AdifName, mode);
|
||||
}
|
||||
index["CW-R"] = Cw;
|
||||
index["RTTY-R"] = Rtty;
|
||||
index["PKT"] = Digital;
|
||||
index["DATA"] = Digital;
|
||||
index["SSB"] = Usb;
|
||||
return index;
|
||||
}
|
||||
}
|
||||
9
src/Nonemm.Core/Nonemm.Core.csproj
Normal file
9
src/Nonemm.Core/Nonemm.Core.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
101
src/Nonemm.Core/Qso.cs
Normal file
101
src/Nonemm.Core/Qso.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// One logged contact. The fields follow N1MM's log columns so a QSO written by
|
||||
/// either program means the same thing to the other.
|
||||
public sealed record Qso
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required DateTime TimestampUtc { get; init; }
|
||||
|
||||
public required Callsign Call { get; init; }
|
||||
|
||||
public required Frequency Frequency { get; init; }
|
||||
|
||||
/// The frequency the other station transmits on when working split.
|
||||
public Frequency QsxFrequency { get; init; } = Frequency.Zero;
|
||||
|
||||
public required Mode Mode { get; init; }
|
||||
|
||||
public required string ContestName { get; init; }
|
||||
|
||||
public int ContestNumber { get; init; }
|
||||
|
||||
public string SentReport { get; init; } = "";
|
||||
|
||||
public string ReceivedReport { get; init; } = "";
|
||||
|
||||
public int SentNumber { get; init; }
|
||||
|
||||
public int ReceivedNumber { get; init; }
|
||||
|
||||
public string Section { get; init; } = "";
|
||||
|
||||
public string Precedence { get; init; } = "";
|
||||
|
||||
public int Check { get; init; }
|
||||
|
||||
public int Zone { get; init; }
|
||||
|
||||
public string Exchange1 { get; init; } = "";
|
||||
|
||||
public string MiscText { get; init; } = "";
|
||||
|
||||
public string Comment { get; init; } = "";
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Qth { get; init; } = "";
|
||||
|
||||
public string Power { get; init; } = "";
|
||||
|
||||
public string GridSquare { get; init; } = "";
|
||||
|
||||
public string RoverLocation { get; init; } = "";
|
||||
|
||||
public string CountryPrefix { get; init; } = "";
|
||||
|
||||
/// The prefix the station itself is signing, which differs from
|
||||
/// `CountryPrefix` for a portable operation.
|
||||
public string StationPrefix { get; init; } = "";
|
||||
|
||||
public string WpxPrefix { get; init; } = "";
|
||||
|
||||
public string Continent { get; init; } = "";
|
||||
|
||||
public int Points { get; init; }
|
||||
|
||||
public bool IsMultiplier1 { get; init; }
|
||||
|
||||
public bool IsMultiplier2 { get; init; }
|
||||
|
||||
public bool IsMultiplier3 { get; init; }
|
||||
|
||||
public bool IsRunQso { get; init; }
|
||||
|
||||
/// N1MM's per-QSO contact type: empty for a normal contact.
|
||||
public string ContactType { get; init; } = "";
|
||||
|
||||
/// Which of a two-radio station's run positions made the contact.
|
||||
public int RunPosition { get; init; }
|
||||
|
||||
public string Operator { get; init; } = "";
|
||||
|
||||
public int RadioNumber { get; init; } = 1;
|
||||
|
||||
public bool IsRadioInterfaced { get; init; }
|
||||
|
||||
public int NetworkedComputerNumber { get; init; }
|
||||
|
||||
public string StationName { get; init; } = "";
|
||||
|
||||
/// False on a QSO this station received from another station in the network.
|
||||
public bool IsOriginal { get; init; } = true;
|
||||
|
||||
/// Cleared when the operator excludes a contact from the claimed score.
|
||||
public bool IsClaimed { get; init; } = true;
|
||||
|
||||
public Band? Band => Bands.ForFrequency(Frequency);
|
||||
|
||||
public static string NewId() => Guid.NewGuid().ToString("N");
|
||||
}
|
||||
37
src/Nonemm.Core/StationInfo.cs
Normal file
37
src/Nonemm.Core/StationInfo.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace Nonemm.Core;
|
||||
|
||||
/// The operator's own station: what goes in the sent exchange and what contest
|
||||
/// rules compare a worked station against.
|
||||
public sealed record StationInfo
|
||||
{
|
||||
public required string Callsign { get; init; }
|
||||
|
||||
public int CqZone { get; init; }
|
||||
|
||||
public int ItuZone { get; init; }
|
||||
|
||||
public string Continent { get; init; } = "";
|
||||
|
||||
/// The primary prefix of the entity operated from, as the country file names it.
|
||||
public string CountryPrefix { get; init; } = "";
|
||||
|
||||
public string State { get; init; } = "";
|
||||
|
||||
public string Province { get; init; } = "";
|
||||
|
||||
public string ArrlSection { get; init; } = "";
|
||||
|
||||
public string GridSquare { get; init; } = "";
|
||||
|
||||
public string County { get; init; } = "";
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Power { get; init; } = "";
|
||||
|
||||
public string Club { get; init; } = "";
|
||||
|
||||
public double Latitude { get; init; }
|
||||
|
||||
public double Longitude { get; init; }
|
||||
}
|
||||
132
src/Nonemm.Formats/Adif/AdifReader.cs
Normal file
132
src/Nonemm.Formats/Adif/AdifReader.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System.Globalization;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Formats.Adif;
|
||||
|
||||
/// Reads ADIF into contacts. Fields the log has no room for are dropped rather
|
||||
/// than crammed into a comment.
|
||||
public static class AdifReader
|
||||
{
|
||||
public static IReadOnlyList<Qso> Read(string text, string contestName = "DX", int contestNumber = 1)
|
||||
{
|
||||
List<Qso> qsos = [];
|
||||
foreach (Dictionary<string, string> record in Records(text))
|
||||
{
|
||||
Qso? qso = ToQso(record, contestName, contestNumber);
|
||||
if (qso is not null)
|
||||
{
|
||||
qsos.Add(qso);
|
||||
}
|
||||
}
|
||||
return qsos;
|
||||
}
|
||||
|
||||
/// The fields of each record, keyed by upper-case field name.
|
||||
public static IEnumerable<Dictionary<string, string>> Records(string text)
|
||||
{
|
||||
int at = text.IndexOf("<EOH>", StringComparison.OrdinalIgnoreCase);
|
||||
int position = at < 0 ? 0 : at + 5;
|
||||
Dictionary<string, string> record = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
while (position < text.Length)
|
||||
{
|
||||
int open = text.IndexOf('<', position);
|
||||
if (open < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
int close = text.IndexOf('>', open + 1);
|
||||
if (close < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
string[] parts = text[(open + 1)..close].Split(':');
|
||||
string name = parts[0].Trim();
|
||||
if (name.Equals("EOR", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (record.Count > 0)
|
||||
{
|
||||
yield return record;
|
||||
}
|
||||
record = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
position = close + 1;
|
||||
continue;
|
||||
}
|
||||
if (parts.Length < 2 || !int.TryParse(parts[1], out int length) || length < 0)
|
||||
{
|
||||
position = close + 1;
|
||||
continue;
|
||||
}
|
||||
int end = Math.Min(close + 1 + length, text.Length);
|
||||
record[name] = text[(close + 1)..end];
|
||||
position = end;
|
||||
}
|
||||
if (record.Count > 0)
|
||||
{
|
||||
yield return record;
|
||||
}
|
||||
}
|
||||
|
||||
private static Qso? ToQso(Dictionary<string, string> record, string contestName, int contestNumber)
|
||||
{
|
||||
if (!record.TryGetValue("CALL", out string? call) || call.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
DateTime when = ParseTimestamp(record);
|
||||
return new Qso
|
||||
{
|
||||
Id = Qso.NewId(),
|
||||
TimestampUtc = when,
|
||||
Call = Callsign.Parse(call),
|
||||
Frequency = ParseFrequency(record),
|
||||
Mode = Modes.Parse(Value(record, "MODE")) ?? Modes.Digital,
|
||||
ContestName = Value(record, "CONTEST_ID") is { Length: > 0 } id ? id : contestName,
|
||||
ContestNumber = contestNumber,
|
||||
SentReport = Value(record, "RST_SENT"),
|
||||
ReceivedReport = Value(record, "RST_RCVD"),
|
||||
SentNumber = Integer(record, "STX"),
|
||||
ReceivedNumber = Integer(record, "SRX"),
|
||||
Zone = Integer(record, "CQZ"),
|
||||
Section = Value(record, "ARRL_SECT"),
|
||||
Name = Value(record, "NAME"),
|
||||
Qth = Value(record, "QTH"),
|
||||
GridSquare = Value(record, "GRIDSQUARE"),
|
||||
Comment = Value(record, "COMMENT"),
|
||||
Operator = Value(record, "OPERATOR"),
|
||||
};
|
||||
}
|
||||
|
||||
private static Frequency ParseFrequency(Dictionary<string, string> record)
|
||||
{
|
||||
if (record.TryGetValue("FREQ", out string? megahertz) &&
|
||||
double.TryParse(megahertz, NumberStyles.Float, CultureInfo.InvariantCulture, out double value))
|
||||
{
|
||||
return Frequency.FromMegahertz(value);
|
||||
}
|
||||
Band? band = Bands.Named(Value(record, "BAND"));
|
||||
return band?.Low ?? Frequency.Zero;
|
||||
}
|
||||
|
||||
private static DateTime ParseTimestamp(Dictionary<string, string> record)
|
||||
{
|
||||
string date = Value(record, "QSO_DATE");
|
||||
string time = Value(record, "TIME_ON").PadRight(6, '0');
|
||||
return DateTime.TryParseExact(
|
||||
date + time[..6],
|
||||
"yyyyMMddHHmmss",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out DateTime when)
|
||||
? when
|
||||
: default;
|
||||
}
|
||||
|
||||
private static string Value(Dictionary<string, string> record, string name) =>
|
||||
record.TryGetValue(name, out string? value) ? value.Trim() : "";
|
||||
|
||||
private static int Integer(Dictionary<string, string> record, string name) =>
|
||||
int.TryParse(Value(record, name), NumberStyles.Integer, CultureInfo.InvariantCulture, out int value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
61
src/Nonemm.Formats/Adif/AdifWriter.cs
Normal file
61
src/Nonemm.Formats/Adif/AdifWriter.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Formats.Adif;
|
||||
|
||||
/// Writes ADIF 3.1.4, which is what logbook and award programs read.
|
||||
public sealed class AdifWriter
|
||||
{
|
||||
private readonly StationInfo me;
|
||||
|
||||
public AdifWriter(StationInfo me) => this.me = me;
|
||||
|
||||
public string Write(IEnumerable<Qso> qsos)
|
||||
{
|
||||
StringBuilder text = new();
|
||||
text.Append("Nonemm ADIF export\r\n");
|
||||
Field(text, "ADIF_VER", "3.1.4");
|
||||
Field(text, "PROGRAMID", "Nonemm");
|
||||
text.Append("<EOH>\r\n");
|
||||
foreach (Qso qso in qsos.OrderBy(q => q.TimestampUtc))
|
||||
{
|
||||
text.Append(Record(qso)).Append("\r\n");
|
||||
}
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
public string Record(Qso qso)
|
||||
{
|
||||
StringBuilder line = new();
|
||||
Field(line, "CALL", qso.Call.Text);
|
||||
Field(line, "QSO_DATE", qso.TimestampUtc.ToString("yyyyMMdd", CultureInfo.InvariantCulture));
|
||||
Field(line, "TIME_ON", qso.TimestampUtc.ToString("HHmmss", CultureInfo.InvariantCulture));
|
||||
Field(line, "BAND", qso.Band?.Name.ToLowerInvariant() ?? "");
|
||||
Field(line, "FREQ", (qso.Frequency.Megahertz).ToString("0.000000", CultureInfo.InvariantCulture));
|
||||
Field(line, "MODE", qso.Mode.AdifName);
|
||||
Field(line, "RST_SENT", qso.SentReport);
|
||||
Field(line, "RST_RCVD", qso.ReceivedReport);
|
||||
Field(line, "STX", qso.SentNumber > 0 ? qso.SentNumber.ToString(CultureInfo.InvariantCulture) : "");
|
||||
Field(line, "SRX", qso.ReceivedNumber > 0 ? qso.ReceivedNumber.ToString(CultureInfo.InvariantCulture) : "");
|
||||
Field(line, "CONTEST_ID", qso.ContestName);
|
||||
Field(line, "OPERATOR", qso.Operator.Length > 0 ? qso.Operator : me.Callsign);
|
||||
Field(line, "STATION_CALLSIGN", me.Callsign);
|
||||
Field(line, "NAME", qso.Name);
|
||||
Field(line, "QTH", qso.Qth);
|
||||
Field(line, "GRIDSQUARE", qso.GridSquare);
|
||||
Field(line, "CQZ", qso.Zone > 0 ? qso.Zone.ToString(CultureInfo.InvariantCulture) : "");
|
||||
Field(line, "ARRL_SECT", qso.Section);
|
||||
Field(line, "COMMENT", qso.Comment);
|
||||
line.Append("<EOR>");
|
||||
return line.ToString();
|
||||
}
|
||||
|
||||
private static void Field(StringBuilder text, string name, string value)
|
||||
{
|
||||
if (value.Length > 0)
|
||||
{
|
||||
text.Append(CultureInfo.InvariantCulture, $"<{name}:{value.Length}>{value} ");
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/Nonemm.Formats/Cabrillo/CabrilloBands.cs
Normal file
42
src/Nonemm.Formats/Cabrillo/CabrilloBands.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Globalization;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Formats.Cabrillo;
|
||||
|
||||
/// The frequency column of a QSO line: kilohertz up to 30 MHz, and the
|
||||
/// sponsor's band designator above it.
|
||||
public static class CabrilloBands
|
||||
{
|
||||
private static readonly Dictionary<string, string> Designators = new(StringComparer.Ordinal)
|
||||
{
|
||||
["6M"] = "50",
|
||||
["4M"] = "70",
|
||||
["2M"] = "144",
|
||||
["1.25M"] = "222",
|
||||
["70CM"] = "432",
|
||||
["33CM"] = "902",
|
||||
["23CM"] = "1.2G",
|
||||
["13CM"] = "2.3G",
|
||||
["9CM"] = "3.4G",
|
||||
["6CM"] = "5.7G",
|
||||
["3CM"] = "10G",
|
||||
["1.25CM"] = "24G",
|
||||
["6MM"] = "47G",
|
||||
["4MM"] = "75G",
|
||||
["2.5MM"] = "123G",
|
||||
["2MM"] = "134G",
|
||||
["1MM"] = "241G",
|
||||
};
|
||||
|
||||
public static string Designator(Frequency frequency)
|
||||
{
|
||||
if (frequency.Hertz < 30_000_000)
|
||||
{
|
||||
return ((long)Math.Round(frequency.Kilohertz)).ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
Band? band = Bands.ForFrequency(frequency);
|
||||
return band is not null && Designators.TryGetValue(band.Name, out string? designator)
|
||||
? designator
|
||||
: ((long)Math.Round(frequency.Kilohertz)).ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
53
src/Nonemm.Formats/Cabrillo/CabrilloHeader.cs
Normal file
53
src/Nonemm.Formats/Cabrillo/CabrilloHeader.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace Nonemm.Formats.Cabrillo;
|
||||
|
||||
/// The header lines of a Cabrillo log. The sponsor rejects a log whose contest
|
||||
/// name or categories it does not recognise, so these come from the entry the
|
||||
/// operator chose rather than being guessed.
|
||||
public sealed record CabrilloHeader
|
||||
{
|
||||
public required string Contest { get; init; }
|
||||
|
||||
public required string Callsign { get; init; }
|
||||
|
||||
public string OperatorCategory { get; init; } = "SINGLE-OP";
|
||||
|
||||
public string AssistedCategory { get; init; } = "NON-ASSISTED";
|
||||
|
||||
public string BandCategory { get; init; } = "ALL";
|
||||
|
||||
public string ModeCategory { get; init; } = "";
|
||||
|
||||
public string PowerCategory { get; init; } = "HIGH";
|
||||
|
||||
public string StationCategory { get; init; } = "";
|
||||
|
||||
public string TransmitterCategory { get; init; } = "ONE";
|
||||
|
||||
public string OverlayCategory { get; init; } = "";
|
||||
|
||||
public string TimeCategory { get; init; } = "";
|
||||
|
||||
public long ClaimedScore { get; init; }
|
||||
|
||||
public string Club { get; init; } = "";
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public IReadOnlyList<string> Address { get; init; } = [];
|
||||
|
||||
public string AddressCity { get; init; } = "";
|
||||
|
||||
public string AddressStateProvince { get; init; } = "";
|
||||
|
||||
public string AddressPostalcode { get; init; } = "";
|
||||
|
||||
public string AddressCountry { get; init; } = "";
|
||||
|
||||
public string Operators { get; init; } = "";
|
||||
|
||||
public string Soapbox { get; init; } = "";
|
||||
|
||||
public string Email { get; init; } = "";
|
||||
|
||||
public string CreatedBy { get; init; } = "Nonemm";
|
||||
}
|
||||
97
src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs
Normal file
97
src/Nonemm.Formats/Cabrillo/CabrilloWriter.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Formats.Cabrillo;
|
||||
|
||||
/// Writes a Cabrillo 3.0 log: the header the sponsor's robot reads, then one
|
||||
/// line per contact.
|
||||
public sealed class CabrilloWriter
|
||||
{
|
||||
private readonly Contest contest;
|
||||
private readonly StationInfo me;
|
||||
|
||||
public CabrilloWriter(Contest contest, StationInfo me)
|
||||
{
|
||||
this.contest = contest;
|
||||
this.me = me;
|
||||
}
|
||||
|
||||
public string Write(CabrilloHeader header, IEnumerable<Qso> qsos)
|
||||
{
|
||||
StringBuilder text = new();
|
||||
text.Append("START-OF-LOG: 3.0\r\n");
|
||||
Line(text, "CONTEST", header.Contest);
|
||||
Line(text, "CALLSIGN", header.Callsign);
|
||||
Line(text, "CATEGORY-OPERATOR", header.OperatorCategory);
|
||||
Line(text, "CATEGORY-ASSISTED", header.AssistedCategory);
|
||||
Line(text, "CATEGORY-BAND", header.BandCategory);
|
||||
Line(text, "CATEGORY-MODE", header.ModeCategory);
|
||||
Line(text, "CATEGORY-POWER", header.PowerCategory);
|
||||
Line(text, "CATEGORY-STATION", header.StationCategory);
|
||||
Line(text, "CATEGORY-TRANSMITTER", header.TransmitterCategory);
|
||||
Line(text, "CATEGORY-OVERLAY", header.OverlayCategory);
|
||||
Line(text, "CATEGORY-TIME", header.TimeCategory);
|
||||
text.Append(CultureInfo.InvariantCulture, $"CLAIMED-SCORE: {header.ClaimedScore}\r\n");
|
||||
Line(text, "CLUB", header.Club);
|
||||
Line(text, "NAME", header.Name);
|
||||
foreach (string line in header.Address)
|
||||
{
|
||||
Line(text, "ADDRESS", line);
|
||||
}
|
||||
Line(text, "ADDRESS-CITY", header.AddressCity);
|
||||
Line(text, "ADDRESS-STATE-PROVINCE", header.AddressStateProvince);
|
||||
Line(text, "ADDRESS-POSTALCODE", header.AddressPostalcode);
|
||||
Line(text, "ADDRESS-COUNTRY", header.AddressCountry);
|
||||
Line(text, "EMAIL", header.Email);
|
||||
Line(text, "OPERATORS", header.Operators);
|
||||
foreach (string line in SplitLines(header.Soapbox))
|
||||
{
|
||||
Line(text, "SOAPBOX", line);
|
||||
}
|
||||
Line(text, "CREATED-BY", header.CreatedBy);
|
||||
|
||||
foreach (Qso qso in qsos.Where(q => q.IsClaimed).OrderBy(q => q.TimestampUtc))
|
||||
{
|
||||
text.Append(QsoLine(qso)).Append("\r\n");
|
||||
}
|
||||
text.Append("END-OF-LOG:\r\n");
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
public string QsoLine(Qso qso)
|
||||
{
|
||||
CabrilloExchange exchange = contest.CabrilloExchange(qso, me);
|
||||
StringBuilder line = new("QSO: ");
|
||||
line.Append(CabrilloBands.Designator(qso.Frequency).PadLeft(5)).Append(' ');
|
||||
line.Append(qso.Mode.CabrilloCode.PadRight(2)).Append(' ');
|
||||
line.Append(qso.TimestampUtc.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(' ');
|
||||
line.Append(qso.TimestampUtc.ToString("HHmm", CultureInfo.InvariantCulture)).Append(' ');
|
||||
line.Append(me.Callsign.PadRight(13)).Append(' ');
|
||||
Append(line, exchange.Sent);
|
||||
line.Append(qso.Call.Text.PadRight(13)).Append(' ');
|
||||
Append(line, exchange.Received);
|
||||
line.Append(qso.RadioNumber > 1 ? '1' : '0');
|
||||
return line.ToString();
|
||||
}
|
||||
|
||||
private static void Append(StringBuilder line, IReadOnlyList<CabrilloField> fields)
|
||||
{
|
||||
foreach (CabrilloField field in fields)
|
||||
{
|
||||
line.Append(field.Value.PadRight(field.Width)).Append(' ');
|
||||
}
|
||||
}
|
||||
|
||||
private static void Line(StringBuilder text, string tag, string value)
|
||||
{
|
||||
if (value.Length > 0)
|
||||
{
|
||||
text.Append(tag).Append(": ").Append(value).Append("\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitLines(string text) =>
|
||||
text.Length == 0 ? [] : text.Split('\n').Select(l => l.TrimEnd('\r'));
|
||||
}
|
||||
14
src/Nonemm.Formats/Nonemm.Formats.csproj
Normal file
14
src/Nonemm.Formats/Nonemm.Formats.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
<ProjectReference Include="..\Nonemm.Contests\Nonemm.Contests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
9
src/Nonemm.Keying/Nonemm.Keying.csproj
Normal file
9
src/Nonemm.Keying/Nonemm.Keying.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
9
src/Nonemm.Network/Nonemm.Network.csproj
Normal file
9
src/Nonemm.Network/Nonemm.Network.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
9
src/Nonemm.Rig/Nonemm.Rig.csproj
Normal file
9
src/Nonemm.Rig/Nonemm.Rig.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
9
src/Nonemm.Session/Nonemm.Session.csproj
Normal file
9
src/Nonemm.Session/Nonemm.Session.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
9
src/Nonemm.Spotting/Nonemm.Spotting.csproj
Normal file
9
src/Nonemm.Spotting/Nonemm.Spotting.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
40
src/Nonemm.Storage/ContestInstance.cs
Normal file
40
src/Nonemm.Storage/ContestInstance.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
namespace Nonemm.Storage;
|
||||
|
||||
/// One running of a contest in the log: the entry categories, the sent exchange
|
||||
/// and the number every contact in it carries.
|
||||
public sealed record ContestInstance
|
||||
{
|
||||
public required int ContestNumber { get; init; }
|
||||
|
||||
public required string ContestName { get; init; }
|
||||
|
||||
public DateTime StartDate { get; init; }
|
||||
|
||||
public string SentExchange { get; init; } = "";
|
||||
|
||||
public string SubType { get; init; } = "";
|
||||
|
||||
public string OperatorCategory { get; init; } = "SINGLE-OP";
|
||||
|
||||
public string BandCategory { get; init; } = "ALL";
|
||||
|
||||
public string PowerCategory { get; init; } = "HIGH";
|
||||
|
||||
public string ModeCategory { get; init; } = "";
|
||||
|
||||
public string OverlayCategory { get; init; } = "";
|
||||
|
||||
public string StationCategory { get; init; } = "";
|
||||
|
||||
public string AssistedCategory { get; init; } = "NON-ASSISTED";
|
||||
|
||||
public string TransmitterCategory { get; init; } = "ONE";
|
||||
|
||||
public string TimeCategory { get; init; } = "";
|
||||
|
||||
public string Operators { get; init; } = "";
|
||||
|
||||
public string Soapbox { get; init; } = "";
|
||||
|
||||
public long ClaimedScore { get; init; }
|
||||
}
|
||||
27
src/Nonemm.Storage/LogStore.cs
Normal file
27
src/Nonemm.Storage/LogStore.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Storage;
|
||||
|
||||
/// Where contacts are kept. The only implementation writes N1MM's `.s3db`, but
|
||||
/// the program talks to this so the store can move elsewhere later.
|
||||
public interface LogStore : IDisposable
|
||||
{
|
||||
IReadOnlyList<ContestInstance> Contests();
|
||||
|
||||
ContestInstance? Contest(int contestNumber);
|
||||
|
||||
/// Adds a contest and returns it with the number the store assigned.
|
||||
ContestInstance AddContest(ContestInstance instance);
|
||||
|
||||
void UpdateContest(ContestInstance instance);
|
||||
|
||||
IReadOnlyList<Qso> Qsos(int contestNumber);
|
||||
|
||||
/// Returns the contact as stored: the timestamp can move by a second when
|
||||
/// another contact with the same call already holds it.
|
||||
Qso Add(Qso qso);
|
||||
|
||||
void Update(Qso qso);
|
||||
|
||||
void Delete(string id);
|
||||
}
|
||||
20
src/Nonemm.Storage/Nonemm.Storage.csproj
Normal file
20
src/Nonemm.Storage/Nonemm.Storage.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schema.sql" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
147
src/Nonemm.Storage/QsoColumns.cs
Normal file
147
src/Nonemm.Storage/QsoColumns.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Storage;
|
||||
|
||||
/// Turns a contact into N1MM's `DXLOG` columns and back. Points and multiplier
|
||||
/// flags are written because N1MM reads them, and ignored on the way back
|
||||
/// because this program works them out from the rules.
|
||||
internal static class QsoColumns
|
||||
{
|
||||
internal const string TimestampFormat = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
internal static readonly IReadOnlyList<string> Names =
|
||||
[
|
||||
"TS", "Call", "Freq", "QSXFreq", "Mode", "ContestName", "SNT", "RCV",
|
||||
"CountryPrefix", "StationPrefix", "QTH", "Name", "Comment", "NR", "Sect",
|
||||
"Prec", "CK", "ZN", "SentNr", "Points", "IsMultiplier1", "IsMultiplier2",
|
||||
"Power", "Band", "WPXPrefix", "Exchange1", "RadioNR", "ContestNR",
|
||||
"isMultiplier3", "MiscText", "IsRunQSO", "ContactType", "Run1Run2",
|
||||
"GridSquare", "Operator", "Continent", "RoverLocation", "RadioInterfaced",
|
||||
"NetworkedCompNr", "NetBiosName", "IsOriginal", "ID", "CLAIMEDQSO",
|
||||
];
|
||||
|
||||
internal static void Bind(SqliteCommand command, Qso qso)
|
||||
{
|
||||
command.Parameters.AddWithValue("@TS", qso.TimestampUtc.ToString(TimestampFormat, CultureInfo.InvariantCulture));
|
||||
command.Parameters.AddWithValue("@Call", qso.Call.Text);
|
||||
command.Parameters.AddWithValue("@Freq", qso.Frequency.Kilohertz);
|
||||
command.Parameters.AddWithValue("@QSXFreq", qso.QsxFrequency.Kilohertz);
|
||||
command.Parameters.AddWithValue("@Mode", qso.Mode.Name);
|
||||
command.Parameters.AddWithValue("@ContestName", qso.ContestName);
|
||||
command.Parameters.AddWithValue("@SNT", qso.SentReport);
|
||||
command.Parameters.AddWithValue("@RCV", qso.ReceivedReport);
|
||||
command.Parameters.AddWithValue("@CountryPrefix", qso.CountryPrefix);
|
||||
command.Parameters.AddWithValue("@StationPrefix", qso.StationPrefix);
|
||||
command.Parameters.AddWithValue("@QTH", qso.Qth);
|
||||
command.Parameters.AddWithValue("@Name", qso.Name);
|
||||
command.Parameters.AddWithValue("@Comment", qso.Comment);
|
||||
command.Parameters.AddWithValue("@NR", qso.ReceivedNumber);
|
||||
command.Parameters.AddWithValue("@Sect", qso.Section);
|
||||
command.Parameters.AddWithValue("@Prec", qso.Precedence);
|
||||
command.Parameters.AddWithValue("@CK", qso.Check);
|
||||
command.Parameters.AddWithValue("@ZN", qso.Zone);
|
||||
command.Parameters.AddWithValue("@SentNr", qso.SentNumber);
|
||||
command.Parameters.AddWithValue("@Points", qso.Points);
|
||||
command.Parameters.AddWithValue("@IsMultiplier1", qso.IsMultiplier1 ? 1 : 0);
|
||||
command.Parameters.AddWithValue("@IsMultiplier2", qso.IsMultiplier2 ? 1 : 0);
|
||||
command.Parameters.AddWithValue("@Power", qso.Power);
|
||||
command.Parameters.AddWithValue("@Band", qso.Band?.MegahertzLabel ?? 0.0);
|
||||
command.Parameters.AddWithValue("@WPXPrefix", qso.WpxPrefix);
|
||||
command.Parameters.AddWithValue("@Exchange1", qso.Exchange1);
|
||||
command.Parameters.AddWithValue("@RadioNR", qso.RadioNumber);
|
||||
command.Parameters.AddWithValue("@ContestNR", qso.ContestNumber);
|
||||
command.Parameters.AddWithValue("@isMultiplier3", qso.IsMultiplier3 ? 1 : 0);
|
||||
command.Parameters.AddWithValue("@MiscText", qso.MiscText);
|
||||
command.Parameters.AddWithValue("@IsRunQSO", qso.IsRunQso ? 1 : 0);
|
||||
command.Parameters.AddWithValue("@ContactType", qso.ContactType);
|
||||
command.Parameters.AddWithValue("@Run1Run2", qso.RunPosition);
|
||||
command.Parameters.AddWithValue("@GridSquare", qso.GridSquare);
|
||||
command.Parameters.AddWithValue("@Operator", qso.Operator);
|
||||
command.Parameters.AddWithValue("@Continent", qso.Continent);
|
||||
command.Parameters.AddWithValue("@RoverLocation", qso.RoverLocation);
|
||||
command.Parameters.AddWithValue("@RadioInterfaced", qso.IsRadioInterfaced ? 1 : 0);
|
||||
command.Parameters.AddWithValue("@NetworkedCompNr", qso.NetworkedComputerNumber);
|
||||
command.Parameters.AddWithValue("@NetBiosName", qso.StationName);
|
||||
command.Parameters.AddWithValue("@IsOriginal", qso.IsOriginal ? 1 : 0);
|
||||
command.Parameters.AddWithValue("@ID", qso.Id);
|
||||
command.Parameters.AddWithValue("@CLAIMEDQSO", qso.IsClaimed ? 1 : 0);
|
||||
}
|
||||
|
||||
internal static Qso Read(SqliteDataReader row) => new()
|
||||
{
|
||||
Id = Text(row, "ID"),
|
||||
TimestampUtc = Timestamp(row, "TS"),
|
||||
Call = Callsign.Parse(Text(row, "Call")),
|
||||
Frequency = Frequency.FromKilohertz(Number(row, "Freq")),
|
||||
QsxFrequency = Frequency.FromKilohertz(Number(row, "QSXFreq")),
|
||||
Mode = Modes.Parse(Text(row, "Mode")) ?? Modes.Digital,
|
||||
ContestName = Text(row, "ContestName"),
|
||||
ContestNumber = Integer(row, "ContestNR"),
|
||||
SentReport = Text(row, "SNT"),
|
||||
ReceivedReport = Text(row, "RCV"),
|
||||
SentNumber = Integer(row, "SentNr"),
|
||||
ReceivedNumber = Integer(row, "NR"),
|
||||
Section = Text(row, "Sect"),
|
||||
Precedence = Text(row, "Prec"),
|
||||
Check = Integer(row, "CK"),
|
||||
Zone = Integer(row, "ZN"),
|
||||
Exchange1 = Text(row, "Exchange1"),
|
||||
MiscText = Text(row, "MiscText"),
|
||||
Comment = Text(row, "Comment"),
|
||||
Name = Text(row, "Name"),
|
||||
Qth = Text(row, "QTH"),
|
||||
Power = Text(row, "Power"),
|
||||
GridSquare = Text(row, "GridSquare"),
|
||||
RoverLocation = Text(row, "RoverLocation"),
|
||||
CountryPrefix = Text(row, "CountryPrefix"),
|
||||
StationPrefix = Text(row, "StationPrefix"),
|
||||
WpxPrefix = Text(row, "WPXPrefix"),
|
||||
Continent = Text(row, "Continent"),
|
||||
IsRunQso = Flag(row, "IsRunQSO"),
|
||||
ContactType = Text(row, "ContactType"),
|
||||
RunPosition = Integer(row, "Run1Run2"),
|
||||
Operator = Text(row, "Operator"),
|
||||
RadioNumber = Integer(row, "RadioNR"),
|
||||
IsRadioInterfaced = Flag(row, "RadioInterfaced"),
|
||||
NetworkedComputerNumber = Integer(row, "NetworkedCompNr"),
|
||||
StationName = Text(row, "NetBiosName"),
|
||||
IsOriginal = Flag(row, "IsOriginal"),
|
||||
IsClaimed = Flag(row, "CLAIMEDQSO"),
|
||||
};
|
||||
|
||||
private static string Text(SqliteDataReader row, string column)
|
||||
{
|
||||
int at = row.GetOrdinal(column);
|
||||
return row.IsDBNull(at) ? "" : row.GetString(at);
|
||||
}
|
||||
|
||||
private static double Number(SqliteDataReader row, string column)
|
||||
{
|
||||
int at = row.GetOrdinal(column);
|
||||
return row.IsDBNull(at) ? 0 : row.GetDouble(at);
|
||||
}
|
||||
|
||||
private static int Integer(SqliteDataReader row, string column)
|
||||
{
|
||||
int at = row.GetOrdinal(column);
|
||||
return row.IsDBNull(at) ? 0 : (int)row.GetInt64(at);
|
||||
}
|
||||
|
||||
private static bool Flag(SqliteDataReader row, string column) => Integer(row, column) != 0;
|
||||
|
||||
/// N1MM writes seconds; older rows and other writers add fractions or a
|
||||
/// `T` separator, so anything round-trippable is accepted.
|
||||
private static DateTime Timestamp(SqliteDataReader row, string column)
|
||||
{
|
||||
string text = Text(row, column);
|
||||
return DateTime.TryParse(
|
||||
text,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
|
||||
out DateTime parsed)
|
||||
? parsed
|
||||
: throw new FormatException($"contact timestamp '{text}' is not a date");
|
||||
}
|
||||
}
|
||||
147
src/Nonemm.Storage/Schema.sql
Normal file
147
src/Nonemm.Storage/Schema.sql
Normal file
@@ -0,0 +1,147 @@
|
||||
CREATE TABLE IF NOT EXISTS [Contest] (
|
||||
[Name] NVARCHAR(10) NOT NULL PRIMARY KEY,
|
||||
[DisplayName] NVARCHAR(50) NULL,
|
||||
[CabrilloName] NVARCHAR(15) NOT NULL,
|
||||
[Mode] NVARCHAR(6) NOT NULL,
|
||||
[DupeType] SMALLINT NULL,
|
||||
[Multiplier1Name] NVARCHAR(15) NULL,
|
||||
[Multiplier2Name] NVARCHAR(15) NULL,
|
||||
[Period] INT NOT NULL,
|
||||
[PointsPerContact] INT NULL,
|
||||
[Multiplier3Name] NVARCHAR(15) NULL,
|
||||
[MasterDTA] NVARCHAR(255) NULL,
|
||||
[CWMessages] NVARCHAR(255) NULL,
|
||||
[SSBMessages] NVARCHAR(255) NULL,
|
||||
[DigiMessages] NVARCHAR(255) NULL,
|
||||
[CabrilloVersion] NVARCHAR(20) NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS [ContestInstance] (
|
||||
[ContestID] INT NOT NULL,
|
||||
[ContestName] NVARCHAR(10),
|
||||
[StartDate] DATETIME,
|
||||
[OperatorCategory] NVARCHAR(20),
|
||||
[BandCategory] NVARCHAR(20),
|
||||
[PowerCategory] NVARCHAR(20),
|
||||
[ModeCategory] NVARCHAR(20),
|
||||
[OverlayCategory] NVARCHAR(20),
|
||||
[ClaimedScore] MONEY,
|
||||
[Operators] NVARCHAR(255),
|
||||
[Soapbox] TEXT,
|
||||
[SentExchange] NVARCHAR(50),
|
||||
[ContestNR] INT,
|
||||
[SubType] NVARCHAR(9),
|
||||
[StationCategory] NVARCHAR(20),
|
||||
[AssistedCategory] NVARCHAR(20),
|
||||
[TransmitterCategory] NVARCHAR(20),
|
||||
[TimeCategory] NVARCHAR(20),
|
||||
CONSTRAINT [sqlite_autoindex_ContestInstance_1] PRIMARY KEY ([ContestID]));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS [DBInfo] (
|
||||
[Key] NVARCHAR(128) NOT NULL,
|
||||
[Value] NVARCHAR(512) NOT NULL,
|
||||
CONSTRAINT [] PRIMARY KEY ([Key]));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS [DBVersionV2] (
|
||||
[VersionNumber] INT NOT NULL,
|
||||
[DateTimeUpdatedUTC] DATETIME NOT NULL,
|
||||
[Log] TEXT,
|
||||
CONSTRAINT [] PRIMARY KEY ([VersionNumber]));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `DXLOG`
|
||||
(
|
||||
`TS` DATETIME NOT NULL,
|
||||
`Call` VARCHAR(15) NOT NULL,
|
||||
`Freq` DOUBLE NULL,
|
||||
`QSXFreq` DOUBLE NULL DEFAULT 0,
|
||||
`Mode` VARCHAR(6),
|
||||
`ContestName` VARCHAR(10) DEFAULT 'NORMAL',
|
||||
`SNT` VARCHAR(10),
|
||||
`RCV` VARCHAR(15),
|
||||
`CountryPrefix` VARCHAR(8) DEFAULT '',
|
||||
`StationPrefix` VARCHAR(15) DEFAULT '',
|
||||
`QTH` VARCHAR(25) DEFAULT '',
|
||||
`Name` VARCHAR(20) DEFAULT '',
|
||||
`Comment` VARCHAR(60) DEFAULT '',
|
||||
`NR` INTEGER DEFAULT 0,
|
||||
`Sect` VARCHAR(8) DEFAULT '',
|
||||
`Prec` VARCHAR(1) DEFAULT '',
|
||||
`CK` TINYINT DEFAULT 0,
|
||||
`ZN` TINYINT DEFAULT 0,
|
||||
`SentNr` INTEGER DEFAULT 0,
|
||||
`Points` INTEGER DEFAULT 0,
|
||||
`IsMultiplier1` TINYINT DEFAULT 0,
|
||||
`IsMultiplier2` INTEGER DEFAULT 0,
|
||||
`Power` VARCHAR(8),
|
||||
`Band` FLOAT NULL DEFAULT 0,
|
||||
`WPXPrefix` VARCHAR(8),
|
||||
`Exchange1` VARCHAR(20),
|
||||
`RadioNR` TINYINT DEFAULT 1,
|
||||
`ContestNR` INTEGER,
|
||||
`isMultiplier3` INTEGER,
|
||||
`MiscText` VARCHAR(20),
|
||||
`IsRunQSO` TINYINT(1) DEFAULT 0,
|
||||
`ContactType` VARCHAR(1),
|
||||
`Run1Run2` TINYINT NOT NULL,
|
||||
`GridSquare` VARCHAR(6),
|
||||
`Operator` VARCHAR(20),
|
||||
`Continent` VARCHAR(2),
|
||||
`RoverLocation` VARCHAR(10),
|
||||
`RadioInterfaced` INTEGER,
|
||||
`NetworkedCompNr` INTEGER,
|
||||
NetBiosName varchar (255),
|
||||
IsOriginal Boolean,
|
||||
[ID] TEXT(16) NOT NULL DEFAULT '0000000000000000',
|
||||
[CLAIMEDQSO] INTEGER DEFAULT 1,
|
||||
PRIMARY KEY (`TS`, `Call`)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS [Station] ([Call] NVARCHAR(20) NOT NULL, [Name] NVARCHAR(50),
|
||||
[Street1] NVARCHAR(50), [Street2] NVARCHAR(50), [City] NVARCHAR(30), [State] NVARCHAR(8),
|
||||
[Zip] NVARCHAR(25), [Country] NVARCHAR(30), [GridSquare] NVARCHAR(8) DEFAULT 'Unknown',
|
||||
[LicenseClass] NVARCHAR(10) DEFAULT 'Unknown', [Latitude] FLOAT DEFAULT 0,
|
||||
[Longitude] FLOAT DEFAULT 0, [PacketNode] NVARCHAR(10) DEFAULT 'N/A',
|
||||
[ARRLSection] NVARCHAR(4), [Club] NVARCHAR(50), [IARUZone] SMALLINT DEFAULT 0,
|
||||
[CQZone] SMALLINT NOT NULL, [STXeq] NVARCHAR(50), [SPowe] NVARCHAR(20),
|
||||
[SAnte] NVARCHAR(30), [SAntH1] NVARCHAR(15), [SAntH2] NVARCHAR(15),
|
||||
[RoverQTH] NVARCHAR(10), PRIMARY KEY([Call]));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS [Skeds](
|
||||
[GUID] VARCHAR(16) PRIMARY KEY NOT NULL UNIQUE DEFAULT '0000000000000000',
|
||||
[ContestName] VARCHAR(10) NOT NULL,
|
||||
[SubType] VARCHAR(10) NOT NULL DEFAULT '',
|
||||
[Deleted] BIT DEFAULT 0,
|
||||
[Time] DATETIME NOT NULL,
|
||||
[Call] VARCHAR(15) NOT NULL,
|
||||
[Frequency] VARCHAR(8) NOT NULL,
|
||||
[Mode] VARCHAR(6) NOT NULL,
|
||||
[OriginStation] VARCHAR(15) NOT NULL,
|
||||
[Comment] VARCHAR(100) DEFAULT '');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS [Call] ON [DXLOG]([ContestNR] ASC, [Call] ASC);
|
||||
CREATE INDEX IF NOT EXISTS [contestNrZoneBandTS] ON [DXLOG] ([ContestNR] ASC, [ZN] ASC, [Band] ASC, [TS] ASC);
|
||||
CREATE INDEX IF NOT EXISTS [ContestNr_TS] ON [DXLOG]([ContestNR] ASC, [TS] ASC);
|
||||
CREATE INDEX IF NOT EXISTS [CountryPrefix] ON [DXLOG]([ContestNR] ASC, [CountryPrefix] ASC, [Band] ASC, [Mode] ASC);
|
||||
CREATE INDEX IF NOT EXISTS [Exchange1] ON [DXLOG]([ContestNR] ASC, [Exchange1] ASC);
|
||||
CREATE INDEX IF NOT EXISTS [Idx_WPXPrefix] ON [DXLOG]([ContestNR] ASC, [WPXPrefix] ASC, [ts] ASC);
|
||||
CREATE INDEX IF NOT EXISTS [MiscText] ON [DXLOG]([ContestNR] ASC, [MiscText] ASC);
|
||||
CREATE INDEX IF NOT EXISTS [NRRCV] ON [DXLOG]([ContestNr] ASC, [RCV] ASC);
|
||||
CREATE INDEX IF NOT EXISTS [RCV] ON [DXLOG]([RCV] ASC);
|
||||
CREATE INDEX IF NOT EXISTS [Sect] ON [DXLOG]([ContestNR] ASC, [Sect] ASC);
|
||||
CREATE INDEX IF NOT EXISTS TS ON DXLog (`TS`);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS [ID_INDEX] ON [DXLOG]([ID] COLLATE [BINARY] ASC);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS AUTOGENERATE_ID
|
||||
AFTER INSERT ON [DXLOG]
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.ID IS NULL)
|
||||
BEGIN
|
||||
UPDATE [DXLOG] SET ID = LOWER(HEX(RANDOMBLOB(16)))
|
||||
WHERE [DXLOG].ROWID = NEW.ROWID;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS READONLY_ID
|
||||
BEFORE UPDATE OF [ID] ON [DXLOG]
|
||||
BEGIN
|
||||
SELECT raise(abort, 'READONLY ID');
|
||||
END;
|
||||
243
src/Nonemm.Storage/SqliteLogStore.cs
Normal file
243
src/Nonemm.Storage/SqliteLogStore.cs
Normal file
@@ -0,0 +1,243 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Storage;
|
||||
|
||||
/// N1MM's `.s3db` log database. A file this writes opens in N1MM, and a file
|
||||
/// N1MM wrote opens here.
|
||||
public sealed class SqliteLogStore : LogStore
|
||||
{
|
||||
private const int SchemaVersion = 4;
|
||||
|
||||
private readonly SqliteConnection connection;
|
||||
|
||||
private SqliteLogStore(SqliteConnection connection) => this.connection = connection;
|
||||
|
||||
/// Opens the file, creating N1MM's tables when it is new.
|
||||
public static SqliteLogStore Open(string path)
|
||||
{
|
||||
SqliteConnection connection = new(new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = path,
|
||||
Mode = SqliteOpenMode.ReadWriteCreate,
|
||||
}.ToString());
|
||||
connection.Open();
|
||||
Execute(connection, "PRAGMA foreign_keys = ON;");
|
||||
SqliteLogStore store = new(connection);
|
||||
store.CreateSchemaIfMissing();
|
||||
return store;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ContestInstance> Contests()
|
||||
{
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT * FROM ContestInstance ORDER BY ContestID";
|
||||
using SqliteDataReader row = command.ExecuteReader();
|
||||
List<ContestInstance> found = [];
|
||||
while (row.Read())
|
||||
{
|
||||
found.Add(ReadContest(row));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public ContestInstance? Contest(int contestNumber)
|
||||
{
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT * FROM ContestInstance WHERE ContestID = @id";
|
||||
command.Parameters.AddWithValue("@id", contestNumber);
|
||||
using SqliteDataReader row = command.ExecuteReader();
|
||||
return row.Read() ? ReadContest(row) : null;
|
||||
}
|
||||
|
||||
public ContestInstance AddContest(ContestInstance instance)
|
||||
{
|
||||
ContestInstance stored = instance with { ContestNumber = NextContestNumber() };
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO ContestInstance
|
||||
(ContestID, ContestName, StartDate, OperatorCategory, BandCategory,
|
||||
PowerCategory, ModeCategory, OverlayCategory, ClaimedScore, Operators,
|
||||
Soapbox, SentExchange, ContestNR, SubType, StationCategory,
|
||||
AssistedCategory, TransmitterCategory, TimeCategory)
|
||||
VALUES
|
||||
(@id, @name, @start, @op, @band, @power, @mode, @overlay, @score, @ops,
|
||||
@soapbox, @sent, @id, @subtype, @station, @assisted, @tx, @time)
|
||||
""";
|
||||
BindContest(command, stored);
|
||||
command.ExecuteNonQuery();
|
||||
return stored;
|
||||
}
|
||||
|
||||
public void UpdateContest(ContestInstance instance)
|
||||
{
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE ContestInstance SET
|
||||
ContestName = @name, StartDate = @start, OperatorCategory = @op,
|
||||
BandCategory = @band, PowerCategory = @power, ModeCategory = @mode,
|
||||
OverlayCategory = @overlay, ClaimedScore = @score, Operators = @ops,
|
||||
Soapbox = @soapbox, SentExchange = @sent, SubType = @subtype,
|
||||
StationCategory = @station, AssistedCategory = @assisted,
|
||||
TransmitterCategory = @tx, TimeCategory = @time
|
||||
WHERE ContestID = @id
|
||||
""";
|
||||
BindContest(command, instance);
|
||||
if (command.ExecuteNonQuery() == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"no contest numbered {instance.ContestNumber} in the log");
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Qso> Qsos(int contestNumber)
|
||||
{
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT * FROM DXLOG WHERE ContestNR = @nr ORDER BY TS";
|
||||
command.Parameters.AddWithValue("@nr", contestNumber);
|
||||
using SqliteDataReader row = command.ExecuteReader();
|
||||
List<Qso> found = [];
|
||||
while (row.Read())
|
||||
{
|
||||
found.Add(QsoColumns.Read(row));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public Qso Add(Qso qso)
|
||||
{
|
||||
Qso candidate = qso;
|
||||
// the log keys on time and call together, so a second contact with the
|
||||
// same call in the same second moves on by a second rather than failing
|
||||
for (int attempt = 0; attempt < 60; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Insert(candidate);
|
||||
return candidate;
|
||||
}
|
||||
catch (SqliteException e) when (e.SqliteErrorCode == 19)
|
||||
{
|
||||
candidate = candidate with { TimestampUtc = candidate.TimestampUtc.AddSeconds(1) };
|
||||
}
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
$"could not find a free second for {qso.Call} near {qso.TimestampUtc:u}");
|
||||
}
|
||||
|
||||
public void Update(Qso qso)
|
||||
{
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
string assignments = string.Join(
|
||||
", ",
|
||||
QsoColumns.Names.Where(n => n != "ID").Select(n => $"[{n}] = @{n}"));
|
||||
command.CommandText = $"UPDATE DXLOG SET {assignments} WHERE ID = @ID";
|
||||
QsoColumns.Bind(command, qso);
|
||||
if (command.ExecuteNonQuery() == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"no contact with id {qso.Id} in the log");
|
||||
}
|
||||
}
|
||||
|
||||
public void Delete(string id)
|
||||
{
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = "DELETE FROM DXLOG WHERE ID = @id";
|
||||
command.Parameters.AddWithValue("@id", id);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Dispose() => connection.Dispose();
|
||||
|
||||
private void Insert(Qso qso)
|
||||
{
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
string columns = string.Join(", ", QsoColumns.Names.Select(n => $"[{n}]"));
|
||||
string values = string.Join(", ", QsoColumns.Names.Select(n => $"@{n}"));
|
||||
command.CommandText = $"INSERT INTO DXLOG ({columns}) VALUES ({values})";
|
||||
QsoColumns.Bind(command, qso);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private int NextContestNumber()
|
||||
{
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT COALESCE(MAX(ContestID), 0) + 1 FROM ContestInstance";
|
||||
return (int)(long)(command.ExecuteScalar() ?? 1L);
|
||||
}
|
||||
|
||||
private void CreateSchemaIfMissing()
|
||||
{
|
||||
using Stream stream = typeof(SqliteLogStore).Assembly
|
||||
.GetManifestResourceStream("Nonemm.Storage.Schema.sql")
|
||||
?? throw new InvalidOperationException("the schema is missing from the build");
|
||||
using StreamReader reader = new(stream);
|
||||
Execute(connection, reader.ReadToEnd());
|
||||
Execute(connection, $"PRAGMA user_version = {SchemaVersion};");
|
||||
}
|
||||
|
||||
private static void Execute(SqliteConnection connection, string sql)
|
||||
{
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static void BindContest(SqliteCommand command, ContestInstance instance)
|
||||
{
|
||||
command.Parameters.AddWithValue("@id", instance.ContestNumber);
|
||||
command.Parameters.AddWithValue("@name", instance.ContestName);
|
||||
command.Parameters.AddWithValue(
|
||||
"@start",
|
||||
instance.StartDate.ToString(QsoColumns.TimestampFormat, CultureInfo.InvariantCulture));
|
||||
command.Parameters.AddWithValue("@op", instance.OperatorCategory);
|
||||
command.Parameters.AddWithValue("@band", instance.BandCategory);
|
||||
command.Parameters.AddWithValue("@power", instance.PowerCategory);
|
||||
command.Parameters.AddWithValue("@mode", instance.ModeCategory);
|
||||
command.Parameters.AddWithValue("@overlay", instance.OverlayCategory);
|
||||
command.Parameters.AddWithValue("@score", instance.ClaimedScore);
|
||||
command.Parameters.AddWithValue("@ops", instance.Operators);
|
||||
command.Parameters.AddWithValue("@soapbox", instance.Soapbox);
|
||||
command.Parameters.AddWithValue("@sent", instance.SentExchange);
|
||||
command.Parameters.AddWithValue("@subtype", instance.SubType);
|
||||
command.Parameters.AddWithValue("@station", instance.StationCategory);
|
||||
command.Parameters.AddWithValue("@assisted", instance.AssistedCategory);
|
||||
command.Parameters.AddWithValue("@tx", instance.TransmitterCategory);
|
||||
command.Parameters.AddWithValue("@time", instance.TimeCategory);
|
||||
}
|
||||
|
||||
private static ContestInstance ReadContest(SqliteDataReader row) => new()
|
||||
{
|
||||
ContestNumber = (int)row.GetInt64(row.GetOrdinal("ContestID")),
|
||||
ContestName = TextOf(row, "ContestName"),
|
||||
StartDate = DateTime.TryParse(
|
||||
TextOf(row, "StartDate"),
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
|
||||
out DateTime start)
|
||||
? start
|
||||
: default,
|
||||
SentExchange = TextOf(row, "SentExchange"),
|
||||
SubType = TextOf(row, "SubType"),
|
||||
OperatorCategory = TextOf(row, "OperatorCategory"),
|
||||
BandCategory = TextOf(row, "BandCategory"),
|
||||
PowerCategory = TextOf(row, "PowerCategory"),
|
||||
ModeCategory = TextOf(row, "ModeCategory"),
|
||||
OverlayCategory = TextOf(row, "OverlayCategory"),
|
||||
StationCategory = TextOf(row, "StationCategory"),
|
||||
AssistedCategory = TextOf(row, "AssistedCategory"),
|
||||
TransmitterCategory = TextOf(row, "TransmitterCategory"),
|
||||
TimeCategory = TextOf(row, "TimeCategory"),
|
||||
Operators = TextOf(row, "Operators"),
|
||||
Soapbox = TextOf(row, "Soapbox"),
|
||||
ClaimedScore = row.IsDBNull(row.GetOrdinal("ClaimedScore"))
|
||||
? 0
|
||||
: (long)row.GetDouble(row.GetOrdinal("ClaimedScore")),
|
||||
};
|
||||
|
||||
private static string TextOf(SqliteDataReader row, string column)
|
||||
{
|
||||
int at = row.GetOrdinal(column);
|
||||
return row.IsDBNull(at) ? "" : row.GetString(at);
|
||||
}
|
||||
}
|
||||
87
tests/Nonemm.Contests.Tests/CqWorldWideTests.cs
Normal file
87
tests/Nonemm.Contests.Tests/CqWorldWideTests.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Tests;
|
||||
|
||||
public class CqWorldWideTests
|
||||
{
|
||||
private static ContestLog LogFor(StationInfo me) =>
|
||||
new(new CqWorldWide(ModeCategory.Cw), me, TestLog.CountryFile);
|
||||
|
||||
[Fact]
|
||||
public void ContactWithOwnCountryScoresNothing()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal(0, log.Judge(TestLog.Contact("DL9XYZ", zone: 14)).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameContinentDifferentCountryScoresOne()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal(1, log.Judge(TestLog.Contact("IK2XYZ", zone: 15)).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentContinentScoresThree()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal(3, log.Judge(TestLog.Contact("JA1XYZ", zone: 25)).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NorthAmericansScoreTwoWithinNorthAmerica()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.UnitedStates);
|
||||
Assert.Equal(2, log.Judge(TestLog.Contact("VE3XYZ", zone: 5)).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnCountryStillCountsAsAMultiplier()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Verdict verdict = log.Judge(TestLog.Contact("DL9XYZ", zone: 14));
|
||||
Assert.Equal(2, verdict.NewMultipliers.Count);
|
||||
Assert.Equal(0, verdict.Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZoneAndCountryCountOncePerBand()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
log.Add(TestLog.Contact("JA1XYZ", zone: 25));
|
||||
Assert.Empty(log.Judge(TestLog.Contact("JA2XYZ", zone: 25)).NewMultipliers);
|
||||
Assert.Equal(2, log.Judge(TestLog.Contact("JA2XYZ", 21_025, zone: 25)).NewMultipliers.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameStationOnAnotherBandIsNotADupe()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
log.Add(TestLog.Contact("JA1XYZ", zone: 25));
|
||||
Assert.True(log.Judge(TestLog.Contact("JA1XYZ", zone: 25)).IsDupe);
|
||||
Assert.False(log.Judge(TestLog.Contact("JA1XYZ", 21_025, zone: 25)).IsDupe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoreIsPointsTimesMultipliers()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
log.Add(TestLog.Contact("JA1XYZ", zone: 25));
|
||||
log.Add(TestLog.Contact("PY2XYZ", zone: 11));
|
||||
Assert.Equal(6, log.Tally.Points);
|
||||
Assert.Equal(4, log.Tally.TotalMultipliers);
|
||||
Assert.Equal(24, log.TotalScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovingAContactHandsItsMultiplierToTheNextOne()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Qso first = log.Add(TestLog.Contact("JA1XYZ", zone: 25, at: new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc)));
|
||||
log.Add(TestLog.Contact("JA2XYZ", zone: 25, at: new DateTime(2026, 5, 30, 12, 1, 0, DateTimeKind.Utc)));
|
||||
log.Remove(first.Id);
|
||||
Assert.True(log.Qsos.Single().IsMultiplier1);
|
||||
Assert.Equal(2, log.Tally.TotalMultipliers);
|
||||
}
|
||||
}
|
||||
69
tests/Nonemm.Contests.Tests/CqWpxTests.cs
Normal file
69
tests/Nonemm.Contests.Tests/CqWpxTests.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Contests.Tests;
|
||||
|
||||
public class CqWpxTests
|
||||
{
|
||||
private static ContestLog LogFor(StationInfo me, ModeCategory mode = ModeCategory.Cw) =>
|
||||
new(new CqWpx(mode), me, TestLog.CountryFile);
|
||||
|
||||
[Theory]
|
||||
[InlineData(14_025, 3)]
|
||||
[InlineData(21_025, 3)]
|
||||
[InlineData(7_025, 6)]
|
||||
[InlineData(3_525, 6)]
|
||||
public void DifferentContinentDoublesBelowFourteenMegahertz(double kilohertz, int expected)
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal(expected, log.Judge(TestLog.Contact("JA1XYZ", kilohertz)).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameContinentScoresOneOnTheHighBands()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal(1, log.Judge(TestLog.Contact("IK2XYZ")).Points);
|
||||
Assert.Equal(2, log.Judge(TestLog.Contact("IK2XYZ", 7_025)).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NorthAmericansScoreDoubleWithinNorthAmerica()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.UnitedStates);
|
||||
Assert.Equal(2, log.Judge(TestLog.Contact("VE3XYZ")).Points);
|
||||
Assert.Equal(4, log.Judge(TestLog.Contact("VE3XYZ", 7_025)).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnCountryScoresOneOnEveryBand()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Equal(1, log.Judge(TestLog.Contact("DL9XYZ")).Points);
|
||||
Assert.Equal(1, log.Judge(TestLog.Contact("DL9XYZ", 3_525)).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RttyScoresOwnCountryTwiceOnTheLowBands()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany, ModeCategory.Digital);
|
||||
Assert.Equal(1, log.Judge(TestLog.Contact("DL9XYZ", mode: Modes.Rtty)).Points);
|
||||
Assert.Equal(2, log.Judge(TestLog.Contact("DL9XYZ", 3_525, Modes.Rtty)).Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrefixCountsOnceForTheWholeContest()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
log.Add(TestLog.Contact("JA1XYZ"));
|
||||
Assert.Empty(log.Judge(TestLog.Contact("JA1ZZZ", 7_025)).NewMultipliers);
|
||||
Assert.Single(log.Judge(TestLog.Contact("JA2ZZZ", 7_025)).NewMultipliers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaritimeMobileBringsNoPrefix()
|
||||
{
|
||||
ContestLog log = LogFor(TestLog.Germany);
|
||||
Assert.Empty(log.Judge(TestLog.Contact("DL1ABC/MM")).NewMultipliers);
|
||||
}
|
||||
}
|
||||
26
tests/Nonemm.Contests.Tests/Nonemm.Contests.Tests.csproj
Normal file
26
tests/Nonemm.Contests.Tests/Nonemm.Contests.Tests.csproj
Normal file
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Nonemm.Contests\Nonemm.Contests.csproj" />
|
||||
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
69
tests/Nonemm.Contests.Tests/TestLog.cs
Normal file
69
tests/Nonemm.Contests.Tests/TestLog.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Country;
|
||||
|
||||
namespace Nonemm.Contests.Tests;
|
||||
|
||||
/// A country file and QSO builder small enough to read, so a scoring test says
|
||||
/// what it is testing rather than how to build a contact.
|
||||
public static class TestLog
|
||||
{
|
||||
public const string Countries = """
|
||||
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
|
||||
DA,DB,DC,DD,DE,DF,DG,DH,DJ,DK,DL,DM,DO,DP,DQ,DR;
|
||||
Italy: 15: 28: EU: 42.83: -12.83: -1.0: I:
|
||||
I,IK,IZ;
|
||||
United States: 05: 08: NA: 37.60: 91.87: 5.0: K:
|
||||
K,W,N,AA,K6(3)[6];
|
||||
Canada: 05: 09: NA: 44.35: 78.75: 5.0: VE:
|
||||
VE,VA;
|
||||
Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA:
|
||||
JA,JH,JR,7K;
|
||||
Brazil: 11: 15: SA: -10.00: 55.00: 3.0: PY:
|
||||
PY,PP;
|
||||
""";
|
||||
|
||||
public static readonly CountryFile CountryFile = CountryFile.Parse(Countries);
|
||||
|
||||
public static readonly StationInfo Germany = new()
|
||||
{
|
||||
Callsign = "DL1ABC",
|
||||
CqZone = 14,
|
||||
ItuZone = 28,
|
||||
Continent = "EU",
|
||||
CountryPrefix = "DL",
|
||||
};
|
||||
|
||||
public static readonly StationInfo UnitedStates = new()
|
||||
{
|
||||
Callsign = "K1ABC",
|
||||
CqZone = 5,
|
||||
ItuZone = 8,
|
||||
Continent = "NA",
|
||||
CountryPrefix = "K",
|
||||
State = "CT",
|
||||
ArrlSection = "CT",
|
||||
};
|
||||
|
||||
public static Qso Contact(
|
||||
string call,
|
||||
double kilohertz = 14_025,
|
||||
Mode? mode = null,
|
||||
int zone = 0,
|
||||
int number = 0,
|
||||
string exchange = "",
|
||||
string section = "",
|
||||
DateTime? at = null) =>
|
||||
new()
|
||||
{
|
||||
Id = Qso.NewId(),
|
||||
TimestampUtc = at ?? new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc),
|
||||
Call = Callsign.Parse(call),
|
||||
Frequency = Frequency.FromKilohertz(kilohertz),
|
||||
Mode = mode ?? Modes.Cw,
|
||||
ContestName = "TEST",
|
||||
Zone = zone,
|
||||
ReceivedNumber = number,
|
||||
Exchange1 = exchange,
|
||||
Section = section,
|
||||
};
|
||||
}
|
||||
34
tests/Nonemm.Core.Tests/BandsTests.cs
Normal file
34
tests/Nonemm.Core.Tests/BandsTests.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Core.Tests;
|
||||
|
||||
public class BandsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1830, "160M")]
|
||||
[InlineData(3510.5, "80M")]
|
||||
[InlineData(14025, "20M")]
|
||||
[InlineData(28499, "10M")]
|
||||
[InlineData(50110, "6M")]
|
||||
[InlineData(144200, "2M")]
|
||||
public void FrequencyLandsOnItsBand(double kilohertz, string expected) =>
|
||||
Assert.Equal(expected, Bands.ForFrequency(Frequency.FromKilohertz(kilohertz))?.Name);
|
||||
|
||||
[Fact]
|
||||
public void FrequencyOutsideEveryAllocationHasNoBand() =>
|
||||
Assert.Null(Bands.ForFrequency(Frequency.FromKilohertz(12000)));
|
||||
|
||||
[Fact]
|
||||
public void BandEdgesAreIncluded()
|
||||
{
|
||||
Assert.Equal("20M", Bands.ForFrequency(Frequency.FromKilohertz(14000))?.Name);
|
||||
Assert.Equal("20M", Bands.ForFrequency(Frequency.FromKilohertz(14350))?.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1.8, "160M")]
|
||||
[InlineData(5, "60M")]
|
||||
[InlineData(76000, "4MM")]
|
||||
public void N1mmBandLabelFindsTheBand(double label, string expected) =>
|
||||
Assert.Equal(expected, Bands.ByLabel(label)?.Name);
|
||||
}
|
||||
55
tests/Nonemm.Core.Tests/CallsignTests.cs
Normal file
55
tests/Nonemm.Core.Tests/CallsignTests.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Core.Tests;
|
||||
|
||||
public class CallsignTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("N8BJQ", "N8")]
|
||||
[InlineData("9A1AA", "9A1")]
|
||||
[InlineData("K1ABC", "K1")]
|
||||
[InlineData("VP2EXX", "VP2")]
|
||||
[InlineData("LZ1400PS", "LZ1400")]
|
||||
public void WpxPrefixEndsAtTheLastDigit(string call, string expected) =>
|
||||
Assert.Equal(expected, Callsign.Parse(call).WpxPrefix());
|
||||
|
||||
[Fact]
|
||||
public void CallWithNoDigitTakesAZeroAfterTwoLetters() =>
|
||||
Assert.Equal("RA0", Callsign.Parse("RAEM").WpxPrefix());
|
||||
|
||||
[Theory]
|
||||
[InlineData("KH9/N8BJQ")]
|
||||
[InlineData("N8BJQ/KH9")]
|
||||
public void PortablePrefixBecomesTheWpxPrefix(string call) =>
|
||||
Assert.Equal("KH9", Callsign.Parse(call).WpxPrefix());
|
||||
|
||||
[Fact]
|
||||
public void PortablePrefixWithNoDigitGainsAZero() =>
|
||||
Assert.Equal("PA0", Callsign.Parse("PA/N8BJQ").WpxPrefix());
|
||||
|
||||
[Fact]
|
||||
public void SingleDigitModifierReplacesTheDigit() =>
|
||||
Assert.Equal("N9", Callsign.Parse("N8BJQ/9").WpxPrefix());
|
||||
|
||||
[Fact]
|
||||
public void PortableModifierChangesNothing()
|
||||
{
|
||||
Callsign call = Callsign.Parse("DL1ABC/P");
|
||||
Assert.Equal("DL1", call.WpxPrefix());
|
||||
Assert.Null(call.PortablePrefix);
|
||||
Assert.Equal("DL1ABC", call.Station);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaritimeMobileCountsForNothing()
|
||||
{
|
||||
Callsign call = Callsign.Parse("DL1ABC/MM");
|
||||
Assert.True(call.IsMaritimeMobile);
|
||||
Assert.False(call.CountsForEntity);
|
||||
Assert.Null(call.WpxPrefix());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortablePrefixIsWhatTheCountryFileGetsAsked() =>
|
||||
Assert.Equal("KH9", Callsign.Parse("KH9/N8BJQ").EntityLookupText());
|
||||
}
|
||||
70
tests/Nonemm.Core.Tests/Country/CountryFileTests.cs
Normal file
70
tests/Nonemm.Core.Tests/Country/CountryFileTests.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Country;
|
||||
|
||||
namespace Nonemm.Core.Tests.Country;
|
||||
|
||||
public class CountryFileTests
|
||||
{
|
||||
private const string Sample = """
|
||||
Sicily: 15: 28: EU: 37.50: -14.00: -1.0: *IT9:
|
||||
IT9,IW9,ID9;
|
||||
Italy: 15: 28: EU: 42.83: -12.83: -1.0: I:
|
||||
I,IK,IZ,=IG9ABC;
|
||||
United States: 05: 08: NA: 37.60: 91.87: 5.0: K:
|
||||
K,W,N,K6(3)[6],=W1AW;
|
||||
Fiji: 32: 56: OC: -17.78: -177.92: -12.0: 3D2:
|
||||
3D2;
|
||||
""";
|
||||
|
||||
private static readonly CountryFile File = CountryFile.Parse(Sample);
|
||||
|
||||
[Fact]
|
||||
public void LongestPrefixWins() =>
|
||||
Assert.Equal("IT9", File.Find("IT9ABC")?.Entity.PrimaryPrefix);
|
||||
|
||||
[Fact]
|
||||
public void ShorterPrefixStillMatches() =>
|
||||
Assert.Equal("I", File.Find("IK2XYZ")?.Entity.PrimaryPrefix);
|
||||
|
||||
[Fact]
|
||||
public void WaeOnlyEntitiesAreMarked() =>
|
||||
Assert.True(File.Find("IT9ABC")?.Entity.IsWaeOnly);
|
||||
|
||||
[Fact]
|
||||
public void WholeCallEntryBeatsThePrefix() =>
|
||||
Assert.Equal("I", File.Find("IG9ABC")?.Entity.PrimaryPrefix);
|
||||
|
||||
[Fact]
|
||||
public void PrefixOverrideChangesTheZone()
|
||||
{
|
||||
CountryLookup? found = File.Find("K6XYZ");
|
||||
Assert.Equal(3, found?.CqZone);
|
||||
Assert.Equal(6, found?.ItuZone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrefixWithoutOverrideKeepsTheEntityZone() =>
|
||||
Assert.Equal(5, File.Find("W1XYZ")?.CqZone);
|
||||
|
||||
[Fact]
|
||||
public void PortableStationIsLookedUpByItsPortablePrefix() =>
|
||||
Assert.Equal("3D2", File.Find("3D2/K1ABC")?.Entity.PrimaryPrefix);
|
||||
|
||||
[Fact]
|
||||
public void MaritimeMobileBelongsToNoEntity() =>
|
||||
Assert.Null(File.Find("K1ABC/MM"));
|
||||
|
||||
[Fact]
|
||||
public void LongitudeComesBackEastPositive() =>
|
||||
Assert.Equal(-91.87, File.Find("W1XYZ")!.Longitude, 2);
|
||||
|
||||
[Fact]
|
||||
public void UnknownCallHasNoEntity() =>
|
||||
Assert.Null(File.Find("XX9ZZZ"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("<html>404 not found</html>")]
|
||||
public void TextThatIsNotACountryFileIsRejected(string text) =>
|
||||
Assert.Throws<FormatException>(() => CountryFile.Parse(text));
|
||||
}
|
||||
46
tests/Nonemm.Core.Tests/GridSquareTests.cs
Normal file
46
tests/Nonemm.Core.Tests/GridSquareTests.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Core.Tests;
|
||||
|
||||
public class GridSquareTests
|
||||
{
|
||||
[Fact]
|
||||
public void FourCharacterSquareCentresOnTheSquare()
|
||||
{
|
||||
Assert.True(GridSquare.TryParse("JN88", out GridSquare grid));
|
||||
Assert.Equal(48.5, grid.Latitude, 3);
|
||||
Assert.Equal(17.0, grid.Longitude, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SixCharacterSquareIsMorePrecise()
|
||||
{
|
||||
Assert.True(GridSquare.TryParse("FN31pr", out GridSquare grid));
|
||||
Assert.Equal(41.7, grid.Latitude, 1);
|
||||
Assert.Equal(-72.7, grid.Longitude, 1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("JN8")]
|
||||
[InlineData("SN89")]
|
||||
[InlineData("JNXY")]
|
||||
public void MalformedLocatorIsRejected(string text) =>
|
||||
Assert.False(GridSquare.TryParse(text, out _));
|
||||
|
||||
[Fact]
|
||||
public void DistanceIsTheGreatCircleBetweenSquareCentres()
|
||||
{
|
||||
Assert.True(GridSquare.TryParse("JO99", out GridSquare jo99));
|
||||
Assert.True(GridSquare.TryParse("IO91", out GridSquare io91));
|
||||
Assert.InRange(jo99.DistanceTo(io91), 1525, 1540);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BearingIsZeroDueNorth()
|
||||
{
|
||||
Assert.True(GridSquare.TryParse("JO99", out GridSquare south));
|
||||
Assert.True(GridSquare.TryParse("JP90", out GridSquare north));
|
||||
Assert.Equal(0, south.BearingTo(north), 6);
|
||||
}
|
||||
}
|
||||
25
tests/Nonemm.Core.Tests/Nonemm.Core.Tests.csproj
Normal file
25
tests/Nonemm.Core.Tests/Nonemm.Core.Tests.csproj
Normal file
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
61
tests/Nonemm.Formats.Tests/AdifTests.cs
Normal file
61
tests/Nonemm.Formats.Tests/AdifTests.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Formats.Adif;
|
||||
|
||||
namespace Nonemm.Formats.Tests;
|
||||
|
||||
public class AdifTests
|
||||
{
|
||||
private static readonly StationInfo Me = new() { Callsign = "DL1ABC" };
|
||||
|
||||
private static Qso Contact() => new()
|
||||
{
|
||||
Id = Qso.NewId(),
|
||||
TimestampUtc = new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc),
|
||||
Call = Callsign.Parse("JA1XYZ"),
|
||||
Frequency = Frequency.FromKilohertz(14_025),
|
||||
Mode = Modes.Cw,
|
||||
ContestName = "CQWW",
|
||||
SentReport = "599",
|
||||
ReceivedReport = "579",
|
||||
Zone = 25,
|
||||
ReceivedNumber = 34,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ContactSurvivesAWriteAndRead()
|
||||
{
|
||||
string text = new AdifWriter(Me).Write([Contact()]);
|
||||
Qso read = AdifReader.Read(text).Single();
|
||||
|
||||
Assert.Equal("JA1XYZ", read.Call.Text);
|
||||
Assert.Equal(new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc), read.TimestampUtc);
|
||||
Assert.Equal(14_025_000, read.Frequency.Hertz);
|
||||
Assert.Equal(Modes.Cw, read.Mode);
|
||||
Assert.Equal("579", read.ReceivedReport);
|
||||
Assert.Equal(25, read.Zone);
|
||||
Assert.Equal(34, read.ReceivedNumber);
|
||||
Assert.Equal("CQWW", read.ContestName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderIsSkipped()
|
||||
{
|
||||
const string text = "Exported by something\r\n<ADIF_VER:5>3.1.4 <EOH>\r\n" +
|
||||
"<CALL:6>JA1XYZ <QSO_DATE:8>20260530 <TIME_ON:6>123456 <MODE:2>CW <EOR>";
|
||||
Assert.Equal("JA1XYZ", AdifReader.Read(text).Single().Call.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordWithNoCallIsDropped()
|
||||
{
|
||||
const string text = "<EOH>\r\n<QSO_DATE:8>20260530 <EOR>\r\n<CALL:6>JA1XYZ <EOR>";
|
||||
Assert.Single(AdifReader.Read(text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BandStandsInWhenThereIsNoFrequency()
|
||||
{
|
||||
const string text = "<EOH>\r\n<CALL:6>JA1XYZ <BAND:3>20m <EOR>";
|
||||
Assert.Equal(Bands.Band20M, AdifReader.Read(text).Single().Band);
|
||||
}
|
||||
}
|
||||
83
tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs
Normal file
83
tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Formats.Cabrillo;
|
||||
|
||||
namespace Nonemm.Formats.Tests;
|
||||
|
||||
public class CabrilloWriterTests
|
||||
{
|
||||
private static readonly StationInfo Me = new()
|
||||
{
|
||||
Callsign = "DL1ABC",
|
||||
CqZone = 14,
|
||||
Continent = "EU",
|
||||
CountryPrefix = "DL",
|
||||
};
|
||||
|
||||
private static Qso Contact(double kilohertz = 14_025, Mode? mode = null) => new()
|
||||
{
|
||||
Id = Qso.NewId(),
|
||||
TimestampUtc = new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc),
|
||||
Call = Callsign.Parse("JA1XYZ"),
|
||||
Frequency = Frequency.FromKilohertz(kilohertz),
|
||||
Mode = mode ?? Modes.Cw,
|
||||
ContestName = "CQWW",
|
||||
SentReport = "599",
|
||||
ReceivedReport = "599",
|
||||
Zone = 25,
|
||||
SentNumber = 12,
|
||||
ReceivedNumber = 34,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void QsoLineCarriesBothExchanges()
|
||||
{
|
||||
CabrilloWriter writer = new(new CqWorldWide(ModeCategory.Cw), Me);
|
||||
string line = writer.QsoLine(Contact());
|
||||
Assert.StartsWith("QSO: 14025 CW 2026-05-30 1234 DL1ABC", line);
|
||||
Assert.Contains("JA1XYZ", line);
|
||||
Assert.EndsWith("0", line);
|
||||
Assert.Contains(" 14 ", line);
|
||||
Assert.Contains(" 25 ", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WpxNumbersArePaddedToFour()
|
||||
{
|
||||
CabrilloWriter writer = new(new CqWpx(ModeCategory.Cw), Me);
|
||||
string line = writer.QsoLine(Contact());
|
||||
Assert.Contains("0012", line);
|
||||
Assert.Contains("0034", line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VhfContactsUseTheBandDesignator()
|
||||
{
|
||||
CabrilloWriter writer = new(new CqWorldWide(ModeCategory.Cw), Me);
|
||||
Assert.Contains(" 144 ", writer.QsoLine(Contact(144_200)));
|
||||
Assert.Contains(" 1.2G ", writer.QsoLine(Contact(1_296_100)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderAndFooterWrapTheContacts()
|
||||
{
|
||||
CabrilloWriter writer = new(new CqWorldWide(ModeCategory.Cw), Me);
|
||||
string log = writer.Write(
|
||||
new CabrilloHeader { Contest = "CQ-WW-CW", Callsign = "DL1ABC", ClaimedScore = 1234 },
|
||||
[Contact()]);
|
||||
Assert.StartsWith("START-OF-LOG: 3.0\r\n", log);
|
||||
Assert.Contains("CONTEST: CQ-WW-CW\r\n", log);
|
||||
Assert.Contains("CLAIMED-SCORE: 1234\r\n", log);
|
||||
Assert.EndsWith("END-OF-LOG:\r\n", log);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactsLeftOutOfTheClaimedScoreAreNotSent()
|
||||
{
|
||||
CabrilloWriter writer = new(new CqWorldWide(ModeCategory.Cw), Me);
|
||||
string log = writer.Write(
|
||||
new CabrilloHeader { Contest = "CQ-WW-CW", Callsign = "DL1ABC" },
|
||||
[Contact() with { IsClaimed = false }]);
|
||||
Assert.DoesNotContain("QSO:", log);
|
||||
}
|
||||
}
|
||||
27
tests/Nonemm.Formats.Tests/Nonemm.Formats.Tests.csproj
Normal file
27
tests/Nonemm.Formats.Tests/Nonemm.Formats.Tests.csproj
Normal file
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Nonemm.Formats\Nonemm.Formats.csproj" />
|
||||
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Nonemm.Contests\Nonemm.Contests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
26
tests/Nonemm.Storage.Tests/Nonemm.Storage.Tests.csproj
Normal file
26
tests/Nonemm.Storage.Tests/Nonemm.Storage.Tests.csproj
Normal file
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Nonemm.Storage\Nonemm.Storage.csproj" />
|
||||
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
145
tests/Nonemm.Storage.Tests/SqliteLogStoreTests.cs
Normal file
145
tests/Nonemm.Storage.Tests/SqliteLogStoreTests.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Storage.Tests;
|
||||
|
||||
public class SqliteLogStoreTests : IDisposable
|
||||
{
|
||||
private readonly string path = Path.Combine(Path.GetTempPath(), $"nonemm-{Guid.NewGuid():N}.s3db");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
File.Delete(path);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static Qso Contact(string call, DateTime at, int contestNumber = 1) => new()
|
||||
{
|
||||
Id = Qso.NewId(),
|
||||
TimestampUtc = at,
|
||||
Call = Callsign.Parse(call),
|
||||
Frequency = Frequency.FromKilohertz(14_025.5),
|
||||
Mode = Modes.Cw,
|
||||
ContestName = "CQWW",
|
||||
ContestNumber = contestNumber,
|
||||
SentReport = "599",
|
||||
ReceivedReport = "599",
|
||||
Zone = 14,
|
||||
Points = 3,
|
||||
IsMultiplier1 = true,
|
||||
CountryPrefix = "DL",
|
||||
Continent = "EU",
|
||||
WpxPrefix = "DL1",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ContactSurvivesARoundTrip()
|
||||
{
|
||||
DateTime at = new(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc);
|
||||
using SqliteLogStore store = SqliteLogStore.Open(path);
|
||||
Qso written = store.Add(Contact("DL1ABC", at));
|
||||
|
||||
Qso read = store.Qsos(1).Single();
|
||||
Assert.Equal(written.Id, read.Id);
|
||||
Assert.Equal("DL1ABC", read.Call.Text);
|
||||
Assert.Equal(at, read.TimestampUtc);
|
||||
Assert.Equal(14_025_500, read.Frequency.Hertz);
|
||||
Assert.Equal(Modes.Cw, read.Mode);
|
||||
Assert.Equal(14, read.Zone);
|
||||
Assert.Equal("EU", read.Continent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondContactInTheSameSecondMovesOnASecond()
|
||||
{
|
||||
DateTime at = new(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc);
|
||||
using SqliteLogStore store = SqliteLogStore.Open(path);
|
||||
store.Add(Contact("DL1ABC", at));
|
||||
Qso second = store.Add(Contact("DL1ABC", at));
|
||||
Assert.Equal(at.AddSeconds(1), second.TimestampUtc);
|
||||
Assert.Equal(2, store.Qsos(1).Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EditedContactKeepsItsIdentity()
|
||||
{
|
||||
using SqliteLogStore store = SqliteLogStore.Open(path);
|
||||
Qso written = store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc)));
|
||||
store.Update(written with { ReceivedReport = "579", Zone = 15 });
|
||||
|
||||
Qso read = store.Qsos(1).Single();
|
||||
Assert.Equal(written.Id, read.Id);
|
||||
Assert.Equal("579", read.ReceivedReport);
|
||||
Assert.Equal(15, read.Zone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeletedContactIsGone()
|
||||
{
|
||||
using SqliteLogStore store = SqliteLogStore.Open(path);
|
||||
Qso written = store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc)));
|
||||
store.Delete(written.Id);
|
||||
Assert.Empty(store.Qsos(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContestsAreNumberedInTurn()
|
||||
{
|
||||
using SqliteLogStore store = SqliteLogStore.Open(path);
|
||||
ContestInstance first = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWW" });
|
||||
ContestInstance second = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWPX" });
|
||||
Assert.Equal(1, first.ContestNumber);
|
||||
Assert.Equal(2, second.ContestNumber);
|
||||
Assert.Equal(2, store.Contests().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactsBelongToTheirOwnContest()
|
||||
{
|
||||
using SqliteLogStore store = SqliteLogStore.Open(path);
|
||||
store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc), contestNumber: 1));
|
||||
store.Add(Contact("DL2ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc), contestNumber: 2));
|
||||
Assert.Equal("DL1ABC", store.Qsos(1).Single().Call.Text);
|
||||
Assert.Equal("DL2ABC", store.Qsos(2).Single().Call.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewDatabaseCarriesN1mmTablesAndVersion()
|
||||
{
|
||||
using (SqliteLogStore store = SqliteLogStore.Open(path))
|
||||
{
|
||||
}
|
||||
|
||||
using SqliteConnection connection = new($"Data Source={path}");
|
||||
connection.Open();
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name";
|
||||
using SqliteDataReader row = command.ExecuteReader();
|
||||
List<string> tables = [];
|
||||
while (row.Read())
|
||||
{
|
||||
tables.Add(row.GetString(0));
|
||||
}
|
||||
Assert.Contains("DXLOG", tables);
|
||||
Assert.Contains("ContestInstance", tables);
|
||||
Assert.Contains("Contest", tables);
|
||||
Assert.Contains("Station", tables);
|
||||
|
||||
using SqliteCommand version = connection.CreateCommand();
|
||||
version.CommandText = "PRAGMA user_version";
|
||||
Assert.Equal(4L, version.ExecuteScalar());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactIdCannotBeChanged()
|
||||
{
|
||||
using SqliteLogStore store = SqliteLogStore.Open(path);
|
||||
store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc)));
|
||||
using SqliteConnection connection = new($"Data Source={path}");
|
||||
connection.Open();
|
||||
using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = "UPDATE DXLOG SET ID = 'zzzz'";
|
||||
Assert.Throws<SqliteException>(() => command.ExecuteNonQuery());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user