A Few Astronomical Examples in Kotlin

The website Open Notify provides a few URLs that return JSON data from NASA. There are currently three links supported:

Just for fun, this blog post uses Kotlin to access each one, transform the results in Kotlin data classes, and presents the results. In each case, since only HTTP GET requests are supported, the code uses the extension function readText that was added to the java.net.URL class. It also parses the resulting JSON data using Google’s GSON parser.

As usual, the code for these examples resides in the GitHub repository for my new book, Kotlin Cookbook.

People In Space

Consider the “number of people in space” service first. The JSON response takes the form:

{
  "message": "success",
  "number": NUMBER_OF_PEOPLE_IN_SPACE,
  "people": [
    {"name": NAME, "craft": SPACECRAFT_NAME},
    ...
  ]
}

The following Kotlin data classes match up with that JSON structure:

data class AstroResult(
    val message: String,
    val number: Int,
    val people: List<Assignment>
)

data class Assignment(
    val craft: String,
    val name: String
)

An Assignment connects a craft to an astronaut name, and the AstroResult has a String message (which out to be “success“), a number, and a list of assignments.

Going from the base URL to the AstroResult is easy enough. Just instantiate a Gson object and invoke its fromJson method:

Gson().fromJson(
    URL("http://api.open-notify.org/astros.json").readText(),
    AstroResult::class.java
)

GSON doesn’t know anything about Kotlin. It’s a Java library, so it expects to convert JSON strings into Java classes. The AstroResult::class.java syntax uses the double colon to get the KClass (the Kotlin class), whose java property returns the Java class.

This result could be assigned to a local variable for printing, but that’s what the scope functions like also or let are for. For example, to print the results, try this:

Gson().fromJson(
    URL("http://api.open-notify.org/astros.json").readText(),
        AstroResult::class.java
).also { astroResult ->
    println("There are ${astroResult.number} people in space:")
    astroResult.people.forEach { assignment ->
        println("\t${assignment.name} aboard ${assignment.craft}")
    }
}

As of December 2019, this prints:

There are 6 people in space:
    Christina Koch aboard ISS
    Alexander Skvortsov aboard ISS
    Luca Parmitano aboard ISS
    Andrew Morgan aboard ISS
    Oleg Skripochka aboard ISS
    Jessica Meir aboard ISS

Even simpler, though not as nicely structured, is to just drill down to the names and print them:

Gson().fromJson(
    URL("http://api.open-notify.org/astros.json").readText(),
    AstroResult::class.java
).people.map(Assignment::name)
    .also(::println)  // or use let instead of also

As a developer I knew once said, “My debugger is .also(::println)“.

For no good reason, you could create a class for the whole process, and why not make it executable at the same time?

class AstroRequest {
    companion object {
        private const val ASTRO_URL = 
            "http://api.open-notify.org/astros.json"
    }

    operator fun invoke(): AstroResult =
        Gson().fromJson(URL(ASTRO_URL).readText(), 
            AstroResult::class.java)
}

The URL is a constant, so you might as well say so. The operator function invoke means that you can use the “parentheses operator”, (), to execute an instance of this class, as the following test shows:

class AstroRequestTest {
    @Test
    fun `get people in space`() {
        val request = AstroRequest()
        val result = request()  // calls "invoke" function
        assertThat(result.message, `is`("success"))
        assertThat(result.number, `is`(greaterThanOrEqualTo(0)))
        assertThat(result.people.size, `is`(result.number))
    }
}

If you really want to go to extremes (and guarantee you get challenged in a code review), you can actually combine the instantiation and execution into one line:

val result = AstroRequest()()

which works, but please don’t do that. 🙂

Position of the ISS

Moving now to the service that returns the position of the International Space Station, the JSON form of the response is:

{
  "message": "success", 
  "timestamp": UNIX_TIME_STAMP, 
  "iss_position": {
    "latitude": CURRENT_LATITUDE, 
    "longitude": CURRENT_LONGITUDE
  }
}

Ah, the joys of the Unix time stamp. Fortunately, since I’m assuming we’re running on the JVM, there is a straightforward (but overly complicated) way to convert the timestamp into an instance of java.time.ZonedDateTime in your local time zone. Here are the resulting data classes:

data class IssPosition(
    val latitude: Double,
    val longitude: Double
)

data class IssResponse(
    val message: String,
    val iss_position: IssPosition,
    val timestamp: Long
) {
    val zdt: ZonedDateTime
        get() = ZonedDateTime.ofInstant(
            Instant.ofEpochSecond(timestamp),
            TimeZone.getDefault().toZoneId()
        )
}

The IssPosition class just combines the latitude and longitude, so there’s nothing special about that. The IssResponse class includes the message (again, “success”) and the ISS position. The property uses an underscore so no additional annotations are needed to do the JSON mapping. The fun part comes with the timestamp.

The timestamp may be identified as a UNIX timestamp, but as far as Kotlin is concerned it’s just a Long representing the number of seconds elapsed since the current epoch began on midnight, January 1, 1970, UTC. You can certainly use a function to convert the long to a ZonedDateTime, but since the result is just a dependent property, it seems appropriate to make it a property with a custom get method.

Taking the same object-oriented approach as before, here is a class to return either the entire response, or just the position:

class ProcessAstroData {
    companion object {
        const val url = "http://api.open-notify.org/iss-now.json"
    }

    fun getResponse(): IssResponse =
        Gson().fromJson(URL(url).readText(),
            IssResponse::class.java)

    fun getPosition() =
        getResponse().also {
            println("As of " + it.zdt.format(
        DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG)))
        }.iss_position
}

The getPosition function could just be written as getPosition().iss_position, but as a side effect it prints the time of the request.

To run this, use the following lines:

val demo = ProcessAstroData()
val (lat, lng) = demo.getPosition()
println("$lat deg N, $lng deg W")

If there was ever a class built for destructuring, it’s a wrapper for latitude and longitude. The current result is:

As of December 19, 2019 at 5:31:47 PM EST
49.0682 deg N, 160.2318 deg W

Overhead Pass Predictions

Moving finally to the overhead pass predictions for the ISS, the JSON output for that service looks like:

{
  "message": "success",
  "request": {
    "latitude": LATITUE,
    "longitude": LONGITUDE, 
    "altitude": ALTITUDE,
    "passes": NUMBER_OF_PASSES,
    "datetime": REQUEST_TIMESTAMP
  },
  "response": [
    {"risetime": TIMESTAMP, "duration": DURATION},
    ...
  ]
}

This time, however, there are input values required. The user has to supply the latitude and longitude, and can optionally supply an altitude and a number of passes (which defaults to five). According to the documentation, the parameters in the service are specified as lat, lon, alt, and n, respectively.

Once again using simple Long variables where the service is expecting a timestamp, the following data classes map to this structure:

data class OverheadResponse(
    val message: String,
    val request: OverheadRequest,
    val response: List<TimeAndDuration>
)

data class OverheadRequest(
    val latitude: Double,
    val longitude: Double,
    val altitude: Double,
    val passes: Int,
    val datetime: Long
)

data class TimeAndDuration(
    val risetime: Long,
    val duration: Long
)

Since there are several timestamp fields, this time use a function to convert the Long values to formatted strings:

fun formatTimestamp(timestamp: Long): String =
    ZonedDateTime.ofInstant(
        Instant.ofEpochSecond(timestamp),
        TimeZone.getDefault().toZoneId()
    ).format(DateTimeFormatter.ofLocalizedDateTime(
        FormatStyle.LONG))

Here is a class to do the work:

class Overhead {
    companion object {
        const val base = "http://api.open-notify.org/iss-pass.json"
    }

    fun getOverheadResponse(lat: Double, 
                            lng: Double,
                            alt: Double = 0.0,
                            num: Int = 5): OverheadResponse {
        val url = "$base?lat=$lat&lon=$lng&alt=$alt&n=$num"
        val json = URL(url).readText()
        return Gson().fromJson(json, OverheadResponse::class.java)
    }
}

Use optional parameters in the Kotlin function to represent the default values for the service. To get the overhead pass times, instantiate the class, get the response, and format them:

// Marlborough, CT
val latitude = 41.6314
val longitude = -72.4596

val output = Overhead().getOverheadResponse(latitude, longitude)
output.response.forEach {
    println(formatTimestamp(it.risetime))
}

The output at the time of this writing is:

December 20, 2019 at 5:42:26 AM EST
December 20, 2019 at 7:17:35 AM EST
December 20, 2019 at 8:55:03 AM EST
December 20, 2019 at 10:32:58 AM EST
December 20, 2019 at 12:10:04 PM EST

These examples used simple GET requests to access the free services and GSON to parse the resulting JSON data. Along the way it used data classes, companion objects, an executable class, optional parameters, and conversion of Unix timestamps to classes from the java.time package. Hopefully you’ll find something interesting in it. Or you can fire it into the sun. 🙂

2 responses to “A Few Astronomical Examples in Kotlin”

  1. That was a nice read, thank you.

  2. […] It makes use of Open Notify PeopleInSpace API to show list of people currently in space and also the position of the International Space Station (inspired by https://kousenit.org/2019/12/19/a-few-astronomical-examples-in-kotlin/)! […]

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Discover more from Stuff I've learned recently...

Subscribe now to keep reading and get access to the full archive.

Continue reading