Merge pull request #160 from dbrgn/osm

OpenStreetMap data parser
This commit is contained in:
Johan von Forstner
2022-02-01 18:51:01 +01:00
committed by GitHub
7 changed files with 352 additions and 5 deletions

View File

@@ -105,7 +105,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
chargers?.take(maxRows)?.let { chargerList ->
val builder = ItemList.Builder()
// only show the city if not all chargers are in the same city
val showCity = chargerList.map { it.address.city }.distinct().size > 1
val showCity = chargerList.map { it.address?.city }.distinct().size > 1
chargerList.forEach { charger ->
builder.addItem(formatCharger(charger, showCity))
}
@@ -177,7 +177,7 @@ class MapScreen(ctx: CarContext, val session: EVMapSession, val favorites: Boole
return Row.Builder().apply {
// only show the city if not all chargers are in the same city (-> showCity == true)
// and the city is not already contained in the charger name
if (showCity && charger.address.city != null && charger.address.city !in charger.name) {
if (showCity && charger.address?.city != null && charger.address.city !in charger.name) {
setTitle(
CarText.Builder("${charger.name} · ${charger.address.city}")
.addVariant(charger.name)

View File

@@ -44,13 +44,13 @@ fun buildDetails(
if (loc == null) return emptyList()
return listOfNotNull(
DetailsAdapter.Detail(
if (loc.address != null) DetailsAdapter.Detail(
R.drawable.ic_address,
R.string.address,
loc.address.toString(),
loc.locationDescription,
clickable = true
),
) else null,
if (loc.operator != null) DetailsAdapter.Detail(
R.drawable.ic_operator,
R.string.operator,

View File

@@ -0,0 +1,202 @@
package net.vonforst.evmap.api.openstreetmap
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import net.vonforst.evmap.model.*
import okhttp3.internal.immutableListOf
import java.time.Instant
import java.time.ZonedDateTime
private data class OsmSocket(
// The OSM socket name (e.g. "type2_combo")
val osmSocketName: String,
// The socket identifier used in EVMap.
// TODO: This should probably be a separate enum-like type, not a string.
val evmapKey: String?,
) {
/**
* Return the OSM socket base tag (e.g. "socket:type2_combo").
*/
fun osmSocketBaseTag(): String {
return "socket:${this.osmSocketName}"
}
}
// List of all OSM socket types that are relevant for EVs:
// https://wiki.openstreetmap.org/wiki/Key:socket
private val SOCKET_TYPES = immutableListOf(
// Type 1
OsmSocket("type1", Chargepoint.TYPE_1),
OsmSocket("type1_combo", Chargepoint.CCS_TYPE_1),
// Type 2
OsmSocket("type2", Chargepoint.TYPE_2_SOCKET), // Type2 socket (or unknown)
OsmSocket("type2_cable", Chargepoint.TYPE_2_PLUG), // Type2 plug
OsmSocket("type2_combo", Chargepoint.CCS_TYPE_2), // CCS
// CHAdeMO
OsmSocket("chademo", Chargepoint.CHADEMO),
// Tesla
OsmSocket("tesla_standard", null),
OsmSocket("tesla_supercharger", Chargepoint.SUPERCHARGER),
// CEE
OsmSocket("cee_blue", Chargepoint.CEE_BLAU), // Also known as "caravan socket"
OsmSocket("cee_red_16a", Chargepoint.CEE_ROT),
OsmSocket("cee_red_32a", Chargepoint.CEE_ROT),
OsmSocket("cee_red_63a", Chargepoint.CEE_ROT),
OsmSocket("cee_red_125a", Chargepoint.CEE_ROT),
// Europe
OsmSocket("schuko", Chargepoint.SCHUKO),
// Switzerland
OsmSocket("sev1011_t13", null),
OsmSocket("sev1011_t15", null),
OsmSocket("sev1011_t23", null),
OsmSocket("sev1011_t25", null),
)
@JsonClass(generateAdapter = true)
data class OSMChargingStation(
// Unique numeric ID
val id: Long,
// Latitude (WGS84)
val lat: Double,
// Longitude (WGS84)
val lon: Double,
// Timestamp of last update
@Json(name = "timestamp") val lastUpdateTimestamp: ZonedDateTime,
// Numeric, monotonically increasing version number
val version: Int,
// User that last modified this POI
val user: String,
// Raw key-value OSM tags
val tags: Map<String, String>,
) {
/**
* Convert the [OSMChargingStation] to a generic [ChargeLocation].
*
* The [dataFetchTimestamp] should be set to the timestamp when the data was last
* refreshed / fetched from OSM. It will always be later than the [lastUpdateTimestamp],
* which contains the timestamp when the data was last _edited_ in OSM.
*/
fun convert(dataFetchTimestamp: Instant) = ChargeLocation(
id,
"openstreetmap",
getName(),
Coordinate(lat, lon),
null, // TODO: Can we determine this with overpass?
getChargepoints(),
tags["network"],
"https://www.openstreetmap.org/node/$id",
"https://www.openstreetmap.org/edit?node=$id",
null,
false, // We don't know
tags["authentication:none"] == "yes",
tags["operator"],
tags["description"],
null,
null,
null,
null,
getOpeningHours(),
getCost(),
"© OpenStreetMap contributors",
null,
dataFetchTimestamp,
true,
)
/**
* Return the name for this charging station.
*/
private fun getName(): String {
// Ideally this station has a name.
// If not, fall back to the operator.
// If that is missing as well, use a generic "Charging Station" string.
return tags["name"]
?: tags["operator"]
?: "Charging Station";
}
/**
* Return the chargepoints for this charging station.
*/
private fun getChargepoints(): List<Chargepoint> {
// Note: In OSM, the chargepoints are mapped as "socket:<type> = <count>"
val chargepoints = mutableListOf<Chargepoint>()
for (socket in SOCKET_TYPES) {
val count = try {
(this.tags[socket.osmSocketBaseTag()] ?: "0").toInt()
} catch (e: NumberFormatException) {
0
}
if (count > 0) {
if (socket.evmapKey != null) {
val outputPower = parseOutputPower(this.tags["${socket.osmSocketBaseTag()}:output"])
chargepoints.add(Chargepoint(socket.evmapKey, outputPower, count))
}
}
}
return chargepoints
}
private fun getOpeningHours(): OpeningHours? {
val rawOpeningHours = tags["opening_hours"] ?: return null
// Handle the simple 24/7 case
if (rawOpeningHours == "24/7") {
return OpeningHours(true, null, null)
}
// TODO: Try to convert other formats as well?
//
// Note: The current {@link OpeningHours} format is not flexible enough to handle
// all rules that OSM can represent and might need to be updated.
// This library could help: https://github.com/simonpoole/OpeningHoursParser
//
// Alternatively, with the opening-hours-evaluator library
// https://github.com/leonardehrenfried/opening-hours-evaluator
// we could implement an "open now" feature.
return null
}
private fun getCost(): Cost? {
val freecharging = when (tags["fee"]?.lowercase()) {
"yes", "y" -> false
"no", "n" -> true
else -> null
}
val freeparking = when (tags["parking:fee"]?.lowercase()) {
"no", "n" -> true
"yes", "y", "interval" -> false
else -> null
}
return Cost(freecharging, freeparking)
}
companion object {
/**
* Parse raw OSM output power.
*
* The proper format to map output power for an EV charging station is "<amount> kW",
* for example "22 kW" or "3.7 kW". Some fields in the wild are tagged with the unit "kVA"
* instead of "kW", those can be treated as equivalent.
*
* Sometimes people also mapped plain numbers (e.g. 7000, I assume that's 7 kW),
* ranges (5,5 - 11 kW, huh?) or even current (32 A), which is wrong. If we cannot parse,
* just ignore the field.
*/
fun parseOutputPower(rawOutput: String?): Double? {
if (rawOutput == null) {
return null;
}
val pattern = Regex("([0-9.,]+)\\s*(kW|kVA)", setOf(RegexOption.IGNORE_CASE))
val matchResult = pattern.matchEntire(rawOutput) ?: return null
val numberString = matchResult.groupValues[1].replace(',', '.')
return numberString.toDoubleOrNull()
}
}
}

View File

@@ -28,6 +28,28 @@ sealed class ChargepointListItem
/**
* A whole charging site (potentially with multiple chargepoints).
*
* @param id A unique number per charging site
* @param dataSource The name of the data source
* @param coordinates The latitude / longitude of this charge location
* @param address The charge location address
* @param chargepoints List of chargepoints at this location
* @param network The charging network (Mobility Service Provider, MSP)
* @param url A link to this charging site
* @param editUrl A link to a website where this charging site can be edited
* @param faultReport Set this if the charging site is reported to be out of service
* @param verified For crowdsourced data sources, this means that the data has been verified
* by an independent person
* @param barrierFree Whether this charge location can be used without prior registration
* @param operator The operator of this charge location (Charge Point Operator, CPO)
* @param generalInformation General information about this charging site that does not fit anywhere else
* @param amenities Description of amenities available at or near the charging site (toilets, food, accommodation, landmarks, etc.)
* @param locationDescription Directions on how to find the charger (e.g. "In the parking garage on level 5")
* @param photos List of photos of this charging site
* @param chargecards List of charge cards accepted here
* @param openinghours List of times when this charging site can be accessed / used
* @param cost The cost for charging and/or parking
* @param license How the data about this chargepoint is licensed
* @param chargepriceData Additional data needed for the Chargeprice implementation
* @param timeRetrieved Time when this information was retrieved from the data source
* @param isDetailed Whether this data includes all available details (for many data sources,
* API calls that return a list may only give a compact representation)
@@ -39,7 +61,7 @@ data class ChargeLocation(
val dataSource: String,
val name: String,
@Embedded val coordinates: Coordinate,
@Embedded val address: Address,
@Embedded val address: Address?,
val chargepoints: List<Chargepoint>,
val network: String?,
val url: String,
@@ -307,6 +329,9 @@ data class Address(
}
}
/**
* One socket with a certain power, which may be available multiple times at a ChargeLocation.
*/
@Parcelize
@JsonClass(generateAdapter = true)
data class Chargepoint(

View File

@@ -100,6 +100,7 @@
android:maxLines="1"
android:text="@{charger.data.address.toString()}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:invisibleUnless="@{charger.data.address != null}"
app:layout_constraintEnd_toStartOf="@+id/guideline2"
app:layout_constraintStart_toStartOf="@+id/guideline"
app:layout_constraintTop_toBottomOf="@+id/txtName"

View File

@@ -67,6 +67,7 @@
android:maxLines="1"
android:text="@{item.charger.address.toString()}"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
app:invisibleUnless="@{item.charger.address != null}"
app:layout_constraintEnd_toStartOf="@+id/textView7"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView15"

View File

@@ -0,0 +1,118 @@
package net.vonforst.evmap.api.openstreetmap
import com.squareup.moshi.Moshi
import net.vonforst.evmap.api.openchargemap.ZonedDateTimeAdapter
import net.vonforst.evmap.model.Chargepoint
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.Instant
import java.time.Month
import java.time.ZoneOffset
const val JSON_SINGLE = "{\n" +
" \"id\": 9084665785,\n" +
" \"lat\": 46.1137872,\n" +
" \"lon\": 7.0778715,\n" +
" \"timestamp\": \"2021-09-12T19:36:56Z\",\n" +
" \"version\": 1,\n" +
" \"user\": \"Voonosm\",\n" +
" \"tags\": {\n" +
" \"amenity\": \"charging_station\",\n" +
" \"authentication:app\": \"yes\",\n" +
" \"authentication:contactless\": \"yes\",\n" +
" \"bicycle\": \"no\",\n" +
" \"capacity\": \"2\",\n" +
" \"cover\": \"no\",\n" +
" \"fee\": \"yes\",\n" +
" \"motorcar\": \"yes\",\n" +
" \"network\": \"Swisscharge\",\n" +
" \"opening_hours\": \"24/7\",\n" +
" \"operator\": \"GOFAST\",\n" +
" \"parking:fee\": \"no\",\n" +
" \"payment:credit_cards\": \"yes\",\n" +
" \"socket:chademo\": \"2\",\n" +
" \"socket:chademo:output\": \"60 kW\",\n" +
" \"socket:type2\": \"1\",\n" +
" \"socket:type2:output\": \"22 kW\",\n" +
" \"socket:type2_combo\": \"2\",\n" +
" \"socket:type2_combo:output\": \"150 kW\"\n" +
" }\n" +
"}"
class OpenStreetMapModelTest {
@Test
fun parseFromJson() {
val moshi = Moshi.Builder()
.add(ZonedDateTimeAdapter())
.build()
val deserialized = moshi
.adapter(OSMChargingStation::class.java)
.fromJson(JSON_SINGLE)!!
assertEquals(9084665785, deserialized.id)
assertEquals(1, deserialized.version)
assertEquals(12, deserialized.lastUpdateTimestamp.dayOfMonth)
assertEquals(Month.SEPTEMBER, deserialized.lastUpdateTimestamp.month)
assertEquals(36, deserialized.lastUpdateTimestamp.minute)
assertEquals(ZoneOffset.UTC, deserialized.lastUpdateTimestamp.offset)
assertEquals("Swisscharge", deserialized.tags["network"])
}
@Test
fun convert() {
val osmChargingStation = Moshi.Builder()
.add(ZonedDateTimeAdapter())
.build()
.adapter(OSMChargingStation::class.java)
.fromJson(JSON_SINGLE)!!
val now = Instant.now()
val chargeLocation = osmChargingStation.convert(now)
// Basics
assertEquals("openstreetmap", chargeLocation.dataSource)
assertEquals("https://www.openstreetmap.org/node/9084665785", chargeLocation.url)
assertEquals(true, chargeLocation.openinghours?.twentyfourSeven)
assertEquals("GOFAST", chargeLocation.name) // Fallback to operator because name is not set
assertEquals(false, chargeLocation.barrierFree) // False because `authentication:none` isn't set
assertEquals(now, chargeLocation.timeRetrieved)
// Cost
assertEquals(false, chargeLocation.cost?.freecharging)
assertEquals(true, chargeLocation.cost?.freeparking)
// Chargepoints
assertEquals(3, chargeLocation.chargepoints.size)
val ccs = chargeLocation.chargepoints.single { it.type == Chargepoint.CCS_TYPE_2 }
val type2 = chargeLocation.chargepoints.single { it.type == Chargepoint.TYPE_2_SOCKET }
val chademo = chargeLocation.chargepoints.single { it.type == Chargepoint.CHADEMO }
assertEquals(2, ccs.count)
assertEquals(150.0, ccs.power)
assertEquals(1, type2.count)
assertEquals(22.0, type2.power)
assertEquals(2, chademo.count)
assertEquals(60.0, chademo.power)
}
@Test
fun parseOutputPower() {
// Null input -> null output
assertNull(OSMChargingStation.parseOutputPower(null))
// Invalid input -> null output
assertNull(OSMChargingStation.parseOutputPower(""))
assertNull(OSMChargingStation.parseOutputPower("a"))
assertNull(OSMChargingStation.parseOutputPower("22 A"))
// Invalid number -> null output
assertNull(OSMChargingStation.parseOutputPower("22.0.1 kW"))
// Valid output power values
assertEquals(22.0, OSMChargingStation.parseOutputPower("22 kW"))
assertEquals(22.0, OSMChargingStation.parseOutputPower("22 kVA"))
assertEquals(22.0, OSMChargingStation.parseOutputPower("22. kW"))
assertEquals(22.0, OSMChargingStation.parseOutputPower("22.0 kW"))
assertEquals(22.0, OSMChargingStation.parseOutputPower("22,0 kW"))
assertEquals(22.0, OSMChargingStation.parseOutputPower("22kW"))
assertEquals(22.0, OSMChargingStation.parseOutputPower("22 kW"))
}
}