refactor: optimisation around stoptimes

- moved stoptime related functionality into new core:data:stoptime module
  - will feature all the different realtime stoptime sources to be
      integrated later
- create proper database schema for future migrations
- deduplicate trips into stoppingpatterns, since many trips share the
  exact same stopping pattern
  - stoptimes are now linked to stoppingpatterns instead
  - stoppingpattern ids are generated from a hash composed of all stoptimes
- stoptimes now use deltas for arrival time to save space
This commit is contained in:
Cilly Leang 2026-05-05 03:23:11 +10:00
parent f1770744db
commit 102c028407
Signed by: cilly
GPG key ID: 6500251E087653C9
39 changed files with 396 additions and 223 deletions

View file

@ -27,6 +27,7 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(projects.core)
api(projects.core.data.stoptime)
}
}
}

View file

@ -10,16 +10,12 @@ import kotlinx.serialization.json.Json
import moe.lava.banksia.core.Constants
import moe.lava.banksia.core.data.repositories.ClientRouteRepository
import moe.lava.banksia.core.data.repositories.ClientStopRepository
import moe.lava.banksia.core.data.repositories.ClientStopTimeRepository
import moe.lava.banksia.core.data.repositories.RouteRepository
import moe.lava.banksia.core.data.repositories.StopRepository
import moe.lava.banksia.core.data.repositories.StopTimeRepository
import moe.lava.banksia.core.data.sources.route.RouteLocalDataSource
import moe.lava.banksia.core.data.sources.route.RouteRemoteDataSource
import moe.lava.banksia.core.data.sources.stop.StopLocalDataSource
import moe.lava.banksia.core.data.sources.stop.StopRemoteDataSource
import moe.lava.banksia.core.data.sources.stoptime.StopTimeLocalDataSource
import moe.lava.banksia.core.data.sources.stoptime.StopTimeRemoteDataSource
import moe.lava.banksia.core.sqld.sqldDiModule
import moe.lava.banksia.core.util.log
import moe.lava.banksia.data.ptv.PtvService
@ -29,6 +25,7 @@ import org.koin.dsl.module
val clientDataDiModule = module {
includes(sqldDiModule)
includes(stopTimeDataDiModule)
// HTTP Clients
singleOf(::PtvService)
@ -56,11 +53,8 @@ val clientDataDiModule = module {
singleOf(::RouteRemoteDataSource)
singleOf(::StopLocalDataSource)
singleOf(::StopRemoteDataSource)
singleOf(::StopTimeLocalDataSource)
singleOf(::StopTimeRemoteDataSource)
// Repositories
singleOf(::ClientRouteRepository) bind RouteRepository::class
singleOf(::ClientStopRepository) bind StopRepository::class
singleOf(::ClientStopTimeRepository) bind StopTimeRepository::class
}

View file

@ -1,16 +0,0 @@
package moe.lava.banksia.core.data.repositories
import moe.lava.banksia.core.data.sources.stoptime.StopTimeLocalDataSource
import moe.lava.banksia.core.data.sources.stoptime.StopTimeRemoteDataSource
import moe.lava.banksia.core.model.StopTimeDated
internal class ClientStopTimeRepository internal constructor(
private val local: StopTimeLocalDataSource,
private val remote: StopTimeRemoteDataSource,
) : StopTimeRepository {
override suspend fun getForStop(id: String): List<StopTimeDated> {
return local
.getAtStop(id)
.ifEmpty { remote.getAtStop(id) }
}
}

View file

@ -1,18 +0,0 @@
package moe.lava.banksia.core.data.sources.trip
import io.ktor.client.HttpClient
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.model.Trip
import kotlin.time.Clock
internal class TripRemoteDataSource(
private val client: HttpClient,
) {
suspend fun get(
day: DayOfWeek? = Clock.System.todayIn(TimeZone.currentSystemDefault()).dayOfWeek,
): List<Trip> {
return listOf()
}
}

View file

@ -1,7 +0,0 @@
package moe.lava.banksia.core.data.repositories
import moe.lava.banksia.core.model.StopTimeDated
interface StopTimeRepository {
suspend fun getForStop(id: String): List<StopTimeDated>
}

View file

@ -0,0 +1,60 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.ksp)
}
kotlin {
android {
namespace = "moe.lava.banksia.core.data.stoptime"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime")
freeCompilerArgs.add("-Xexpect-actual-classes")
}
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
val clientMain by creating {
dependsOn(commonMain.get())
}
androidMain.get().dependsOn(clientMain)
iosArm64Main.get().dependsOn(clientMain)
iosSimulatorArm64Main.get().dependsOn(clientMain)
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
commonMain.dependencies {
implementation(libs.okio)
implementation(libs.koin.core)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.protobuf)
implementation(projects.core)
implementation(projects.core.sqld)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}

View file

@ -0,0 +1,11 @@
package moe.lava.banksia.core.data
import moe.lava.banksia.core.data.repositories.StopTimeRepository
import moe.lava.banksia.core.data.sources.stoptime.StopTimeRemoteDataSource
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
internal actual val platformModule = module {
singleOf(::StopTimeRepository)
singleOf(::StopTimeRemoteDataSource)
}

View file

@ -0,0 +1,19 @@
package moe.lava.banksia.core.data.repositories
import kotlinx.coroutines.flow.flow
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.data.sources.stoptime.StopTimeLocalDataSource
import moe.lava.banksia.core.data.sources.stoptime.StopTimeRemoteDataSource
actual class StopTimeRepository internal constructor(
private val local: StopTimeLocalDataSource,
private val remote: StopTimeRemoteDataSource,
) {
actual suspend fun getForStop(id: String, date: LocalDate) = flow {
emit(local.getAtStop(id, date))
remote.getAtStop(id, date)
.takeIf { it.isNotEmpty() }
?.let { emit(it) }
}
}

View file

@ -7,7 +7,7 @@ import io.ktor.client.request.parameter
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.model.StopTimeDated
import moe.lava.banksia.core.model.StopTime
import kotlin.time.Clock
internal class StopTimeRemoteDataSource(
@ -16,21 +16,9 @@ internal class StopTimeRemoteDataSource(
suspend fun getAtStop(
stopId: String,
date: LocalDate? = Clock.System.todayIn(TimeZone.currentSystemDefault()),
): List<StopTimeDated> {
): List<StopTime.Dated> {
return client.get("stoptimes/by_stop/${stopId}") {
parameter("date", date)
}.body<List<StopTimeDated>>()
}.body<List<StopTime.Dated>>()
}
/*suspend fun get(
stop: String? = null,
trip: String? = null,
day: DayOfWeek? = Clock.System.todayIn(TimeZone.currentSystemDefault()).dayOfWeek,
): List<StopTime> {
return client.get("stoptimes") {
stop?.let { parameter("stop", it) }
trip?.let { parameter("trip", it) }
day?.let { parameter("day", it) }
}.body<List<StopTime>>()
}*/
}

View file

@ -0,0 +1,13 @@
package moe.lava.banksia.core.data
import moe.lava.banksia.core.data.sources.stoptime.StopTimeLocalDataSource
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
internal expect val platformModule: Module;
val stopTimeDataDiModule = module {
includes(platformModule)
singleOf(::StopTimeLocalDataSource)
}

View file

@ -0,0 +1,15 @@
package moe.lava.banksia.core.data.repositories
import kotlinx.coroutines.flow.Flow
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.model.StopTime
import kotlin.time.Clock
expect class StopTimeRepository {
suspend fun getForStop(
id: String,
date: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
): Flow<List<StopTime.Dated>>
}

View file

@ -4,23 +4,19 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.model.StopTimeDated
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.atDate
import moe.lava.banksia.core.sqld.StopTimeQueries
import moe.lava.banksia.core.sqld.mappers.asModel
import moe.lava.banksia.core.util.serialise
import kotlin.time.Clock
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
internal class StopTimeLocalDataSource(
private val queries: StopTimeQueries,
) {
suspend fun getAtStop(
stopId: String,
date: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
): List<StopTimeDated> {
return withContext(Dispatchers.IO) {
internal class StopTimeLocalDataSource : KoinComponent {
private val queries get() = get<StopTimeQueries>()
suspend fun getAtStop(stopId: String, date: LocalDate): List<StopTime.Dated> {
return withContext(context = Dispatchers.IO) {
queries
.getForStopDated(
listOf(date.dayOfWeek).serialise().toLong(),
@ -29,7 +25,7 @@ internal class StopTimeLocalDataSource(
)
.executeAsList()
.map { it.asModel().atDate(date) }
.sortedBy { it.departureTime }
.sortedBy { it.time.departure }
}
}
}

View file

@ -0,0 +1,9 @@
package moe.lava.banksia.core.data
import moe.lava.banksia.core.data.repositories.StopTimeRepository
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
internal actual val platformModule = module {
singleOf(::StopTimeRepository)
}

View file

@ -0,0 +1,13 @@
package moe.lava.banksia.core.data.repositories
import kotlinx.coroutines.flow.flow
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.data.sources.stoptime.StopTimeLocalDataSource
actual class StopTimeRepository internal constructor(
private val local: StopTimeLocalDataSource,
) {
actual suspend fun getForStop(id: String, date: LocalDate) = flow {
emit(local.getAtStop(id, date))
}
}

View file

@ -47,6 +47,7 @@ sqldelight {
databases {
register("BanksiaDatabase") {
packageName.set("moe.lava.banksia.core.sqld")
schemaOutputDirectory.set(file("src/commonMain/sqldelight/schema"))
}
}
}

View file

@ -5,10 +5,10 @@ import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
actual class DatabaseManager actual constructor() : KoinComponent {
actual class DatabaseManager : KoinComponent {
actual val database by lazy {
val ctx = get<Context>().applicationContext
val driver = AndroidSqliteDriver(BanksiaDatabase.Schema, ctx, "timetable.db")
val driver = AndroidSqliteDriver(BanksiaDatabase.Schema, ctx, "${DBNAME}.db")
BanksiaDatabase(driver)
}
}

View file

@ -1,7 +1,7 @@
package moe.lava.banksia.core.sqld
import org.koin.core.component.KoinComponent
internal const val DBNAME = "timetable"
expect class DatabaseManager() : KoinComponent {
expect class DatabaseManager() {
val database: BanksiaDatabase
}

View file

@ -11,6 +11,7 @@ val sqldDiModule = module {
factory { get<BanksiaDatabase>().serviceExceptionQueries }
factory { get<BanksiaDatabase>().shapeQueries }
factory { get<BanksiaDatabase>().stopQueries }
factory { get<BanksiaDatabase>().stoppingPatternQueries }
factory { get<BanksiaDatabase>().stopTimeQueries }
factory { get<BanksiaDatabase>().tripQueries }
}

View file

@ -3,23 +3,25 @@ package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.FutureTime
import moe.lava.banksia.core.model.FutureTime.Companion.asInt
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.TimeType
import moe.lava.banksia.core.sqld.StopTime as DbStopTime
fun DbStopTime.asModel() = StopTime(
tripId = tripId,
patternId = patternId,
stopId = stopId,
arrivalTime = FutureTime.fromInt(arrivalTime.toInt()),
departureTime = FutureTime.fromInt(departureTime.toInt()),
headsign = null,
time = TimeType.Undated(
arrival = FutureTime.fromInt((departureTime + arrivalDelta).toInt()),
departure = FutureTime.fromInt(departureTime.toInt()),
),
pickupType = pickupType.toInt(),
dropOffType = dropOffType.toInt(),
)
fun StopTime.asDb() = DbStopTime(
tripId = tripId,
fun StopTime.Undated.asDb() = DbStopTime(
patternId = patternId,
stopId = stopId,
arrivalTime = arrivalTime.asInt().toLong(),
departureTime = departureTime.asInt().toLong(),
arrivalDelta = (time.arrival.asInt() - time.departure.asInt()).toLong(),
departureTime = time.departure.asInt().toLong(),
pickupType = pickupType.toLong(),
dropOffType = dropOffType.toLong(),
)

View file

@ -0,0 +1,22 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.StoppingPattern
import moe.lava.banksia.core.sqld.StoppingPattern as DbStoppingPattern
fun DbStoppingPattern.asModel(stoptimes: List<StopTime.Undated>) = StoppingPattern.Undated(
id = id,
routeId = routeId,
shapeId = shapeId,
headsign = headsign,
wheelchairAccessible = wheelchairAccessible == 1L,
stoptimes = stoptimes,
)
fun StoppingPattern.Undated.asDb() = DbStoppingPattern(
id = id,
routeId = routeId,
shapeId = shapeId,
headsign = headsign,
wheelchairAccessible = if (wheelchairAccessible) 1L else 0L,
)

View file

@ -1,32 +1,27 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Service
import moe.lava.banksia.core.model.StoppingPattern
import moe.lava.banksia.core.model.Trip
import moe.lava.banksia.core.sqld.Trip as DbTrip
fun DbTrip.asModel(service: Service): Trip {
fun DbTrip.asModel(pattern: StoppingPattern.Undated, service: Service): Trip.Undated {
if (serviceId != service.id) {
throw IllegalArgumentException("trip and service id mismatch (${serviceId} != ${service.id})")
}
return Trip(
id = id,
routeId = routeId,
id = gtfsId,
pattern = pattern,
service = service,
shapeId = shapeId,
tripHeadsign = tripHeadsign,
directionId = directionId,
blockId = blockId,
wheelchairAccessible = wheelchairAccessible == 1L
directionId = directionId.toInt(),
blockId = blockId.toString(),
)
}
fun Trip.asDb() = DbTrip(
id = id,
routeId = routeId,
fun Trip.Undated.asDb() = DbTrip(
gtfsId = id,
patternId = pattern.id,
serviceId = service.id,
shapeId = shapeId,
tripHeadsign = tripHeadsign,
directionId = directionId,
blockId = blockId,
wheelchairAccessible = if (wheelchairAccessible) 1L else 0L
directionId = directionId.toLong(),
blockId = blockId?.toLong(),
)

View file

@ -15,7 +15,7 @@ getAll:
SELECT * FROM Stop;
getAllParentless:
SELECT * FROM Stop WHERE platformCode IS NULL AND parent IS NULL;
SELECT * FROM Stop WHERE platformCode IS NOT NULL AND parent IS NULL;
get:
SELECT * FROM Stop WHERE id == ?;
@ -32,8 +32,8 @@ UPDATE Stop SET parent = ? WHERE id IN ?;
getByRoute:
SELECT Stop.* FROM Stop
INNER JOIN StopTime ON StopTime.stopId == Stop.id
INNER JOIN Trip ON Trip.id == StopTime.tripId
WHERE Trip.routeId == :id
INNER JOIN StoppingPattern ON StoppingPattern.id == StopTime.patternId
WHERE StoppingPattern.routeId == :id
GROUP BY Stop.id;
-- I vibecoded this, sorry
@ -41,8 +41,8 @@ getParentsByRoute:
WITH RECURSIVE Tree AS (
SELECT Stop.* FROM Stop
INNER JOIN StopTime ON StopTime.stopId == Stop.id
INNER JOIN Trip ON Trip.id == StopTime.tripId
WHERE Trip.routeId == :id
INNER JOIN StoppingPattern ON StoppingPattern.id == StopTime.patternId
WHERE StoppingPattern.routeId == :id
GROUP BY Stop.id
UNION ALL

View file

@ -1,11 +1,11 @@
CREATE TABLE StopTime (
tripId TEXT NOT NULL REFERENCES Trip (id),
patternId INTEGER NOT NULL REFERENCES StoppingPattern (id),
stopId TEXT NOT NULL REFERENCES Stop (id),
arrivalTime INTEGER NOT NULL,
arrivalDelta INTEGER NOT NULL,
departureTime INTEGER NOT NULL,
pickupType INTEGER NOT NULL,
dropOffType INTEGER NOT NULL,
PRIMARY KEY (tripId, stopId)
PRIMARY KEY (patternId, stopId)
) WITHOUT ROWID;
CREATE INDEX idx_StopTime_stopId ON StopTime (stopId);
@ -16,8 +16,9 @@ INSERT OR REPLACE INTO StopTime VALUES ?;
getForStopDated:
SELECT DISTINCT StopTime.* FROM StopTime
INNER JOIN Service ON Service.days & :days = :days AND :date BETWEEN Service.start AND Service.`end`
INNER JOIN Trip ON Trip.serviceId == Service.id
LEFT JOIN ServiceException ON ServiceException.serviceId == Service.id AND ServiceException.date == :date
WHERE StopTime.tripId == Trip.id
INNER JOIN Trip ON Trip.serviceId == Service.id
INNER JOIN StoppingPattern ON StoppingPattern.id == Trip.patternId
WHERE StopTime.patternId == StoppingPattern.id
AND StopTime.stopId IN (SELECT Stop.id FROM Stop WHERE Stop.parent == :stopId OR Stop.id == :stopId)
AND ServiceException.type IS NULL;

View file

@ -0,0 +1,10 @@
CREATE TABLE StoppingPattern (
id INTEGER PRIMARY KEY NOT NULL,
routeId TEXT NOT NULL REFERENCES Route (id),
shapeId TEXT NOT NULL REFERENCES Shape (id),
headsign TEXT NOT NULL,
wheelchairAccessible INTEGER NOT NULL
);
insert:
INSERT OR REPLACE INTO StoppingPattern VALUES ?;

View file

@ -1,14 +1,12 @@
CREATE TABLE Trip (
id TEXT PRIMARY KEY NOT NULL,
routeId TEXT NOT NULL REFERENCES Route (id),
gtfsId TEXT PRIMARY KEY NOT NULL,
patternId INTEGER NOT NULL REFERENCES StoppingPattern (id),
serviceId TEXT NOT NULL REFERENCES Service (id),
shapeId TEXT NOT NULL REFERENCES Shape (id),
tripHeadsign TEXT NOT NULL,
directionId TEXT NOT NULL,
blockId TEXT,
wheelchairAccessible INTEGER NOT NULL
blockId INTEGER,
directionId INTEGER NOT NULL
);
CREATE INDEX idx_Trip_patternId ON Trip (patternId);
CREATE INDEX idx_Trip_serviceId ON Trip (serviceId);
insert:

Binary file not shown.

View file

@ -1,10 +1,11 @@
package moe.lava.banksia.core.sqld
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import org.koin.core.component.KoinComponent
actual class DatabaseManager actual constructor() : org.koin.core.component.KoinComponent {
actual class DatabaseManager : KoinComponent {
actual val database by lazy {
val driver = NativeSqliteDriver(BanksiaDatabase.Schema, "timetable.db")
val driver = NativeSqliteDriver(BanksiaDatabase.Schema, "${DBNAME}.db")
BanksiaDatabase(driver)
}
}

View file

@ -11,9 +11,7 @@ import java.io.File
import java.util.Properties
import kotlin.system.exitProcess
private const val DBNAME = "timetable"
actual class DatabaseManager actual constructor() : KoinComponent {
actual class DatabaseManager : KoinComponent {
private var driver = connect()
actual val database get() = BanksiaDatabase(driver)

View file

@ -1,14 +1,43 @@
package moe.lava.banksia.core.model
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
@Serializable
data class StopTime(
val tripId: String,
data class StopTime<T: TimeType>(
val patternId: Long,
val stopId: String,
val arrivalTime: FutureTime,
val departureTime: FutureTime,
val headsign: String?,
val time: T,
val pickupType: Int,
val dropOffType: Int,
) {
typealias Dated = StopTime<TimeType.Dated>
typealias Undated = StopTime<TimeType.Undated>
}
@Serializable
sealed class TimeType {
@Serializable
data class Undated(
val arrival: FutureTime,
val departure: FutureTime,
) : TimeType()
@Serializable
data class Dated(
val arrival: LocalDateTime,
val departure: LocalDateTime,
) : TimeType()
}
fun StopTime<TimeType.Undated>.atDate(date: LocalDate) = StopTime(
patternId = patternId,
stopId = stopId,
time = TimeType.Dated(
arrival = time.arrival.atDate(date),
departure = time.departure.atDate(date),
),
pickupType = pickupType,
dropOffType = dropOffType,
)

View file

@ -1,26 +0,0 @@
package moe.lava.banksia.core.model
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable
@Serializable
data class StopTimeDated(
val tripId: String,
val stopId: String,
val arrivalTime: LocalDateTime,
val departureTime: LocalDateTime,
val headsign: String?,
val pickupType: Int,
val dropOffType: Int,
)
fun StopTime.atDate(date: LocalDate) = StopTimeDated(
tripId = tripId,
stopId = stopId,
arrivalTime = arrivalTime.atDate(date),
departureTime = departureTime.atDate(date),
headsign = headsign,
pickupType = pickupType,
dropOffType = dropOffType,
)

View file

@ -0,0 +1,16 @@
package moe.lava.banksia.core.model
import kotlinx.serialization.Serializable
@Serializable
data class StoppingPattern<T: TimeType>(
val id: Long,
val routeId: String,
val shapeId: String,
val headsign: String,
val wheelchairAccessible: Boolean,
val stoptimes: List<StopTime<T>>,
) {
typealias Dated = StoppingPattern<TimeType.Dated>
typealias Undated = StoppingPattern<TimeType.Undated>
}

View file

@ -3,13 +3,13 @@ package moe.lava.banksia.core.model
import kotlinx.serialization.Serializable
@Serializable
data class Trip(
data class Trip<T: TimeType>(
val id: String,
val routeId: String,
val pattern: StoppingPattern<T>,
val service: Service,
val shapeId: String,
val tripHeadsign: String,
val directionId: String,
val directionId: Int,
val blockId: String?,
val wheelchairAccessible: Boolean,
)
) {
typealias Dated = Trip<TimeType.Dated>
typealias Undated = Trip<TimeType.Undated>
}

View file

@ -18,6 +18,7 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.modules.EmptySerializersModule
import kotlinx.serialization.serializer
import moe.lava.banksia.core.Constants
import moe.lava.banksia.core.model.FutureTime.Companion.asInt
import moe.lava.banksia.core.model.Route
import moe.lava.banksia.core.model.RouteType
import moe.lava.banksia.core.model.Service
@ -25,6 +26,8 @@ import moe.lava.banksia.core.model.ServiceException
import moe.lava.banksia.core.model.Shape
import moe.lava.banksia.core.model.Stop
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.StoppingPattern
import moe.lava.banksia.core.model.TimeType
import moe.lava.banksia.core.model.Trip
import moe.lava.banksia.core.util.Point
import moe.lava.banksia.server.gtfs.structures.GtfsRoute
@ -35,6 +38,8 @@ import moe.lava.banksia.server.gtfs.structures.GtfsStop
import moe.lava.banksia.server.gtfs.structures.GtfsStopTime
import moe.lava.banksia.server.gtfs.structures.GtfsTrip
import java.io.File
import java.nio.ByteBuffer
import java.security.MessageDigest
import java.util.zip.ZipFile
import kotlin.time.ExperimentalTime
@ -46,8 +51,7 @@ sealed class GtfsData {
data class ServiceExceptionChunk(val exceptions: List<ServiceException>) : GtfsData()
data class ShapeChunk(val shapes: List<Shape>) : GtfsData()
data class StopChunk(val stops: List<Stop>) : GtfsData()
data class StopTimeChunk(val stopTimes: List<StopTime>) : GtfsData()
data class TripChunk(val trips: List<Trip>) : GtfsData()
data class TripChunk(val trips: List<Trip.Undated>) : GtfsData()
}
class GtfsParser(
@ -129,7 +133,6 @@ class GtfsParser(
.filter { it.name == "trips.txt" }
.flatMap { fd ->
parseTrips(fd, services)
.also { emit(GtfsData.TripChunk(it)) }
}
.associateBy { it.id }
@ -137,13 +140,53 @@ class GtfsParser(
.filter { it.name == "stop_times.txt" }
.forEach { fd ->
log.info("parsing stop times for ${fd.parent}...")
parseStopTimes(fd, trips) { seq ->
seq.chunked(1000000)
.forEach { emit(GtfsData.StopTimeChunk(it)) }
parseStopTimes(fd) { seq ->
val times = ArrayList<Pair<String, StopTime.Undated>>(1000100)
seq.forEach { pair ->
val (_, stoptime) = pair
if (times.size > 1000000 && stoptime.patternId == 1L) {
emit(GtfsData.TripChunk(processStoptimes(trips, times)))
times.clear()
}
times.add(pair)
}
emit(GtfsData.TripChunk(processStoptimes(trips, times)))
}
}
}
private fun hashCalc(headsign: String, stops: List<StopTime.Undated>): Long {
val inst = MessageDigest.getInstance("SHA-256")
inst.update(headsign.toByteArray())
stops.forEach {
inst.update(it.stopId.toByteArray())
val dint = it.time.departure.asInt()
inst.update((dint).toByte())
inst.update((dint shr 8).toByte())
val aint = it.time.arrival.asInt()
inst.update((aint).toByte())
inst.update((aint shr 8).toByte())
}
val buf = inst.digest().slice(0..7).toByteArray()
buf[0] = 0
buf[1] = 0
return ByteBuffer.wrap(buf).long
}
private fun processStoptimes(trips: Map<String, Trip.Undated>, times: ArrayList<Pair<String, StopTime.Undated>>) =
times.groupBy { it.first }
.map { (tripId, pairs) ->
val trip = trips[tripId]!!
val stoptimes = pairs.map { it.second }
val hash = hashCalc(trip.pattern.headsign, stoptimes)
trip.copy(pattern = trip.pattern.copy(
id = hash,
stoptimes = stoptimes.map { it.copy(patternId = hash) }
))
}
private fun parseRoutes(fd: File) =
fd.parseCsv<GtfsRoute>()
.map { with(it) {
@ -180,16 +223,17 @@ class GtfsParser(
)
} }
private inline fun parseStopTimes(fd: File, trips: Map<String, Trip>, block: (Sequence<StopTime>) -> Unit) =
private inline fun parseStopTimes(fd: File, block: (Sequence<Pair<String, StopTime.Undated>>) -> Unit) =
fd.parseCsvSequence<GtfsStopTime> { seq ->
seq
.map { with(it) {
StopTime(
tripId = trip_id,
it.trip_id to StopTime(
patternId = stop_sequence,
stopId = stop_id,
arrivalTime = GtfsStopTime.parseGtfsTime(arrival_time),
departureTime = GtfsStopTime.parseGtfsTime(departure_time),
headsign = stop_headsign.ifEmpty { trips[trip_id]!!.tripHeadsign },
time = TimeType.Undated(
arrival = GtfsStopTime.parseGtfsTime(arrival_time),
departure = GtfsStopTime.parseGtfsTime(departure_time),
),
pickupType = pickup_type,
dropOffType = drop_off_type,
)
@ -230,15 +274,19 @@ class GtfsParser(
private fun parseTrips(fd: File, services: Map<String, Service>) =
fd.parseCsv<GtfsTrip>()
.map { with(it) {
Trip(
Trip.Undated(
id = trip_id,
routeId = route_id,
pattern = StoppingPattern(
id = 0,
routeId = route_id,
shapeId = shape_id,
headsign = trip_headsign,
wheelchairAccessible = wheelchair_accessible == "1",
stoptimes = listOf()
),
service = services["${fd.parentFile.name}_${service_id}"]!!,
shapeId = shape_id,
tripHeadsign = trip_headsign,
directionId = direction_id,
directionId = direction_id.toInt(),
blockId = block_id.ifEmpty { null },
wheelchairAccessible = wheelchair_accessible == "1",
)
} }

View file

@ -10,7 +10,7 @@ internal data class GtfsStopTime(
val arrival_time: String,
val departure_time: String,
val stop_id: String,
val stop_sequence: Int,
val stop_sequence: Long,
val stop_headsign: String,
val pickup_type: Int,
val drop_off_type: Int,

View file

@ -151,7 +151,7 @@ fun Application.module() {
)
.executeAsList()
.map { it.asModel().atDate(date) }
.sortedBy { it.departureTime }
.sortedBy { it.time.departure }
}
call.respond(times)
}

View file

@ -6,7 +6,6 @@ import moe.lava.banksia.core.model.Service
import moe.lava.banksia.core.model.ServiceException
import moe.lava.banksia.core.model.Shape
import moe.lava.banksia.core.model.Stop
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.Trip
import moe.lava.banksia.core.sqld.DatabaseManager
import moe.lava.banksia.core.sqld.mappers.asDb
@ -30,7 +29,6 @@ class GtfsImporter(
is GtfsData.ServiceExceptionChunk -> database.addServiceExceptions(chunk.exceptions)
is GtfsData.ShapeChunk -> database.addShapes(chunk.shapes)
is GtfsData.StopChunk -> database.addStops(chunk.stops)
is GtfsData.StopTimeChunk -> database.addStopTimes(chunk.stopTimes)
is GtfsData.TripChunk -> database.addTrips(chunk.trips)
}
}
@ -101,21 +99,15 @@ class GtfsImporter(
log.info("done")
}
private fun Database.addStopTimes(stopTimes: List<StopTime>) {
log.info("inserting ${stopTimes.size} stoptimes...")
stopTimeQueries.transaction {
stopTimes.forEach {
stopTimeQueries.insert(it.asDb())
}
}
log.info("done")
}
private fun Database.addTrips(trips: List<Trip>) {
private fun Database.addTrips(trips: List<Trip.Undated>) {
log.info("inserting ${trips.size} trips...")
tripQueries.transaction {
trips.forEach {
tripQueries.insert(it.asDb())
transaction {
trips.forEach { trip ->
stoppingPatternQueries.insert(trip.pattern.asDb())
trip.pattern.stoptimes.forEach { stoptime ->
stopTimeQueries.insert(stoptime.asDb())
}
tripQueries.insert(trip.asDb())
}
}
log.info("done")

View file

@ -14,7 +14,7 @@
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="trace">
<root level="debug">
<appender-ref ref="FILE"/>
<appender-ref ref="STDOUT"/>
</root>

View file

@ -39,6 +39,7 @@ include(":core")
include(":core:data")
include(":core:data:client")
include(":core:data:server")
include(":core:data:stoptime")
include(":core:sqld")
include(":ui")
include(":ui:maps")

View file

@ -231,30 +231,36 @@ class MapScreenViewModel(
)
}
val departures = stopTimeRepository.getForStop(id)
.filter { !it.headsign.isNullOrBlank() }
.groupBy { it.headsign!! }
.map { (headsign, stopTimes) ->
val now = Clock.System.now()
val times = stopTimes
.map { it.arrivalTime.toInstant(TimeZone.currentSystemDefault()) }
.filter { it >= (now - 1.minutes) }
.joinToString(" | ") {
val diff = (it - now).inWholeMinutes.coerceAtLeast(0)
if (diff >= 65) {
"${((diff + 30.0) / 60.0).toInt()}hr"
} else {
"${diff}mn"
}
stopTimeRepository.getForStop(id)
.onEach { stoptimes ->
val departures = stoptimes
// .filter { !it.headsign.isNullOrBlank() }
// .groupBy { it.headsign!! }
.groupBy { it.stopId } // TODO: Placeholder
.map { (headsign, stopTimes) ->
val now = Clock.System.now()
val times = stopTimes
.map { it.time.arrival.toInstant(TimeZone.currentSystemDefault()) }
.filter { it >= (now - 1.minutes) }
.joinToString(" | ") {
val diff = (it - now).inWholeMinutes.coerceAtLeast(0)
if (diff >= 65) {
"${((diff + 30.0) / 60.0).toInt()}hr"
} else {
"${diff}mn"
}
}
StopInfoPanelState.Departure(headsign, times)
}
StopInfoPanelState.Departure(headsign, times)
iInfoState.update {
if (it !is StopInfoPanelState)
it
else
it.copy(departures = departures)
}
}
iInfoState.update {
if (it !is StopInfoPanelState)
it
else
it.copy(departures = departures)
}
.launchIn(viewModelScope)
}
/*private suspend fun buildPolylines(route: PtvRoute) {