From e4fa1f2c78add44c33e909145308a2616bb3bb8e Mon Sep 17 00:00:00 2001 From: johan12345 Date: Thu, 18 May 2023 01:08:56 +0200 Subject: [PATCH] add Supercharger utilization graph #272 --- .../api/availability/AvailabilityDetector.kt | 1 + .../availability/TeslaAvailabilityDetector.kt | 32 +++++- .../net/vonforst/evmap/ui/BarGraphView.kt | 100 +++++++++++++----- .../net/vonforst/evmap/ui/BindingAdapters.kt | 5 + .../vonforst/evmap/viewmodel/MapViewModel.kt | 87 ++++++++++----- app/src/main/res/layout/detail_view.xml | 18 ++-- app/src/main/res/layout/fragment_map.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/strings.xml | 1 + 10 files changed, 181 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/net/vonforst/evmap/api/availability/AvailabilityDetector.kt b/app/src/main/java/net/vonforst/evmap/api/availability/AvailabilityDetector.kt index ff3e30ed..e77327fc 100644 --- a/app/src/main/java/net/vonforst/evmap/api/availability/AvailabilityDetector.kt +++ b/app/src/main/java/net/vonforst/evmap/api/availability/AvailabilityDetector.kt @@ -137,6 +137,7 @@ data class ChargeLocationStatus( val status: Map>, val source: String, val evseIds: Map>? = null, + val congestionHistogram: List? = null, val extraData: Any? = null // API-specific data ) { fun applyFilters(connectors: Set?, minPower: Int?): ChargeLocationStatus { diff --git a/app/src/main/java/net/vonforst/evmap/api/availability/TeslaAvailabilityDetector.kt b/app/src/main/java/net/vonforst/evmap/api/availability/TeslaAvailabilityDetector.kt index 79a398ce..e93bbfb9 100644 --- a/app/src/main/java/net/vonforst/evmap/api/availability/TeslaAvailabilityDetector.kt +++ b/app/src/main/java/net/vonforst/evmap/api/availability/TeslaAvailabilityDetector.kt @@ -23,6 +23,7 @@ import java.security.MessageDigest import java.security.SecureRandom import java.time.Instant import java.time.LocalTime +import java.util.Collections private const val coordRange = 0.005 // range of latitude and longitude for loading the map @@ -277,10 +278,10 @@ interface TeslaGraphQlApi { @JsonClass(generateAdapter = true) data class ChargingSiteInformation( - // TODO: congestionPriceHistogram, pricing val siteDynamic: SiteDynamic, val siteStatic: SiteStatic, - val pricing: Pricing + val pricing: Pricing, + val congestionPriceHistogram: CongestionPriceHistogram, ) @JsonClass(generateAdapter = true) @@ -380,6 +381,18 @@ interface TeslaGraphQlApi { val rates: List ) + @JsonClass(generateAdapter = true) + data class CongestionPriceHistogram( + val data: List, + val dataAttributes: List + ) + + @JsonClass(generateAdapter = true) + data class CongestionHistogramDataAttributes( + val congestionThreshold: String, // "LEVEL_1" + val label: String // "1AM", "2AM", etc. + ) + enum class ChargerAvailability { @Json(name = "CHARGER_AVAILABILITY_AVAILABLE") AVAILABLE, @@ -561,7 +574,20 @@ class TeslaAvailabilityDetector( i += connector.count } - return ChargeLocationStatus(statusMap, "Tesla", extraData = details.pricing) + val indexOfMidnight = + details.congestionPriceHistogram.dataAttributes.indexOfFirst { it.label == "12AM" } + val congestionHistogram = indexOfMidnight.takeIf { it >= 0 }?.let { index -> + val data = details.congestionPriceHistogram.data.toMutableList() + Collections.rotate(data, -index) + data + } + + return ChargeLocationStatus( + statusMap, + "Tesla", + congestionHistogram = congestionHistogram, + extraData = details.pricing + ) } override fun isChargerSupported(charger: ChargeLocation): Boolean { diff --git a/app/src/main/java/net/vonforst/evmap/ui/BarGraphView.kt b/app/src/main/java/net/vonforst/evmap/ui/BarGraphView.kt index 90b7e3c9..51a02bfd 100644 --- a/app/src/main/java/net/vonforst/evmap/ui/BarGraphView.kt +++ b/app/src/main/java/net/vonforst/evmap/ui/BarGraphView.kt @@ -14,6 +14,7 @@ import android.view.View import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import net.vonforst.evmap.R +import java.time.Duration import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter @@ -28,8 +29,8 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs) private val dp = context.resources.displayMetrics.density private val sp = context.resources.displayMetrics.scaledDensity var zeroHeight = 4 * dp - var barWidth = (16 * dp).roundToInt() - var barMargin = (2 * dp).roundToInt() + var barWidth = 16 * dp + var barMargin = 2 * dp var legendWidth = 12 * dp var legendLineLength = 4 * dp var legendLineWidth = 1 * dp @@ -42,18 +43,20 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs) var barDrawable = AppCompatResources.getDrawable(context, R.drawable.bar_graph)!! var colorAvailable = ContextCompat.getColor(context, R.color.available) + var colorSomeAvailable = ContextCompat.getColor(context, R.color.some_available) var colorUnavailable = ContextCompat.getColor(context, R.color.unavailable) - var data: Map? = null + var data: Map? = null set(value) { field = value invalidate() } - var maxValue: Int? = null + var maxValue: Double? = null set(value) { field = value invalidate() } + var isPercentage: Boolean = false var activeAlpha = 0.87f var inactiveAlpha = 0.60f @@ -110,22 +113,28 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs) plusMinutes((minutesRound - minute).toLong()) } data = (0..20).associate { - now.plusMinutes(15L * it) to (Math.random() * 8).roundToInt() + now.plusMinutes(15L * it) to (Math.random() * 8) } - maxValue = 8 + maxValue = 8.0 } val data = data?.toSortedMap() ?: return if (data.isEmpty()) return val maxValue = maxValue ?: data.maxOf { it.value } + val graphWidth = graphBounds?.width() ?: return + val n = data.size + val barMarginFactor = 0.1f + barWidth = graphWidth / (n + barMarginFactor * (n - 1)) + barMargin = barWidth * barMarginFactor + drawGraph(canvas, data, maxValue) drawBubble(canvas, data, maxValue) } private fun drawGraph( canvas: Canvas, - data: SortedMap, - maxValue: Int + data: SortedMap, + maxValue: Double ) { val graphBounds = graphBounds ?: return @@ -139,26 +148,30 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs) ) legendPaint.textAlign = Paint.Align.CENTER + data.entries.forEachIndexed { i, (t, v) -> val height = - zeroHeight + (graphBounds.height() - zeroHeight) * v.toFloat() / maxValue + zeroHeight + (graphBounds.height() - zeroHeight) * v.toFloat() / maxValue.toFloat() val left = graphBounds.left + (barWidth + barMargin) * i if (left + barWidth > graphBounds.right) return@forEachIndexed barDrawable.setBounds( - left, + 0, graphBounds.bottom - height.roundToInt(), - left + barWidth, + barWidth.roundToInt(), graphBounds.bottom ) + + canvas.translate(left, 0f) barDrawable.alpha = ((if (i == selectedBar) activeAlpha else inactiveAlpha) * 255).roundToInt() barDrawable.setTint(getColor(v, maxValue)) barDrawable.draw(canvas) + canvas.translate(-left, 0f) - val center = left.toFloat() + barWidth / 2 - if (t.minute == 0) { + val center = left + barWidth / 2 + if (shouldDrawLabel(t, data)) { drawLine( center, graphBounds.bottom.toFloat(), center, graphBounds.bottom + legendLineLength, legendPaint @@ -196,19 +209,44 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs) ) legendPaint.textAlign = Paint.Align.LEFT - drawText( - this@BarGraphView.maxValue.toString(), - graphBounds.right.toFloat() + legendLineLength, - graphBounds.top + (legendWidth - legendLineLength) / 3, - legendPaint - ) + if (!isPercentage) { + drawText( + maxValue.roundToInt().toString(), + graphBounds.right.toFloat() + legendLineLength, + graphBounds.top + (legendWidth - legendLineLength) / 3, + legendPaint + ) + } } } - private fun getColor(v: Int, maxValue: Int) = - if (v < maxValue) colorAvailable else colorUnavailable + private fun shouldDrawLabel(t: ZonedDateTime, data: SortedMap): Boolean { + val ts = data.keys.toList() + return if (Duration.between(ts[0], ts[1]) > Duration.ofMinutes(31)) { + // label every 6 hours + t.hour % 6 == 0 + } else { + // label every 15 minutes + t.minute == 0 + } + } - private fun drawBubble(canvas: Canvas, data: SortedMap, maxValue: Int) { + private fun getColor(v: Double, maxValue: Double) = + if (isPercentage) { + when (v) { + in 0.0..0.5 -> colorAvailable + in 0.5..0.8 -> colorSomeAvailable + else -> colorUnavailable + } + } else { + if (v < maxValue) colorAvailable else colorUnavailable + } + + private fun drawBubble( + canvas: Canvas, + data: SortedMap, + maxValue: Double + ) { val bubbleBounds = bubbleBounds ?: return val graphBounds = graphBounds ?: return val d = data.toList() @@ -221,12 +259,16 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs) R.string.prediction_time_colon, t.withZoneSameInstant(ZoneId.systemDefault()).format(timeFormat) ) - val availableformat = context.resources.getQuantityString( - R.plurals.prediction_number_available, - maxValue - v, - maxValue - v, - maxValue - ) + val availableformat = if (isPercentage) { + "%.0f %%".format(v * 100) + } else { + context.resources.getQuantityString( + R.plurals.prediction_number_available, + (maxValue - v).roundToInt(), + (maxValue - v).roundToInt(), + maxValue.roundToInt() + ) + } val text = SpannableString("$tformat $availableformat").apply { setSpan( ForegroundColorSpan(getColor(v, maxValue)), @@ -297,7 +339,7 @@ class BarGraphView(context: Context, attrs: AttributeSet) : View(context, attrs) private fun updateSelectedBar(x: Int) { val graphBounds = graphBounds ?: return - val bar = (x - graphBounds.left) / (barWidth + barMargin) + val bar = ((x - graphBounds.left) / (barWidth + barMargin)).roundToInt() if (bar != selectedBar) { selectedBar = bar invalidate() diff --git a/app/src/main/java/net/vonforst/evmap/ui/BindingAdapters.kt b/app/src/main/java/net/vonforst/evmap/ui/BindingAdapters.kt index dda156bb..9b76ab4b 100644 --- a/app/src/main/java/net/vonforst/evmap/ui/BindingAdapters.kt +++ b/app/src/main/java/net/vonforst/evmap/ui/BindingAdapters.kt @@ -420,4 +420,9 @@ fun setImageTint(view: ImageView, @ColorInt tint: Int?) { } else { view.imageTintList = null } +} + +@BindingAdapter("isPercentage") +fun setIsPercentage(view: BarGraphView, value: Boolean) { + view.isPercentage = value } \ No newline at end of file diff --git a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt index 70efcad4..3f1a65ec 100644 --- a/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt +++ b/app/src/main/java/net/vonforst/evmap/viewmodel/MapViewModel.kt @@ -37,6 +37,9 @@ import net.vonforst.evmap.ui.cluster import net.vonforst.evmap.utils.distanceBetween import retrofit2.HttpException import java.io.IOException +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId import java.time.ZonedDateTime @Parcelize @@ -286,33 +289,45 @@ class MapViewModel(application: Application, private val state: SavedStateHandle } } - val predictionGraph: LiveData?> by lazy { - prediction.map { - it.data?.let { responses -> - if (responses.isEmpty()) { - null - } else { - val evseIds = responses.map { it.evseId } - val groupByTimestamp = responses.flatMap { response -> - response.predictions.map { - Triple( - it.timestamp, - response.evseId, - it.status - ) + val predictionGraph: LiveData?> = + MediatorLiveData?>().apply { + listOf(prediction, availability).forEach { + addSource(it) { + val congestionHistogram = availability.value?.data?.congestionHistogram + val prediction = prediction.value?.data + value = if (congestionHistogram != null && prediction == null) { + congestionHistogram.mapIndexed { i, value -> + LocalTime.of(i, 0).atDate(LocalDate.now()) + .atZone(ZoneId.systemDefault()) to value + }.toMap() + } else { + prediction?.let { responses -> + if (responses.isEmpty()) { + null + } else { + val evseIds = responses.map { it.evseId } + val groupByTimestamp = responses.flatMap { response -> + response.predictions.map { + Triple( + it.timestamp, + response.evseId, + it.status + ) + } + } + .groupBy { it.first } // group by timestamp + .mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status + .filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs + .filterKeys { it > ZonedDateTime.now() } // only show predictions in the future + + groupByTimestamp.mapValues { + it.value.count { + it.second == FronyxStatus.UNAVAILABLE + }.toDouble() + }.ifEmpty { null } + } } } - .groupBy { it.first } // group by timestamp - .mapValues { it.value.map { it.second to it.third } } // only keep EVSEID and status - .filterValues { it.map { it.first } == evseIds } // remove values where status is not given for all EVSEs - .filterKeys { it > ZonedDateTime.now() } // only show predictions in the future - - groupByTimestamp.mapValues { - it.value.count { - it.second == FronyxStatus.UNAVAILABLE - } - }.ifEmpty { null } - } } } } @@ -332,9 +347,25 @@ class MapViewModel(application: Application, private val state: SavedStateHandle } } - val predictionMaxValue: LiveData by lazy { - predictedChargepoints.map { - it?.sumOf { it.count } ?: 0 + val predictionMaxValue: LiveData = MediatorLiveData().apply { + listOf(prediction, availability).forEach { + addSource(it) { + value = + if (availability.value?.data?.congestionHistogram != null && prediction.value?.data == null) { + 1.0 + } else { + (predictedChargepoints.value?.sumOf { it.count } ?: 0).toDouble() + } + } + } + } + + val predictionIsPercentage: LiveData = MediatorLiveData().apply { + listOf(prediction, availability).forEach { + addSource(it) { + value = + availability.value?.data?.congestionHistogram != null && prediction.value?.data == null + } } } diff --git a/app/src/main/res/layout/detail_view.xml b/app/src/main/res/layout/detail_view.xml index fe66eee9..f5b7e78c 100644 --- a/app/src/main/res/layout/detail_view.xml +++ b/app/src/main/res/layout/detail_view.xml @@ -45,11 +45,15 @@ + type="Map<ZonedDateTime, Double>" /> + type="Double" /> + + bis zu %s Andere Zeiten: Blockiergebühr: %s + Durchschnittliche Auslastung \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index de8d6764..1f17615f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -15,6 +15,7 @@ #607d8b #4caf50 #00bcd4 + #ffc107 #f44336 #9e9e9e #C3000000 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8162da2a..35331496 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -312,4 +312,5 @@ up to %s Other times: Blocking fee: %s + Average Utilization \ No newline at end of file