geteq: A CLI for Earthquake Events

Posted on Aug 27, 2024
tl;dr: I wrote a CLI in Go that retrieves earthquake records from the web and displays it to the terminal.

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:

  1. Obtain a list of real-time earthquakes events and output the list to the terminal in a neatly formatted table. (a human-computer interaction)
  2. 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:

  1. Write the real-time feed functionality within Cobra’s CLI paradigm.
  2. Add new commands/subcommands for the historical catalog to query events into a list.
  3. 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.