|
| 1 | +package com.evsuite.hardware.telemetry.model |
| 2 | + |
| 3 | +import kotlin.math.abs |
| 4 | + |
| 5 | +/** |
| 6 | + * The three-coefficient normal-equations fit both consumption models are built on. |
| 7 | + * |
| 8 | + * `constant + a × speed² + b × |outside − comfort|` is the same shape whether the left-hand |
| 9 | + * side is kWh per 100 km or percent of charge per 100 km, and the arithmetic that solves it |
| 10 | + * has no opinion about units. It lives here so there is one of it: a second copy would drift |
| 11 | + * from this one on the day someone fixes a pivot and only remembers one file. |
| 12 | + * |
| 13 | + * O(1) working memory, O(n) in samples. Call from a worker thread. |
| 14 | + */ |
| 15 | +internal class LeastSquares3(private val features: Int = FEATURES) { |
| 16 | + private val matrix = Array(features) { DoubleArray(features) } |
| 17 | + private val vector = DoubleArray(features) |
| 18 | + |
| 19 | + init { |
| 20 | + require(features in 2..FEATURES) { "two or three features" } |
| 21 | + } |
| 22 | + |
| 23 | + fun add(speedKmh: Double, outsideTempCelsius: Double, consumption: Double) { |
| 24 | + val speedSquared = speedKmh * speedKmh |
| 25 | + val temperatureOffset = abs(outsideTempCelsius - EnergyModel.COMFORT_TEMP_CELSIUS) |
| 26 | + for (row in 0 until features) { |
| 27 | + val rowValue = feature(row, speedSquared, temperatureOffset) |
| 28 | + vector[row] += rowValue * consumption |
| 29 | + for (column in 0 until features) { |
| 30 | + matrix[row][column] += rowValue * |
| 31 | + feature(column, speedSquared, temperatureOffset) |
| 32 | + } |
| 33 | + } |
| 34 | + } |
| 35 | + |
| 36 | + private fun feature(index: Int, speedSquared: Double, temperatureOffset: Double) = |
| 37 | + when (index) { |
| 38 | + 0 -> 1.0 |
| 39 | + 1 -> speedSquared |
| 40 | + else -> temperatureOffset |
| 41 | + } |
| 42 | + |
| 43 | + /** |
| 44 | + * The coefficients, or null when the points do not determine them. |
| 45 | + * |
| 46 | + * Two features is not a lesser fit, it is a different claim: a driver who has only ever |
| 47 | + * recorded trips at one outside temperature has no evidence about temperature, and a |
| 48 | + * thermal coefficient invented from that would be read as knowledge. The envelope keeps |
| 49 | + * the narrow range, so such a model refuses the cold rather than guessing at it. |
| 50 | + */ |
| 51 | + fun solve(): DoubleArray? { |
| 52 | + val augmented = Array(features) { row -> |
| 53 | + DoubleArray(features + 1) { column -> |
| 54 | + if (column == features) vector[row] else matrix[row][column] |
| 55 | + } |
| 56 | + } |
| 57 | + for (column in 0 until features) { |
| 58 | + val pivot = (column until features).maxBy { abs(augmented[it][column]) } |
| 59 | + if (abs(augmented[pivot][column]) < MIN_PIVOT) return null |
| 60 | + val swap = augmented[column] |
| 61 | + augmented[column] = augmented[pivot] |
| 62 | + augmented[pivot] = swap |
| 63 | + val divisor = augmented[column][column] |
| 64 | + for (index in column..features) augmented[column][index] /= divisor |
| 65 | + for (row in 0 until features) { |
| 66 | + if (row == column) continue |
| 67 | + val factor = augmented[row][column] |
| 68 | + for (index in column..features) { |
| 69 | + augmented[row][index] -= factor * augmented[column][index] |
| 70 | + } |
| 71 | + } |
| 72 | + } |
| 73 | + return DoubleArray(features) { augmented[it][features] } |
| 74 | + } |
| 75 | + |
| 76 | + private companion object { |
| 77 | + const val FEATURES = 3 |
| 78 | + const val MIN_PIVOT = 1e-9 |
| 79 | + } |
| 80 | +} |
0 commit comments