geteq: A CLI for Earthquake Events
Motivation
Command Line Interfaces (CLIs) predate modern forms of interaction with a
computer, namely Graphical User Interfaces (GUIs). Currently, several UI
libraries exists to compose highly interactive webpages on browsers. Rather than
build a webpage with React components (or another UI library) that displays data
from an API endpoint, I wanted to try a different approach. I wanted to retrieve
earthquake records from USGS and I wanted to do it by
building a CLI tool in Go. I have this fascination with being able to query
earthquake events happening around the world and pipe that straight into the
terminal similar to quickly checking the weather via curl using
wttr.in. Having instant access to science from the command
line seems pretty cool and I still have this fascination with earthquakes since
my days as a structural engineer. Perhaps folks might be interested in
earthquakes if there is less context switching between applications because the
place to grep
words in files, check the calendar with cal
, or the time with
date
would be the same place to check the most recent earthquakes around the
world with geteq
.
See the repo here.
CLIs can be interpreted in two ways, as a conversation between a human and a
computer and as a conversation taking place between computers where two or more
CLI applications are passing data to each other like an assembly line or
conveyor belt, a form of Interprocess Communication (IPC). Thanks to Doug
McIlroy’s concept of Unix pipelines combined with Ken Thompson’s syntax of the
|
(pipe) operator, using the output from one command line application as input
into another command line application creates composable data processing
pipelines such that simple scripts can be progressively developed to perform
complex transformations of data.
My initial goals for this project weren’t that lofty, I started out with humble goals:
- Obtain a list of real-time earthquakes events and output the list to the terminal in a neatly formatted table. (a human-computer interaction)
- Output the list into other consumable formats such as CSV and JSON in case a user has more programmatic uses for the data (a computer-computer interaction)
That’s it really. Nothing too ambitious, but I ended up adding more features and learning more about developing CLIs as they grow in complexity later on.
Could this be done in curl? Yes, though I wanted to make crafting GET requests more programmatic. Why program it in Go? Honestly, I wanted learn how to build software in Go. Realistically, there a lot of different programming languages available to construct a CLI application along with different packages and frameworks within each programming language to choose from.
Flags Package
Building the CLI started out with the intention to only request real-time data
feeds. To USGS,
real-time meant querying earthquake records within 30 days from the current
time. At the time, the flag
package felt like an appropriate choice since I
was only looking to provide options to users to select specific data sets and
construct the associated URLs provided by USGS to make an HTTP request.
Very minimal flags were required to set up the program. I ended up only using three flags:
import "flag"
func main() {
formatFlag := flag.String("format", "human", "string - {human, json, csv}")
timeFlag := flag.String("time", "hour", "string - {hour, day, week, month}")
magFlag := flag.String("mag", "major", "string - {all, 1.0, 2.5, 4.5, major}")
flag.Parse()
// ... more code to request, process, and display the query to standard output
}
Each flag is intended fill-in a portion of the real-time feed URL such as requesting records for “earthquakes greater than magnitude 4.5 that happened within the past hour”. The rest of the program was spent supporting different output formats to standard output such as CSV, JSON, and a formatted table.
After running go build
, run ./geteq -h
for the program’s usage menu. The
usage menu comes free with the flag
package:
$ ./geteq -h
Usage of ./geteq:
-format string
string - {human, json, csv} (default "human")
-mag string
string - {all, 1.0, 2.5, 4.5, major} (default "major")
-time string
string - {hour, day, week, month} (default "month")
As a sample run, here’s a query for earthquake records with magnitudes greater than 1.0 within the past hour:
$ ./geteq --mag=1.0 --time=hour
Date-Time UTC+00:00 Mag Place Lat Long
2024-08-26 07:56:18 1.60 23 km SW of Ocotillo Wells, CA 32.98 -116.28
2024-08-26 07:55:06 1.17 5 km WNW of Garnet, CA 33.92 -116.60
2024-08-26 07:19:33 1.70 14 km S of Fox River, Alaska 59.73 -150.99
2024-08-26 07:07:40 1.40 5 km NW of Big Lake, Alaska 61.55 -150.02
2024-08-26 07:03:01 2.34 3 km SSW of Medicine Park, Oklahoma 34.70 -98.52
Expanding Beyond Real-time Feeds & Cobra (Subcommands all the way down…)
After implementing the real-time querying with flag
, I didn’t stop at
real-time records. I wanted more functionality! For example, what if I wanted to
go back more than 30 days? What if I wanted to select records occurring within a
specific 30 minute time window? Or what if I wanted to search for earthquake
event records bounded by any arbitrary magnitude range? What if I wanted more
detailed information about a specific earthquake event? Fortunately, there is a
historical catalog endpoint for
performing these kinds of searches. However, with the amount variations of the
search I wanted, it also meant handling a multitude of different parameters to
narrow or broaden the search space for earthquake records.
Enter Cobra. Cobra is a framework for building CLI applications in Go. Some of the features that make developing with Cobra appealing is the ease of scaffolding out the CLI application with nested subcommands and composing global flags such that their flag values can persist when running deeper child subcommands and their child subcommands and so on. Given that the historical catalog contains several methods for querying records, many more output formats for the request, and several search parameters, the Cobra framework seemed like an appropriate approach to extend the program’s functionality with room to grow the application in case I needed to add more commands in the future.
With Cobra, the next tasks were:
- Write the real-time feed functionality within Cobra’s CLI paradigm.
- Add new commands/subcommands for the historical catalog to query events into a list.
- Add commands to support the output of detailed information for a single event.
The overall breakdown of subcommands formed a tree structure with the main CLI
entry point, geteq
, at the root of the command tree:
geteq
|
|-realtime # subcommand for the original realtime feed CLI
|
|-fdsn # subcommand for the historical catalog CLI
|
|-query # subcommand for querying lists of events
|
|-event # subcommand for retrieving single event details
A subcommand can be made a child to a parent command by adding the subcommand to
a parent. For example, here’s the code to assign realtime
to geteq
in
cmd/realtime.go
:
func init() {
rootCmd.AddCommand(realtimeCmd) // where rootCmd is the 'geteq' cmd
}
There are several flags that are common to multiple subcommands. Flags such as
magnitudes, magnitude ranges, datetime ranges, and the output format make these
great candidates for persistent flags. As a result, I ended up declaring
persistent flags within the cmd/fdsn.go
:
// Flag variables will persist to child subcommands of the `fdsn` cmd
var FDSNMagFlag string
var FDSNDateTimeFlag string
var FDSNFormatFlag string
func init() {
rootCmd.AddCommand(fdsnCmd)
fdsnCmd.PersistentFlags().StringVarP(&FDSNMagFlag, "magnitude", "m", "", `magnitude or magnitude range (e.g. low[,high] "2.3,4.5")`)
fdsnCmd.PersistentFlags().StringVarP(&FDSNDateTimeFlag, "time", "t", "", `UTC datetime range (e.g. startdate,enddate "2024-09-20,2024-09-21")`)
fdsnCmd.PersistentFlags().StringVarP(&FDSNFormatFlag, "output", "o", "table", "output format options: {csv, json, table, text}")
}
Parsing Flag Values & Unit Testing
Magnitude, datetime, and format flags take string values from users. User
defined string values has its own set of challenges to address. Go provides a
package in the standard library to run unit tests aptly named testing
.
I looked at Go’s standard library test files as a starting point for my initial
set of writing tests. A Go test file can be identified with the suffix
_test.go
appended to the end of a .go
file of the same name. Admittedly, I’m
still working out the best way to organize and write tests, but I wanted to get
some level of testing on the books.
Here’s a snippet example of the unit test file (logic/requestutil_test.go
) for
the file logic/requestutil.go
:
import "testing"
// A data type for storing a test case
type MagnitudeTest struct {
in, outBegin, outEnd string
err error
}
// Test functions begin with prefix 'Test'
type TestExtractMagnitude(t *testing.T) {
// creating a slice of test cases within the function
tests := []MagnitudeTest{
{"4.0", "4.0", "4.0", nil},
// more tests
}
for _, mTest := range tests {
begin, end, err := extractMegnitude(mTest.in)
if begin != mTest.outBegin || end != mTest.outEnd || err != mTest.err {
t.Errorf("extractMagnitude(%q) = %v %v %v; want %v %v %v", test.in, begin, end, err, test.outBegin, test.outEnd, test.err)
}
}
}
To run the unit tests, run go test
in the directory with the _test.go
file.
Putting It All Together
Running a real-time query with the updated CLI with a query similar the original
flag
version emits the following:
$ ./geteq realtime -m 1.0 -t hour
EventId Date-Time UTC+00:00 Mag Place Lat Long
hv74427297 2024-08-27 19:13:58 2.16 12 km SSE of Fern Forest, Hawaii 19.37 -155.07
ci40716159 2024-08-27 18:38:36 1.65 16 km NNE of Apple Valley, CA 34.64 -117.11
ok2024qwby 2024-08-27 18:33:41 1.44 5 km SSW of Glencoe, Oklahoma 36.18 -96.96
nc75054121 2024-08-27 18:19:22 1.03 2 km N of The Geysers, CA 38.79 -122.76
Adding the EventId
field such that users could now refer to the id when
looking up a specific event. Looking up event details emits the following to
standard output:
$ ./geteq fdsn query event hv74427297
Single Event Details
--------------------
Event Id: hv74427297
Review Status: automatic
Time (UTC+00:00): 2024-08-27 19:13:58
Updated Time (UTC+00:00): 2024-08-27 19:16:53
Time Zone Offset: 0
Place: 12 km SSE of Fern Forest, Hawaii
Magnitude: 2.16
Magnitude Type: ml
Depth: 2.59 km
Latitude: 19.37
Longitude: -155.07
Horizontal distance (in deg) from epicenter to the nearest station: 0.036910
Largest Azimuthal Gap between stations (deg): 156.00
Root-Mean-Square (RMS) Travel Time Residual (sec): 0.340
Seismic Event Type: earthquake
PAGER Alert Level:
Number of Felt Reports of DYFI: 0
Intensity Level: 0.00
Modified Mercalli Intensity (MMI): 0.00
Event Significance: 72
Large Event in Oceanic Region: 0
Number of Stations used to determine location: 47
Associated Event Ids: ,hv74427297,
Network Contributors: ,hv,
Preferred Contributor Id: hv
Event Id Code: 74427297
Room For Improvement & What I Learned
Like all things with software, I know that there is a lot of room for improvement. For instance, other kinds of search parameters need to be implemented such as the option to search for earthquakes in a bounded region around a specific latitude-longitude pair and basic sorting of events in ascending or descending orders via event dates and/or magnitudes. Also, deciding whether these types of search parameters should be turned into flags or subcommands makes the most sense, aesthetically pleasing human readable output, the list continues…
Overall, I’m satisfied with how this turned out. What started out as a short app turned into an excercise in CLI scaffolding with a framework, adding features while trying minimizing complexity, progressively building and running tests on user inputs, and providing opportunities grow the application in case I want to work on it later.