add Supercharger utilization graph

#272
This commit is contained in:
johan12345
2023-05-18 01:08:56 +02:00
parent b2b5cc63e8
commit e4fa1f2c78
10 changed files with 181 additions and 66 deletions

View File

@@ -137,6 +137,7 @@ data class ChargeLocationStatus(
val status: Map<Chargepoint, List<ChargepointStatus>>,
val source: String,
val evseIds: Map<Chargepoint, List<String>>? = null,
val congestionHistogram: List<Double>? = null,
val extraData: Any? = null // API-specific data
) {
fun applyFilters(connectors: Set<String>?, minPower: Int?): ChargeLocationStatus {

View File

@@ -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<Double>
)
@JsonClass(generateAdapter = true)
data class CongestionPriceHistogram(
val data: List<Double>,
val dataAttributes: List<CongestionHistogramDataAttributes>
)
@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 {

View File

@@ -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<ZonedDateTime, Int>? = null
var data: Map<ZonedDateTime, Double>? = 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<ZonedDateTime, Int>,
maxValue: Int
data: SortedMap<ZonedDateTime, Double>,
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<ZonedDateTime, Double>): 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<ZonedDateTime, Int>, 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<ZonedDateTime, Double>,
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()

View File

@@ -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
}

View File

@@ -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<Map<ZonedDateTime, Int>?> 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<Map<ZonedDateTime, Double>?> =
MediatorLiveData<Map<ZonedDateTime, Double>?>().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<Int> by lazy {
predictedChargepoints.map {
it?.sumOf { it.count } ?: 0
val predictionMaxValue: LiveData<Double> = MediatorLiveData<Double>().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<Boolean> = MediatorLiveData<Boolean>().apply {
listOf(prediction, availability).forEach {
addSource(it) {
value =
availability.value?.data?.congestionHistogram != null && prediction.value?.data == null
}
}
}

View File

@@ -45,11 +45,15 @@
<variable
name="predictionGraph"
type="Map&lt;ZonedDateTime, Integer&gt;" />
type="Map&lt;ZonedDateTime, Double&gt;" />
<variable
name="predictionMaxValue"
type="Integer" />
type="Double" />
<variable
name="predictionIsPercentage"
type="Boolean" />
<variable
name="predictionDescription"
@@ -357,7 +361,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/utilization_prediction"
android:text="@{predictionIsPercentage ? @string/average_utilization : @string/utilization_prediction}"
tools:text="@string/utilization_prediction"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?colorPrimary"
app:goneUnless="@{predictionGraph != null}"
@@ -372,7 +377,7 @@
android:layout_marginEnd="8dp"
android:text="@{predictionDescription}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:goneUnless="@{predictionGraph != null}"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:layout_constraintBaseline_toBaselineOf="@+id/textView8"
app:layout_constraintEnd_toStartOf="@+id/btnPredictionHelp"
app:layout_constraintStart_toEndOf="@+id/textView8"
@@ -384,7 +389,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/help"
app:goneUnless="@{predictionGraph != null}"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:icon="@drawable/ic_help"
app:iconTint="?android:textColorSecondary"
app:layout_constraintBottom_toBottomOf="@+id/textView8"
@@ -402,6 +407,7 @@
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/textView8"
app:maxValue="@{predictionMaxValue}"
app:isPercentage="@{predictionIsPercentage}"
tools:itemCount="3"
tools:layoutManager="LinearLayoutManager"
tools:listitem="@layout/item_connector"
@@ -415,7 +421,7 @@
android:adjustViewBounds="true"
android:background="?selectableItemBackgroundBorderless"
android:scaleType="fitCenter"
app:goneUnless="@{predictionGraph != null}"
app:goneUnless="@{predictionGraph != null &amp;&amp; !predictionIsPercentage}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintTop_toBottomOf="@+id/prediction"
app:srcCompat="@drawable/ic_powered_by_fronyx"

View File

@@ -199,6 +199,7 @@
app:filteredAvailability="@{vm.filteredAvailability}"
app:predictionGraph="@{vm.predictionGraph}"
app:predictionMaxValue="@{vm.predictionMaxValue}"
app:predictionIsPercentage="@{vm.predictionIsPercentage}"
app:predictionDescription="@{vm.predictionDescription}"
app:chargeCards="@{vm.chargeCardMap}"
app:filteredChargeCards="@{vm.filteredChargeCards}"

View File

@@ -312,4 +312,5 @@
<string name="pricing_up_to">bis zu %s</string>
<string name="tesla_pricing_other_times">Andere Zeiten:</string>
<string name="tesla_pricing_blocking_fee">Blockiergebühr: %s</string>
<string name="average_utilization">Durchschnittliche Auslastung</string>
</resources>

View File

@@ -15,6 +15,7 @@
<color name="charger_low">#607d8b</color>
<color name="available">#4caf50</color>
<color name="charging">#00bcd4</color>
<color name="some_available">#ffc107</color>
<color name="unavailable">#f44336</color>
<color name="unknown">#9e9e9e</color>
<color name="status_bar_scrim">#C3000000</color>

View File

@@ -312,4 +312,5 @@
<string name="pricing_up_to">up to %s</string>
<string name="tesla_pricing_other_times">Other times:</string>
<string name="tesla_pricing_blocking_fee">Blocking fee: %s</string>
<string name="average_utilization">Average Utilization</string>
</resources>