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 { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation(projects.core) 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.Constants
import moe.lava.banksia.core.data.repositories.ClientRouteRepository import moe.lava.banksia.core.data.repositories.ClientRouteRepository
import moe.lava.banksia.core.data.repositories.ClientStopRepository 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.RouteRepository
import moe.lava.banksia.core.data.repositories.StopRepository 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.RouteLocalDataSource
import moe.lava.banksia.core.data.sources.route.RouteRemoteDataSource 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.StopLocalDataSource
import moe.lava.banksia.core.data.sources.stop.StopRemoteDataSource 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.sqld.sqldDiModule
import moe.lava.banksia.core.util.log import moe.lava.banksia.core.util.log
import moe.lava.banksia.data.ptv.PtvService import moe.lava.banksia.data.ptv.PtvService
@ -29,6 +25,7 @@ import org.koin.dsl.module
val clientDataDiModule = module { val clientDataDiModule = module {
includes(sqldDiModule) includes(sqldDiModule)
includes(stopTimeDataDiModule)
// HTTP Clients // HTTP Clients
singleOf(::PtvService) singleOf(::PtvService)
@ -56,11 +53,8 @@ val clientDataDiModule = module {
singleOf(::RouteRemoteDataSource) singleOf(::RouteRemoteDataSource)
singleOf(::StopLocalDataSource) singleOf(::StopLocalDataSource)
singleOf(::StopRemoteDataSource) singleOf(::StopRemoteDataSource)
singleOf(::StopTimeLocalDataSource)
singleOf(::StopTimeRemoteDataSource)
// Repositories // Repositories
singleOf(::ClientRouteRepository) bind RouteRepository::class singleOf(::ClientRouteRepository) bind RouteRepository::class
singleOf(::ClientStopRepository) bind StopRepository::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.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn import kotlinx.datetime.todayIn
import moe.lava.banksia.core.model.StopTimeDated import moe.lava.banksia.core.model.StopTime
import kotlin.time.Clock import kotlin.time.Clock
internal class StopTimeRemoteDataSource( internal class StopTimeRemoteDataSource(
@ -16,21 +16,9 @@ internal class StopTimeRemoteDataSource(
suspend fun getAtStop( suspend fun getAtStop(
stopId: String, stopId: String,
date: LocalDate? = Clock.System.todayIn(TimeZone.currentSystemDefault()), date: LocalDate? = Clock.System.todayIn(TimeZone.currentSystemDefault()),
): List<StopTimeDated> { ): List<StopTime.Dated> {
return client.get("stoptimes/by_stop/${stopId}") { return client.get("stoptimes/by_stop/${stopId}") {
parameter("date", date) 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.IO
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import moe.lava.banksia.core.model.StopTime
import kotlinx.datetime.todayIn
import moe.lava.banksia.core.model.StopTimeDated
import moe.lava.banksia.core.model.atDate import moe.lava.banksia.core.model.atDate
import moe.lava.banksia.core.sqld.StopTimeQueries import moe.lava.banksia.core.sqld.StopTimeQueries
import moe.lava.banksia.core.sqld.mappers.asModel import moe.lava.banksia.core.sqld.mappers.asModel
import moe.lava.banksia.core.util.serialise 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( internal class StopTimeLocalDataSource : KoinComponent {
private val queries: StopTimeQueries, private val queries get() = get<StopTimeQueries>()
) {
suspend fun getAtStop( suspend fun getAtStop(stopId: String, date: LocalDate): List<StopTime.Dated> {
stopId: String, return withContext(context = Dispatchers.IO) {
date: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
): List<StopTimeDated> {
return withContext(Dispatchers.IO) {
queries queries
.getForStopDated( .getForStopDated(
listOf(date.dayOfWeek).serialise().toLong(), listOf(date.dayOfWeek).serialise().toLong(),
@ -29,7 +25,7 @@ internal class StopTimeLocalDataSource(
) )
.executeAsList() .executeAsList()
.map { it.asModel().atDate(date) } .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 { databases {
register("BanksiaDatabase") { register("BanksiaDatabase") {
packageName.set("moe.lava.banksia.core.sqld") 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.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
actual class DatabaseManager actual constructor() : KoinComponent { actual class DatabaseManager : KoinComponent {
actual val database by lazy { actual val database by lazy {
val ctx = get<Context>().applicationContext val ctx = get<Context>().applicationContext
val driver = AndroidSqliteDriver(BanksiaDatabase.Schema, ctx, "timetable.db") val driver = AndroidSqliteDriver(BanksiaDatabase.Schema, ctx, "${DBNAME}.db")
BanksiaDatabase(driver) BanksiaDatabase(driver)
} }
} }

View file

@ -1,7 +1,7 @@
package moe.lava.banksia.core.sqld 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 val database: BanksiaDatabase
} }

View file

@ -11,6 +11,7 @@ val sqldDiModule = module {
factory { get<BanksiaDatabase>().serviceExceptionQueries } factory { get<BanksiaDatabase>().serviceExceptionQueries }
factory { get<BanksiaDatabase>().shapeQueries } factory { get<BanksiaDatabase>().shapeQueries }
factory { get<BanksiaDatabase>().stopQueries } factory { get<BanksiaDatabase>().stopQueries }
factory { get<BanksiaDatabase>().stoppingPatternQueries }
factory { get<BanksiaDatabase>().stopTimeQueries } factory { get<BanksiaDatabase>().stopTimeQueries }
factory { get<BanksiaDatabase>().tripQueries } 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
import moe.lava.banksia.core.model.FutureTime.Companion.asInt import moe.lava.banksia.core.model.FutureTime.Companion.asInt
import moe.lava.banksia.core.model.StopTime import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.model.TimeType
import moe.lava.banksia.core.sqld.StopTime as DbStopTime import moe.lava.banksia.core.sqld.StopTime as DbStopTime
fun DbStopTime.asModel() = StopTime( fun DbStopTime.asModel() = StopTime(
tripId = tripId, patternId = patternId,
stopId = stopId, stopId = stopId,
arrivalTime = FutureTime.fromInt(arrivalTime.toInt()), time = TimeType.Undated(
departureTime = FutureTime.fromInt(departureTime.toInt()), arrival = FutureTime.fromInt((departureTime + arrivalDelta).toInt()),
headsign = null, departure = FutureTime.fromInt(departureTime.toInt()),
),
pickupType = pickupType.toInt(), pickupType = pickupType.toInt(),
dropOffType = dropOffType.toInt(), dropOffType = dropOffType.toInt(),
) )
fun StopTime.asDb() = DbStopTime( fun StopTime.Undated.asDb() = DbStopTime(
tripId = tripId, patternId = patternId,
stopId = stopId, stopId = stopId,
arrivalTime = arrivalTime.asInt().toLong(), arrivalDelta = (time.arrival.asInt() - time.departure.asInt()).toLong(),
departureTime = departureTime.asInt().toLong(), departureTime = time.departure.asInt().toLong(),
pickupType = pickupType.toLong(), pickupType = pickupType.toLong(),
dropOffType = dropOffType.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 package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Service 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.model.Trip
import moe.lava.banksia.core.sqld.Trip as DbTrip 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) { if (serviceId != service.id) {
throw IllegalArgumentException("trip and service id mismatch (${serviceId} != ${service.id})") throw IllegalArgumentException("trip and service id mismatch (${serviceId} != ${service.id})")
} }
return Trip( return Trip(
id = id, id = gtfsId,
routeId = routeId, pattern = pattern,
service = service, service = service,
shapeId = shapeId, directionId = directionId.toInt(),
tripHeadsign = tripHeadsign, blockId = blockId.toString(),
directionId = directionId,
blockId = blockId,
wheelchairAccessible = wheelchairAccessible == 1L
) )
} }
fun Trip.asDb() = DbTrip( fun Trip.Undated.asDb() = DbTrip(
id = id, gtfsId = id,
routeId = routeId, patternId = pattern.id,
serviceId = service.id, serviceId = service.id,
shapeId = shapeId, directionId = directionId.toLong(),
tripHeadsign = tripHeadsign, blockId = blockId?.toLong(),
directionId = directionId,
blockId = blockId,
wheelchairAccessible = if (wheelchairAccessible) 1L else 0L
) )

View file

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

View file

@ -1,11 +1,11 @@
CREATE TABLE StopTime ( CREATE TABLE StopTime (
tripId TEXT NOT NULL REFERENCES Trip (id), patternId INTEGER NOT NULL REFERENCES StoppingPattern (id),
stopId TEXT NOT NULL REFERENCES Stop (id), stopId TEXT NOT NULL REFERENCES Stop (id),
arrivalTime INTEGER NOT NULL, arrivalDelta INTEGER NOT NULL,
departureTime INTEGER NOT NULL, departureTime INTEGER NOT NULL,
pickupType INTEGER NOT NULL, pickupType INTEGER NOT NULL,
dropOffType INTEGER NOT NULL, dropOffType INTEGER NOT NULL,
PRIMARY KEY (tripId, stopId) PRIMARY KEY (patternId, stopId)
) WITHOUT ROWID; ) WITHOUT ROWID;
CREATE INDEX idx_StopTime_stopId ON StopTime (stopId); CREATE INDEX idx_StopTime_stopId ON StopTime (stopId);
@ -16,8 +16,9 @@ INSERT OR REPLACE INTO StopTime VALUES ?;
getForStopDated: getForStopDated:
SELECT DISTINCT StopTime.* FROM StopTime SELECT DISTINCT StopTime.* FROM StopTime
INNER JOIN Service ON Service.days & :days = :days AND :date BETWEEN Service.start AND Service.`end` 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 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 StopTime.stopId IN (SELECT Stop.id FROM Stop WHERE Stop.parent == :stopId OR Stop.id == :stopId)
AND ServiceException.type IS NULL; 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 ( CREATE TABLE Trip (
id TEXT PRIMARY KEY NOT NULL, gtfsId TEXT PRIMARY KEY NOT NULL,
routeId TEXT NOT NULL REFERENCES Route (id), patternId INTEGER NOT NULL REFERENCES StoppingPattern (id),
serviceId TEXT NOT NULL REFERENCES Service (id), serviceId TEXT NOT NULL REFERENCES Service (id),
shapeId TEXT NOT NULL REFERENCES Shape (id), blockId INTEGER,
tripHeadsign TEXT NOT NULL, directionId INTEGER NOT NULL
directionId TEXT NOT NULL,
blockId TEXT,
wheelchairAccessible INTEGER NOT NULL
); );
CREATE INDEX idx_Trip_patternId ON Trip (patternId);
CREATE INDEX idx_Trip_serviceId ON Trip (serviceId); CREATE INDEX idx_Trip_serviceId ON Trip (serviceId);
insert: insert:

Binary file not shown.

View file

@ -1,10 +1,11 @@
package moe.lava.banksia.core.sqld package moe.lava.banksia.core.sqld
import app.cash.sqldelight.driver.native.NativeSqliteDriver 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 { actual val database by lazy {
val driver = NativeSqliteDriver(BanksiaDatabase.Schema, "timetable.db") val driver = NativeSqliteDriver(BanksiaDatabase.Schema, "${DBNAME}.db")
BanksiaDatabase(driver) BanksiaDatabase(driver)
} }
} }

View file

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

View file

@ -1,14 +1,43 @@
package moe.lava.banksia.core.model package moe.lava.banksia.core.model
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class StopTime( data class StopTime<T: TimeType>(
val tripId: String, val patternId: Long,
val stopId: String, val stopId: String,
val arrivalTime: FutureTime, val time: T,
val departureTime: FutureTime,
val headsign: String?,
val pickupType: Int, val pickupType: Int,
val dropOffType: 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 import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Trip( data class Trip<T: TimeType>(
val id: String, val id: String,
val routeId: String, val pattern: StoppingPattern<T>,
val service: Service, val service: Service,
val shapeId: String, val directionId: Int,
val tripHeadsign: String,
val directionId: String,
val blockId: String?, 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.modules.EmptySerializersModule
import kotlinx.serialization.serializer import kotlinx.serialization.serializer
import moe.lava.banksia.core.Constants 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.Route
import moe.lava.banksia.core.model.RouteType import moe.lava.banksia.core.model.RouteType
import moe.lava.banksia.core.model.Service 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.Shape
import moe.lava.banksia.core.model.Stop import moe.lava.banksia.core.model.Stop
import moe.lava.banksia.core.model.StopTime 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.model.Trip
import moe.lava.banksia.core.util.Point import moe.lava.banksia.core.util.Point
import moe.lava.banksia.server.gtfs.structures.GtfsRoute 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.GtfsStopTime
import moe.lava.banksia.server.gtfs.structures.GtfsTrip import moe.lava.banksia.server.gtfs.structures.GtfsTrip
import java.io.File import java.io.File
import java.nio.ByteBuffer
import java.security.MessageDigest
import java.util.zip.ZipFile import java.util.zip.ZipFile
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@ -46,8 +51,7 @@ sealed class GtfsData {
data class ServiceExceptionChunk(val exceptions: List<ServiceException>) : GtfsData() data class ServiceExceptionChunk(val exceptions: List<ServiceException>) : GtfsData()
data class ShapeChunk(val shapes: List<Shape>) : GtfsData() data class ShapeChunk(val shapes: List<Shape>) : GtfsData()
data class StopChunk(val stops: List<Stop>) : GtfsData() data class StopChunk(val stops: List<Stop>) : GtfsData()
data class StopTimeChunk(val stopTimes: List<StopTime>) : GtfsData() data class TripChunk(val trips: List<Trip.Undated>) : GtfsData()
data class TripChunk(val trips: List<Trip>) : GtfsData()
} }
class GtfsParser( class GtfsParser(
@ -129,7 +133,6 @@ class GtfsParser(
.filter { it.name == "trips.txt" } .filter { it.name == "trips.txt" }
.flatMap { fd -> .flatMap { fd ->
parseTrips(fd, services) parseTrips(fd, services)
.also { emit(GtfsData.TripChunk(it)) }
} }
.associateBy { it.id } .associateBy { it.id }
@ -137,13 +140,53 @@ class GtfsParser(
.filter { it.name == "stop_times.txt" } .filter { it.name == "stop_times.txt" }
.forEach { fd -> .forEach { fd ->
log.info("parsing stop times for ${fd.parent}...") log.info("parsing stop times for ${fd.parent}...")
parseStopTimes(fd, trips) { seq -> parseStopTimes(fd) { seq ->
seq.chunked(1000000) val times = ArrayList<Pair<String, StopTime.Undated>>(1000100)
.forEach { emit(GtfsData.StopTimeChunk(it)) } 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) = private fun parseRoutes(fd: File) =
fd.parseCsv<GtfsRoute>() fd.parseCsv<GtfsRoute>()
.map { with(it) { .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 -> fd.parseCsvSequence<GtfsStopTime> { seq ->
seq seq
.map { with(it) { .map { with(it) {
StopTime( it.trip_id to StopTime(
tripId = trip_id, patternId = stop_sequence,
stopId = stop_id, stopId = stop_id,
arrivalTime = GtfsStopTime.parseGtfsTime(arrival_time), time = TimeType.Undated(
departureTime = GtfsStopTime.parseGtfsTime(departure_time), arrival = GtfsStopTime.parseGtfsTime(arrival_time),
headsign = stop_headsign.ifEmpty { trips[trip_id]!!.tripHeadsign }, departure = GtfsStopTime.parseGtfsTime(departure_time),
),
pickupType = pickup_type, pickupType = pickup_type,
dropOffType = drop_off_type, dropOffType = drop_off_type,
) )
@ -230,15 +274,19 @@ class GtfsParser(
private fun parseTrips(fd: File, services: Map<String, Service>) = private fun parseTrips(fd: File, services: Map<String, Service>) =
fd.parseCsv<GtfsTrip>() fd.parseCsv<GtfsTrip>()
.map { with(it) { .map { with(it) {
Trip( Trip.Undated(
id = trip_id, id = trip_id,
pattern = StoppingPattern(
id = 0,
routeId = route_id, routeId = route_id,
service = services["${fd.parentFile.name}_${service_id}"]!!,
shapeId = shape_id, shapeId = shape_id,
tripHeadsign = trip_headsign, headsign = trip_headsign,
directionId = direction_id,
blockId = block_id.ifEmpty { null },
wheelchairAccessible = wheelchair_accessible == "1", wheelchairAccessible = wheelchair_accessible == "1",
stoptimes = listOf()
),
service = services["${fd.parentFile.name}_${service_id}"]!!,
directionId = direction_id.toInt(),
blockId = block_id.ifEmpty { null },
) )
} } } }

View file

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

View file

@ -151,7 +151,7 @@ fun Application.module() {
) )
.executeAsList() .executeAsList()
.map { it.asModel().atDate(date) } .map { it.asModel().atDate(date) }
.sortedBy { it.departureTime } .sortedBy { it.time.departure }
} }
call.respond(times) 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.ServiceException
import moe.lava.banksia.core.model.Shape import moe.lava.banksia.core.model.Shape
import moe.lava.banksia.core.model.Stop 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.model.Trip
import moe.lava.banksia.core.sqld.DatabaseManager import moe.lava.banksia.core.sqld.DatabaseManager
import moe.lava.banksia.core.sqld.mappers.asDb import moe.lava.banksia.core.sqld.mappers.asDb
@ -30,7 +29,6 @@ class GtfsImporter(
is GtfsData.ServiceExceptionChunk -> database.addServiceExceptions(chunk.exceptions) is GtfsData.ServiceExceptionChunk -> database.addServiceExceptions(chunk.exceptions)
is GtfsData.ShapeChunk -> database.addShapes(chunk.shapes) is GtfsData.ShapeChunk -> database.addShapes(chunk.shapes)
is GtfsData.StopChunk -> database.addStops(chunk.stops) is GtfsData.StopChunk -> database.addStops(chunk.stops)
is GtfsData.StopTimeChunk -> database.addStopTimes(chunk.stopTimes)
is GtfsData.TripChunk -> database.addTrips(chunk.trips) is GtfsData.TripChunk -> database.addTrips(chunk.trips)
} }
} }
@ -101,21 +99,15 @@ class GtfsImporter(
log.info("done") log.info("done")
} }
private fun Database.addStopTimes(stopTimes: List<StopTime>) { private fun Database.addTrips(trips: List<Trip.Undated>) {
log.info("inserting ${stopTimes.size} stoptimes...")
stopTimeQueries.transaction {
stopTimes.forEach {
stopTimeQueries.insert(it.asDb())
}
}
log.info("done")
}
private fun Database.addTrips(trips: List<Trip>) {
log.info("inserting ${trips.size} trips...") log.info("inserting ${trips.size} trips...")
tripQueries.transaction { transaction {
trips.forEach { trips.forEach { trip ->
tripQueries.insert(it.asDb()) stoppingPatternQueries.insert(trip.pattern.asDb())
trip.pattern.stoptimes.forEach { stoptime ->
stopTimeQueries.insert(stoptime.asDb())
}
tripQueries.insert(trip.asDb())
} }
} }
log.info("done") 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> <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder> </encoder>
</appender> </appender>
<root level="trace"> <root level="debug">
<appender-ref ref="FILE"/> <appender-ref ref="FILE"/>
<appender-ref ref="STDOUT"/> <appender-ref ref="STDOUT"/>
</root> </root>

View file

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

View file

@ -231,13 +231,16 @@ class MapScreenViewModel(
) )
} }
val departures = stopTimeRepository.getForStop(id) stopTimeRepository.getForStop(id)
.filter { !it.headsign.isNullOrBlank() } .onEach { stoptimes ->
.groupBy { it.headsign!! } val departures = stoptimes
// .filter { !it.headsign.isNullOrBlank() }
// .groupBy { it.headsign!! }
.groupBy { it.stopId } // TODO: Placeholder
.map { (headsign, stopTimes) -> .map { (headsign, stopTimes) ->
val now = Clock.System.now() val now = Clock.System.now()
val times = stopTimes val times = stopTimes
.map { it.arrivalTime.toInstant(TimeZone.currentSystemDefault()) } .map { it.time.arrival.toInstant(TimeZone.currentSystemDefault()) }
.filter { it >= (now - 1.minutes) } .filter { it >= (now - 1.minutes) }
.joinToString(" | ") { .joinToString(" | ") {
val diff = (it - now).inWholeMinutes.coerceAtLeast(0) val diff = (it - now).inWholeMinutes.coerceAtLeast(0)
@ -249,6 +252,7 @@ class MapScreenViewModel(
} }
StopInfoPanelState.Departure(headsign, times) StopInfoPanelState.Departure(headsign, times)
} }
iInfoState.update { iInfoState.update {
if (it !is StopInfoPanelState) if (it !is StopInfoPanelState)
it it
@ -256,6 +260,8 @@ class MapScreenViewModel(
it.copy(departures = departures) it.copy(departures = departures)
} }
} }
.launchIn(viewModelScope)
}
/*private suspend fun buildPolylines(route: PtvRoute) { /*private suspend fun buildPolylines(route: PtvRoute) {
val routeWithGeo = if (route.geopath.isEmpty()) val routeWithGeo = if (route.geopath.isEmpty())