Skip to content

Commit 71bb4c8

Browse files
committed
Add --field-order option, add practical examples to README
1 parent 8e6ccd2 commit 71bb4c8

File tree

15 files changed

+246
-61
lines changed

15 files changed

+246
-61
lines changed

README.md

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,125 @@ adifmt fix log1.adi \
118118
creates a file named `minimal.csv` with just the date, time, and callsign from
119119
each record in the input file `log1.adi`.
120120
121+
## Practical examples
122+
123+
The following examples transform data into a format expected by a particular
124+
program or ham radio activity. For details on how they work, see documentation
125+
for individual commands or formats below. For contest log examples, see the
126+
[Cabrillo](#cabrillo) section. Contributions of useful pipelines are welcome.
127+
128+
### Add station and location to a log
129+
130+
This example uses `edit` to add several fields to a POTA log saved in CSV
131+
format. It then uses `fix` to remove the `:` from the time, transform the
132+
decimal (GPS) latitude and longitude to ADIF sexagesimal format and transforms
133+
`USA` and `CAN` country abbreviations to DXCC entity numbers. `flatten` makes
134+
two copies of each record, one for park `US-0791` and one for park `US-4567`.
135+
`infer` then sets the band from the frequency, grid square (Maidenhead locator)
136+
based on the latitude and longitude, `STATION_CALLSIGN` field to the `OPERATOR`
137+
field, and `SIG` and `SIG_INFO` from the `POTA_REF` field. (POTA doesn't
138+
require the country or latitude/longitude fields; they're included for
139+
illustration.) The input log might look like this:
140+
141+
```csv
142+
TIME_ON,FREQ,MODE,CALL,STATE,COUNTRY
143+
12:34,7.012,CW,W1AW,CT,USA
144+
12:56,14.234,SSB,VA1XYZ,NS,CAN
145+
```
146+
147+
```sh
148+
adiifmt edit mylog.csv \
149+
--add qso_date=20240704 \
150+
--add operator=WT0RJ \
151+
--add my_pota_ref=US-0791,US-4567 \
152+
--add my_state=DC --add my_country=USA \
153+
--add my_lat=38.899736 --add my_lon=-77.063331 \
154+
| adifmt fix \
155+
| adifmt flatten --fields pota_ref,my_pota_ref \
156+
| adifmt infer --fields band,my_gridsquare,station_callsign
157+
```
158+
159+
### ADIF to SOTA CSV
160+
161+
This example uses `find` to filter out any records which don’t have `SOTA_REF`
162+
or `MY_SOTA_REF` fields, `edit` to add a `V2` field to each record (required by
163+
the SOTA uploader), `select` to output only the fields expected by the SOTA
164+
uploader and in the right order, `validate` to ensure fields are present and
165+
correctly formatted, and `save --csv-omit-header` to create a file with just
166+
the records, no file header. If your log lacks frequencies, replace the `freq`
167+
field with `band`. (Note that the SOTA uploader now accepts ADIF files, so you
168+
could just use the `find` command and upload directly. This example may be
169+
useful if the data need to be further transformed or imported by a SOTA data
170+
analysis program.)
171+
172+
```sh
173+
SOTA_CSV_ORDER=version,station_callsign,my_sota_ref,qso_date,time_on,freq,mode,call,sota_ref,comment
174+
adifmt find mylog.adi --if-not 'my_sota_ref=' --or-if-not 'sota_ref=' \
175+
| adifmt edit --set version=V2 \
176+
| adifmt select --fields $SOTA_CSV_ORDER \
177+
| adifmt validate --required-fields station_callsign,qso_date,time_on,freq,mode,call \
178+
| adifmt save --csv-omit-header --field-order $SOTA_CSV_ORDER sotalog.csv
179+
```
180+
181+
The variable assignment syntax for `SOTA_CSV_ORDER` works on Mac and Linux. On
182+
Windows PowerShell assign the variable as `$SOTA_CSV_ORDER =
183+
version,station_callsign,...`. On Windows cmd.exe, assign it as
184+
`set SOTA_CSV_ORDER = version,station_callsign,...` and reference it as
185+
`%SOTA_CSV_ORDER%` rather than the `$` prefix.
186+
187+
### Filter WARC bands
188+
189+
This example uses `infer` to set the band from the frequency if the former
190+
isn’t set. It then uses `find` to filter out any contacts made on the
191+
[WARC bands](https://en.wikipedia.org/wiki/WARC_bands): 12, 17, 30, and 60
192+
meters. Contesting is not allowed on those bands, so this is useful when
193+
preparing a contest submission from a general station log where contacts may
194+
have been made on bands not part of the contest.
195+
196+
```sh
197+
adifmt infer --fields band mylog.adi \
198+
| adifmt find --if-not 'band=60m|30m|17m|12m'
199+
```
200+
201+
### Set mode from frequency (U.S. band plan)
202+
203+
This example sets the mode and submode based on the frequency, according to the
204+
U.S. band plan. It assumes that CW and SSB are the only modes in use (no FM,
205+
AM, or digital), but can be extended if there are frequency ranges that you use
206+
exclusively for one mode. `edit --add` will not overwrite the mode if it
207+
already has a value (`edit --set` would force the new value).
208+
209+
```sh
210+
# US HF SSB (overlaps SSTV & AM) 80m: 3.6:4, 40m: 7.125:7.3, 20m:14.15:14.35,
211+
# 17m:18.11:18.168, 15m:21.2:21.45, 12m:24.93 to 24.99, 10m: 28.3:29
212+
# 6m 50.1:50.3 is CW/SSB, SSB calling 60.125, assume 60.120+ is SSB
213+
adifmt edit --if 'freq>3.6' --if 'freq<4' \
214+
--or-if 'freq>7.125' --if 'freq<=7.3' \
215+
--or-if 'freq>=14.15' --if 'freq<14.35' \
216+
--or-if 'freq>=18.11' --if 'freq<18.168' \
217+
--or-if 'freq>=21.2' --if 'freq<21.45' \
218+
--or-if 'freq>=24.93' --if 'freq<24.99' \
219+
--or-if 'freq>=28.3' --if 'freq<29' \
220+
--or-if 'freq>=50.12' --if 'freq<50.3' \
221+
--add mode=SSB |\
222+
223+
# US HF CW (some digital could occur) low end of the band, below FT8 and friends
224+
adifmt edit --if 'freq>3.5' --if 'freq<3.7' \
225+
--or-if 'freq>7' --if 'freq<7.07' \
226+
--or-if 'freq>10.1' --if 'freq<10.3' \
227+
--or-if 'freq>14' --if 'freq<14.07' \
228+
--or-if 'freq>18.068' --if 'freq<18.1' \
229+
--or-if 'freq>21' --if 'freq<21.07' \
230+
--or-if 'freq>24.89' --if 'freq<14.91' \
231+
--or-if 'freq>28' --if 'freq<28.07' \
232+
--or-if 'freq>50.1' --if 'freq<50.12' \
233+
--add mode=CW |\
234+
235+
# SSB is usually LSB on 40m and below except 60m, USB on 20m and above
236+
adifmt edit --if mode=SSB --if 'freq<8' --if-not band=60m --add submode=LSB |\
237+
adifmt edit --if mode=SSB --if 'freq>=14' --or-if band=60m --add submode=USB
238+
```
239+
121240
## Features
122241
123242
### Input/Output formats
@@ -559,7 +678,7 @@ and a change:
559678
560679
```sh
561680
adifmt cat mylog.adi \
562-
| adifmt edit --if 'mode=SSB' --if 'band>=20m' --add 'submode=USB' \
681+
| adifmt edit --if 'mode=SSB' --if 'band>=20m' --or-if 'band=60m' --add 'submode=USB' \
563682
| adifmt edit --if 'mode=SSB' --if 'band=40m|80m|160m' --add 'submode=LSB' \
564683
| adifmt save fixed_sideband.adi
565684
```
@@ -613,8 +732,8 @@ contacts on the border of a square as separate:
613732
614733
```sh
615734
adifmt flatten --fields VUCC_GRIDS --output tsv \
616-
| adifmt select --fields VUCC_GRIDS --output tsv \
617-
| tail +2 | sort | uniq -c
735+
| adifmt select --fields VUCC_GRIDS --output tsv --tsv-omit-header \
736+
| sort | uniq -c
618737
```
619738
620739
The `flatten` command will turn
@@ -762,8 +881,8 @@ find duplicate QSOs by date, band, and mode, use
762881
[uniq](https://man7.org/linux/man-pages/man1/uniq.1.html):
763882
764883
```sh
765-
adifmt select --fields call,qso_date,band,mode --output tsv mylog.adi \
766-
| tail +2 | sort | uniq -d
884+
adifmt select --fields call,qso_date,band,mode --output tsv --tsv-omit-header mylog.adi \
885+
| sort | uniq -d
767886
```
768887
769888
This is similar to a SQL `SELECT` clause, except it cannot (yet?) transform the
@@ -846,6 +965,7 @@ Features I plan to add:
846965
the same callsign on the same band with the same mode on the same Zulu day
847966
and the same `MY_SIG_INFO` value.
848967
* Option for `save` to append records to an existing ADIF file.
968+
* [FLE (fast log entry)](https://df3cb.com/fle/documentation/) format support.
849969
* Count the total number of records or the number of distinct values of a
850970
field. (The total number of records can currently be counted with
851971
`--output=tsv --tsv-omit-header` and piping the output to `wc -l`.) This

adifmt/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ func buildContext(fs *flag.FlagSet, prepare func(l *adif.Logfile)) *cmd.Context
146146

147147
// General flags
148148
fmtopts := "options: " + strings.Join(adif.FormatNames(), ", ")
149+
fs.Var(&ctx.FieldOrder, "field-order", "Comma-separated `field` order for output (repeatable)")
149150
fs.Var(&ctx.InputFormat, "input",
150151
"input `format` when it cannot be inferred from file extension\n"+fmtopts)
151152
fs.Var(&ctx.OutputFormat, "output",

adifmt/testdata/sota_csv.txtar

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# ADI to CSV with field order matching the SOTA uploader expectations.
2+
# This pipeline is an example in README.md
3+
exec adifmt find mylog.adi --if-not 'my_sota_ref=' --or-if-not 'sota_ref='
4+
! stderr .
5+
stdin stdout
6+
exec adifmt edit --set version=V2
7+
! stderr .
8+
stdin stdout
9+
exec adifmt select --fields version,station_callsign,my_sota_ref,qso_date,time_on,freq,mode,call,sota_ref,comment
10+
! stderr .
11+
stdin stdout
12+
exec adifmt validate --required-fields station_callsign,qso_date,time_on,freq,mode,call
13+
! stderr .
14+
stdin stdout
15+
exec adifmt save --csv-omit-header --field-order version,station_callsign,my_sota_ref,qso_date,time_on,freq,mode,call,sota_ref,comment sotalog.csv
16+
cmp sotalog.csv expected.csv
17+
! stdout .
18+
stderr 'Wrote 3 records to sotalog.csv'
19+
20+
-- mylog.adi --
21+
<ADIF_VER:5>3.1.4 <CREATED_TIMESTAMP:15>23450607 080910 <PROGRAMID:6>adifmt <PROGRAMVERSION:7>(devel) <EOH>
22+
SOTA activator
23+
<QSO_DATE:8>20200101 <TIME_ON:4>0111 <MODE:2>FM <BAND:2>2m <FREQ:6>146.52 <CALL:3>K1A <STATE:2>CT <STATION_CALLSIGN:4>W1AW <MY_SOTA_REF:9>W1/MB-009 <MY_STATE:2>MA <COMMENT:24>Good signal, clear audio <EOR>
24+
Summit-to-summit
25+
<QSO_DATE:8>20210202 <TIME_ON:4>0222 <MODE:2>CW <FREQ:7>21.0123 <BAND:3>15m <CALL:3>W2B <STATE:2>CA <STATION_CALLSIGN:4>W1AW <RST_SENT:3>479 <RST_RCVD:3>559 <SOTA_REF:9>W6/SN-001 <MY_SOTA_REF:10>W4C/CM-009 <MY_STATE:2>NC <EOR>
26+
Not a SOTA contact
27+
<QSO_DATE:8>20220303 <TIME_ON:4>0333 <MODE:3>SSB <BAND:3>40m <FREQ:5>7.200 <CALL:3>K3C <STATE:2>PA <STATION_CALLSIGN:4>W1AW <MY_STATE:2>CT <EOR>
28+
SOTA chaser
29+
<QSO_DATE:8>20230404 <TIME_ON:4>0444 <MODE:3>FT8 <FREQ:6>14.074 <CALL:6>W4D/9H <STATION_CALLSIGN:4>W1AW <RST_SENT:3>-6 <RST_RCVD:3>-10 <SOTA_REF:9>9H/MA-001 <MY_STATE:2>CT <EOR>
30+
-- expected.csv --
31+
V2,W1AW,W1/MB-009,20200101,0111,146.52,FM,K1A,,"Good signal, clear audio"
32+
V2,W1AW,W4C/CM-009,20210202,0222,21.0123,CW,W2B,W6/SN-001,
33+
V2,W1AW,,20230404,0444,14.074,FT8,W4D/9H,9H/MA-001,

cmd/cat.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,14 @@
1414

1515
package cmd
1616

17-
import (
18-
"github.com/flwyd/adif-multitool/adif"
19-
)
20-
2117
var Cat = Command{Name: "cat", Run: runCat,
2218
Description: "Concatenate all input files to standard output"}
2319

2420
func runCat(ctx *Context, args []string) error {
25-
acc := accumulator{Out: adif.NewLogfile(), Ctx: ctx}
21+
acc, err := newAccumulator(ctx)
22+
if err != nil {
23+
return err
24+
}
2625
for _, f := range filesOrStdin(args) {
2726
l, err := acc.read(f)
2827
if err != nil {

cmd/context.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type Context struct {
3535
Out io.Writer
3636
Locale language.Tag
3737
CommandCtx any
38+
FieldOrder FieldList
3839
UserdefFields UserdefFieldList
3940
SuppressAppHeaders bool
4041
Prepare func(*adif.Logfile)

cmd/edit.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,18 +88,20 @@ func runEdit(ctx *Context, args []string) error {
8888
toTz := cctx.ToZone.Get()
8989
adjustTz := fromTz.String() != toTz.String()
9090
cond := cctx.Cond.Get()
91-
out := adif.NewLogfile()
92-
acc := accumulator{Out: out, Ctx: ctx}
91+
acc, err := newAccumulator(ctx)
92+
if err != nil {
93+
return err
94+
}
9395
for _, f := range filesOrStdin(args) {
9496
l, err := acc.read(f)
9597
if err != nil {
9698
return err
9799
}
98-
updateFieldOrder(out, l.FieldOrder)
100+
updateFieldOrder(acc.Out, l.FieldOrder)
99101
for _, r := range l.Records {
100102
eval := recordEvalContext{record: r, lang: ctx.Locale}
101103
if !cond.Evaluate(eval) {
102-
out.AddRecord(r) // edit condition doesn't match, pass through
104+
acc.Out.AddRecord(r) // edit condition doesn't match, pass through
103105
continue
104106
}
105107
seen := make(map[string]string)
@@ -155,14 +157,14 @@ func runEdit(ctx *Context, args []string) error {
155157
return fmt.Errorf("could not adjust time zone: %w", err)
156158
}
157159
}
158-
out.AddRecord(rec)
160+
acc.Out.AddRecord(rec)
159161
}
160162
}
161163
}
162164
if err := acc.prepare(); err != nil {
163165
return err
164166
}
165-
return write(ctx, out)
167+
return write(ctx, acc.Out)
166168
}
167169

168170
func adjustTimeZone(r *adif.Record, from, to *time.Location) error {

cmd/find.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@
1414

1515
package cmd
1616

17-
import (
18-
"github.com/flwyd/adif-multitool/adif"
19-
)
20-
2117
var Find = Command{Name: "find", Run: runFind, Help: helpFind,
2218
Description: "Include only records matching a condition"}
2319

@@ -56,23 +52,25 @@ Use quotes so operators are not treated as special shell characters:
5652
func runFind(ctx *Context, args []string) error {
5753
cctx := ctx.CommandCtx.(*FindContext)
5854
cond := cctx.Cond.Get()
59-
out := adif.NewLogfile()
60-
acc := accumulator{Out: out, Ctx: ctx}
55+
acc, err := newAccumulator(ctx)
56+
if err != nil {
57+
return err
58+
}
6159
for _, f := range filesOrStdin(args) {
6260
l, err := acc.read(f)
6361
if err != nil {
6462
return err
6563
}
66-
updateFieldOrder(out, l.FieldOrder)
64+
updateFieldOrder(acc.Out, l.FieldOrder)
6765
for _, r := range l.Records {
6866
eval := recordEvalContext{record: r, lang: ctx.Locale}
6967
if cond.Evaluate(eval) {
70-
out.AddRecord(r)
68+
acc.Out.AddRecord(r)
7169
}
7270
}
7371
}
7472
if err := acc.prepare(); err != nil {
7573
return err
7674
}
77-
return write(ctx, out)
75+
return write(ctx, acc.Out)
7876
}

cmd/fix.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,32 +42,33 @@ func helpFix() string {
4242
}
4343

4444
func runFix(ctx *Context, args []string) error {
45-
// TODO add any needed flags
46-
out := adif.NewLogfile()
47-
acc := accumulator{Out: out, Ctx: ctx}
45+
acc, err := newAccumulator(ctx)
46+
if err != nil {
47+
return err
48+
}
4849
for _, f := range filesOrStdin(args) {
4950
l, err := acc.read(f)
5051
if err != nil {
5152
return err
5253
}
53-
updateFieldOrder(out, l.FieldOrder)
54+
updateFieldOrder(acc.Out, l.FieldOrder)
5455
for _, rec := range l.Records {
55-
out.AddRecord(fixRecord(rec, l))
56+
acc.Out.AddRecord(fixRecord(rec, l))
5657
}
5758
}
5859
if err := acc.prepare(); err != nil {
5960
return err
6061
}
6162
// fix again in case userdef fields were added
62-
for _, r := range out.Records {
63+
for _, r := range acc.Out.Records {
6364
for _, f := range r.Fields() {
64-
ff := fixField(f, r, out)
65+
ff := fixField(f, r, acc.Out)
6566
if f != ff {
6667
r.Set(ff)
6768
}
6869
}
6970
}
70-
return write(ctx, out)
71+
return write(ctx, acc.Out)
7172
}
7273

7374
func fixRecord(r *adif.Record, l *adif.Logfile) *adif.Record {

cmd/flatten.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,10 @@ func runFlatten(ctx *Context, args []string) error {
6060
delims[n] = d
6161
}
6262

63-
out := adif.NewLogfile()
64-
acc := accumulator{Out: out, Ctx: ctx}
63+
acc, err := newAccumulator(ctx)
64+
if err != nil {
65+
return err
66+
}
6567
for _, f := range filesOrStdin(args) {
6668
l, err := acc.read(f)
6769
if err != nil {
@@ -90,14 +92,14 @@ func runFlatten(ctx *Context, args []string) error {
9092
}
9193
}
9294
for _, e := range expn {
93-
out.AddRecord(e)
95+
acc.Out.AddRecord(e)
9496
}
9597
}
9698
}
9799
if err := acc.prepare(); err != nil {
98100
return err
99101
}
100-
return write(ctx, out)
102+
return write(ctx, acc.Out)
101103
}
102104

103105
var typeDelims = map[spec.DataType]string{

0 commit comments

Comments
 (0)