Browse Source

Added forecast plot

Vadik Sirekanyan 4 years ago
parent
commit
84966b8327

+ 3 - 0
build.gradle.kts

@@ -22,6 +22,9 @@ dependencies {
     implementation("io.ktor:ktor-client-cio:1.4.0")
     implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.0")
     implementation("org.jetbrains.exposed:exposed-jdbc:0.28.1")
+    implementation("org.jetbrains.lets-plot:lets-plot-common:2.1.0")
+    implementation("org.jetbrains.lets-plot:lets-plot-image-export:2.1.0")
+    implementation("org.jetbrains.lets-plot:lets-plot-kotlin-jvm:3.0.2")
     implementation("org.postgresql:postgresql:42.2.18")
     implementation("org.slf4j:slf4j-simple:1.7.30")
     testImplementation("junit:junit:4.13")

+ 18 - 0
src/main/kotlin/com/sirekanyan/andersrobot/AndersRobot.kt

@@ -1,16 +1,19 @@
 package com.sirekanyan.andersrobot
 
+import com.sirekanyan.andersrobot.api.Forecast
 import com.sirekanyan.andersrobot.api.WeatherApi
 import com.sirekanyan.andersrobot.config.Config
 import com.sirekanyan.andersrobot.config.ConfigKey.*
 import com.sirekanyan.andersrobot.extensions.*
 import com.sirekanyan.andersrobot.repository.CityRepositoryImpl
 import com.sirekanyan.andersrobot.repository.supportedLanguages
+import jetbrains.letsPlot.export.ggsave
 import org.telegram.telegrambots.bots.DefaultAbsSender
 import org.telegram.telegrambots.bots.DefaultBotOptions
 import org.telegram.telegrambots.meta.api.objects.Update
 import org.telegram.telegrambots.meta.generics.LongPollingBot
 import org.telegram.telegrambots.util.WebhookUtils
+import java.io.File
 
 private const val DEFAULT_CITY_ID = 524901L // Moscow
 
@@ -43,6 +46,7 @@ class AndersRobot : DefaultAbsSender(DefaultBotOptions()), LongPollingBot {
         val isBetterAccuracy = message.chatId == 314085103L || message.chatId == adminId
         val accuracy = if (isBetterAccuracy) 1 else 0
         val cityCommand = getCityCommand(message.text)
+        val forecastCommand = getForecastCommand(message.text)
         val addCityCommand = getAddCityCommand(message.text)
         val delCityCommand = getDelCityCommand(message.text)
         when {
@@ -62,6 +66,14 @@ class AndersRobot : DefaultAbsSender(DefaultBotOptions()), LongPollingBot {
                     sendWeather(chatId, weather, accuracy)
                 }
             }
+            !forecastCommand.isNullOrEmpty() -> {
+                val forecast = api.getForecast(forecastCommand, language)
+                if (forecast == null) {
+                    sendText(chatId, "Не знаю такого города")
+                } else {
+                    showForecast(chatId, forecast)
+                }
+            }
             !addCityCommand.isNullOrEmpty() -> {
                 val weather = api.getWeather(addCityCommand, language)
                 if (weather == null) {
@@ -96,6 +108,12 @@ class AndersRobot : DefaultAbsSender(DefaultBotOptions()), LongPollingBot {
         sendText(chatId, temperatures.joinToString("\n") { it.format(accuracy) })
     }
 
+    private fun showForecast(chatId: Long, forecast: Forecast) {
+        val city = forecast.city.name
+        ggsave(plotForecast(forecast), "$city.png")
+        sendPhoto(chatId, File("lets-plot-images/$city.png"))
+    }
+
     override fun clearWebhook() {
         logInfo("Cleared.")
         WebhookUtils.clearWebhook(this)

+ 65 - 0
src/main/kotlin/com/sirekanyan/andersrobot/LetsPlot.kt

@@ -0,0 +1,65 @@
+package com.sirekanyan.andersrobot
+
+import com.sirekanyan.andersrobot.api.Forecast
+import jetbrains.datalore.base.values.Color
+import jetbrains.letsPlot.geom.geomHLine
+import jetbrains.letsPlot.geom.geomSmooth
+import jetbrains.letsPlot.geom.geomVLine
+import jetbrains.letsPlot.ggsize
+import jetbrains.letsPlot.intern.Plot
+import jetbrains.letsPlot.label.ggtitle
+import jetbrains.letsPlot.letsPlot
+import jetbrains.letsPlot.scale.scaleXContinuous
+import jetbrains.letsPlot.scale.scaleYContinuous
+import java.time.LocalDateTime
+import java.time.LocalTime
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+
+fun plotForecast(forecast: Forecast): Plot {
+    val offset = ZoneOffset.ofTotalSeconds(forecast.city.timezone)
+    val values = forecast.list.take(33)
+    val xValues = values.map { it.dt }
+    val yValues = values.map { it.main.temp }
+    val xBreaks = xBreaks(xValues, offset)
+    val yBreaks = yBreaks(yValues)
+    val xLabels = xBreaks.map {
+        LocalDateTime.ofEpochSecond(it, 0, offset)
+            .format(DateTimeFormatter.ofPattern("MMM d"))
+    }
+    return letsPlot(mapOf("x" to xValues, "y" to yValues)) { x = "x"; y = "y" } +
+            ggsize(400, 250) +
+            ggtitle(forecast.city.name) +
+            geomSmooth(method = "loess", se = false, span = 2.0 / values.size, color = Color.BLUE) +
+            scaleXContinuous("", breaks = xBreaks, labels = xLabels) +
+            scaleYContinuous("", breaks = yBreaks, format = "{d}°C") +
+            geomVLine(data = mapOf("x" to xBreaks), linetype = "dotted") { xintercept = "x" } +
+            geomHLine(data = mapOf("y" to yBreaks), linetype = "dotted", color = Color.GRAY) { yintercept = "y" }
+}
+
+private fun xBreaks(times: List<Long>, offset: ZoneOffset): List<Long> {
+    val start = times.minOrNull()!!.toLocalDate(offset)
+    val end = times.maxOrNull()!!.toLocalDate(offset)
+    val breaks = mutableListOf<Long>()
+    var b = start.plusDays(1)
+    while (b <= end) {
+        breaks.add(b.toEpochSecond(LocalTime.MIN, offset))
+        b = b.plusDays(1)
+    }
+    return breaks
+}
+
+private fun yBreaks(temps: List<Double>): List<Int> {
+    val start = (temps.minOrNull()!!.toInt() / 5 - 1) * 5
+    val end = (temps.maxOrNull()!!.toInt() / 5 + 1) * 5
+    val breaks = mutableListOf<Int>()
+    var b = start + 5
+    while (b <= end) {
+        breaks.add(b)
+        b += 5
+    }
+    return breaks
+}
+
+private fun Long.toLocalDate(offset: ZoneOffset) =
+    LocalDateTime.ofEpochSecond(this, 0, offset).toLocalDate()

+ 14 - 0
src/main/kotlin/com/sirekanyan/andersrobot/api/Forecast.kt

@@ -0,0 +1,14 @@
+package com.sirekanyan.andersrobot.api
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Forecast(val cnt: Int, val list: List<ForecastItem>, val city: City) {
+
+    @Serializable
+    data class ForecastItem(val dt: Long, val main: MainInfo)
+
+    @Serializable
+    data class City(val name: String, val timezone: Int)
+
+}

+ 6 - 0
src/main/kotlin/com/sirekanyan/andersrobot/api/MainInfo.kt

@@ -0,0 +1,6 @@
+package com.sirekanyan.andersrobot.api
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class MainInfo(val temp: Double)

+ 0 - 3
src/main/kotlin/com/sirekanyan/andersrobot/api/Weather.kt

@@ -12,9 +12,6 @@ data class Weather(
     val weather: List<Condition>
 ) {
 
-    @Serializable
-    data class MainInfo(val temp: Double)
-
     @Serializable
     data class System(val country: String = "")
 

+ 15 - 2
src/main/kotlin/com/sirekanyan/andersrobot/api/WeatherApi.kt

@@ -10,7 +10,9 @@ import kotlinx.serialization.decodeFromString
 import kotlinx.serialization.json.Json
 import org.telegram.telegrambots.meta.api.objects.Location
 
-private const val WEATHER_URL = "https://api.openweathermap.org/data/2.5/weather"
+private const val BASE_URL = "https://api.openweathermap.org/data/2.5"
+private const val WEATHER_URL = "$BASE_URL/weather"
+private const val FORECAST_URL = "$BASE_URL/forecast"
 
 class WeatherApi {
 
@@ -35,9 +37,20 @@ class WeatherApi {
             .sortedWith(comparator)
     }
 
+    fun getForecast(city: String, language: String?): Forecast? = runBlocking {
+        println("getting $city forecast")
+        fetchForecast("q" to city, "lang" to language)
+    }
+
     private suspend fun fetchWeather(vararg params: Pair<String, Any?>): Weather? =
+        fetch(WEATHER_URL, *params)
+
+    private suspend fun fetchForecast(vararg params: Pair<String, Any?>): Forecast? =
+        fetch(FORECAST_URL, *params)
+
+    private suspend inline fun <reified T> fetch(url: String, vararg params: Pair<String, Any?>): T? =
         try {
-            val response: String = httpClient.get(WEATHER_URL) {
+            val response: String = httpClient.get(url) {
                 params.forEach { (k, v) ->
                     if (v != null) {
                         parameter(k, v)

+ 4 - 0
src/main/kotlin/com/sirekanyan/andersrobot/extensions/AbsSender.kt

@@ -2,6 +2,7 @@ package com.sirekanyan.andersrobot.extensions
 
 import com.sirekanyan.andersrobot.api.Weather
 import org.telegram.telegrambots.meta.api.methods.send.SendMessage
+import org.telegram.telegrambots.meta.api.methods.send.SendPhoto
 import org.telegram.telegrambots.meta.api.methods.send.SendSticker
 import org.telegram.telegrambots.meta.api.objects.InputFile
 import org.telegram.telegrambots.meta.api.objects.Message
@@ -14,6 +15,9 @@ private val cachedFileIds: MutableMap<File, String> = ConcurrentHashMap()
 fun AbsSender.sendText(chatId: Long, text: String): Message =
     execute(SendMessage(chatId, text))
 
+fun AbsSender.sendPhoto(chatId: Long, file: File): Message =
+    execute(SendPhoto().setChatId(chatId).setPhoto(file))
+
 fun AbsSender.sendWeather(chatId: Long, weather: Weather, accuracy: Int) {
     weather.findImageFile()?.let { icon ->
         sendSticker(chatId, icon)

+ 4 - 0
src/main/kotlin/com/sirekanyan/andersrobot/extensions/String.kt

@@ -7,6 +7,7 @@ private val REGEX = Regex("\\b(андерс|anders|погод[аеуы])\\b", IG
 private val DEGREE_REGEX = Regex("\\bградус", IGNORE_CASE)
 private val CELSIUS_REGEX = Regex("\\b(Celsi|Цельси)", IGNORE_CASE)
 private val CITY_REGEX = Regex("(/temp|погода) +(.+)", IGNORE_CASE)
+private val FORECAST_REGEX = Regex("(/forecast|прогноз) +(.+)", IGNORE_CASE)
 private val ADD_CITY_REGEX = Regex("(/add|добавить город) +(.+)", IGNORE_CASE)
 private val DEL_CITY_REGEX = Regex("(/del|удалить город) +(.+)", IGNORE_CASE)
 
@@ -20,6 +21,9 @@ fun isCelsiusCommand(text: String?): Boolean =
 fun getCityCommand(text: String?): String? =
     CITY_REGEX.matchEntire(text.orEmpty())?.groupValues?.get(2)
 
+fun getForecastCommand(text: String?): String? =
+    FORECAST_REGEX.matchEntire(text.orEmpty())?.groupValues?.get(2)
+
 fun getAddCityCommand(text: String?): String? =
     ADD_CITY_REGEX.matchEntire(text.orEmpty())?.groupValues?.get(2)