Pith - kiln
kiln/app/src/main/java/com/vgmlr/kiln/KilnDayStats.kt [6.0 kb]
Modified: 20:13:54 158 026 (24 Aug 026)
5 Days Ago
package com.vgmlr.kiln

import java.time.LocalDate
import java.time.YearMonth
import java.time.temporal.ChronoUnit
import java.time.DayOfWeek
import java.time.temporal.TemporalAdjusters
import kotlin.math.roundToInt

private data class MoodTally(
    val top: Int?,
    val topCount: Int,
    val second: Int?,
    val secondCount: Int,
    val total: Int
)

data class KilnDayStats(
    val daysToPeriod: Long?,
    val daysToPreMenstrual: Long?,
    val topMood: Int?,
    val topMoodCount: Int,
    val secondMood: Int?,
    val secondMoodCount: Int,
    val moodDays: Int,
    val intermissionAvg: Float,
    val preMenstrualAvg: Float?,
    val menstruationAvg: Float?
) {
    companion object {
        private fun scanSpan(cycle: Int) = cycle.toLong() + 1

        fun of(
            date: LocalDate,
            today: LocalDate,
            records: List<KilnDayRecord>,
            buttons: KilnButtonSet,
            starts: List<LocalDate>,
            cycle: Int,
            hueOf: (LocalDate) -> Float,
            preMenstrualAvg: Float?
        ): KilnDayStats {
            val moods = moodTally(records, YearMonth.from(date))
            return KilnDayStats(
                daysToPeriod = daysToPeriod(today, starts, cycle),
                daysToPreMenstrual = preMenstrualIn(today, starts, cycle, hueOf),
                topMood = moods.top,
                topMoodCount = moods.topCount,
                secondMood = moods.second,
                secondMoodCount = moods.secondCount,
                moodDays = moods.total,
                intermissionAvg = KilnCyclePredictor.avgCycleExact(starts),
                preMenstrualAvg = preMenstrualAvg,
                menstruationAvg = KilnCyclePredictor.avgPeriodLength(records, buttons)
            )
        }
        
        fun preMenstrualSeries(
            starts: List<LocalDate>,
            cycle: Int,
            hueOf: (LocalDate) -> Float
        ): List<Pair<LocalDate, Int>> =
            KilnCyclePredictor.measurableStarts(starts)
                .mapNotNull { s -> leadInto(s, cycle, hueOf)?.let { s to it.toInt() } }

        fun preMenstrualAvg(
            starts: List<LocalDate>,
            cycle: Int,
            hueOf: (LocalDate) -> Float
        ): Float? {
            val leads = preMenstrualSeries(starts, cycle, hueOf).map { it.second }
            return if (leads.isEmpty()) null else leads.average().toFloat()
        }

        private fun leadInto(start: LocalDate, cycle: Int, hueOf: (LocalDate) -> Float): Long? {
            if (!KilnGradientPalette.isPreMenstrual(hueOf(start))) return null
            for (i in 1L..scanSpan(cycle)) {
                if (!KilnGradientPalette.isPreMenstrual(hueOf(start.minusDays(i)))) return i - 1
            }
            return null
        }

        private val MOOD_VALUE = intArrayOf(-2, -1, 0, 1, 2)

        fun moodSeries(records: List<KilnDayRecord>): List<Pair<LocalDate, Int>> =
            records.mapNotNull { r ->
                r.mood?.let { LocalDate.ofEpochDay(r.epochDay) to it }
            }
                .groupBy { (d, _) -> d.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) }
                .map { (week, days) -> week to score(days.map { it.second }) }
                .sortedBy { it.first }

        private fun score(moods: List<Int>): Int =
            moods.map { MOOD_VALUE.getOrElse(it) { 0 } }.average().roundToInt()

        fun daysToPeriod(date: LocalDate, starts: List<LocalDate>, cycle: Int): Long? {
            if (starts.isEmpty()) return null
            starts.firstOrNull { !it.isBefore(date) }
                ?.let { return ChronoUnit.DAYS.between(date, it) }
            val last = starts.last()
            val gap = ChronoUnit.DAYS.between(last, date)
            val k = (gap + cycle - 1) / cycle
            return ChronoUnit.DAYS.between(date, last.plusDays(k * cycle))
        }

        fun preMenstrualOnset(
            starts: List<LocalDate>,
            cycle: Int,
            hueOf: (LocalDate) -> Float
        ): LocalDate? {
            val last = starts.lastOrNull() ?: return null
            var prev = KilnGradientPalette.isPreMenstrual(hueOf(last.minusDays(1)))
            for (i in 0L..scanSpan(cycle)) {
                val day = last.plusDays(i)
                val now = KilnGradientPalette.isPreMenstrual(hueOf(day))
                if (now && !prev) return day
                prev = now
            }
            return null
        }

        fun preMenstrualIn(
            date: LocalDate,
            starts: List<LocalDate>,
            cycle: Int,
            hueOf: (LocalDate) -> Float
        ): Long? {
            val onset = preMenstrualOnset(starts, cycle, hueOf) ?: return null
            return ChronoUnit.DAYS.between(date, onset).coerceAtLeast(0L)
        }

        fun preMenstrualIn(
            date: LocalDate,
            records: List<KilnDayRecord>,
            buttons: KilnButtonSet
        ): Long? {
            val starts = KilnCyclePredictor.periodStarts(records, buttons)
            if (starts.isEmpty()) return null
            val cycle = KilnCyclePredictor.avgCycleLength(starts)
            return preMenstrualIn(
                date, starts, cycle,
                KilnGradientPalette.lookup(records, buttons, starts, cycle)
            )
        }

        private fun moodTally(records: List<KilnDayRecord>, month: YearMonth): MoodTally {
            val counts = IntArray(5)
            var total = 0
            for (r in records) {
                val m = r.mood ?: continue
                if (m !in counts.indices) continue
                if (YearMonth.from(LocalDate.ofEpochDay(r.epochDay)) != month) continue
                counts[m]++
                total++
            }
            if (total == 0) return MoodTally(null, 0, null, 0, 0)
            val ranked = counts.indices.filter { counts[it] > 0 }
                .sortedByDescending { counts[it] }
            val top = ranked[0]
            val second = ranked.getOrNull(1)
            return MoodTally(top, counts[top], second, second?.let { counts[it] } ?: 0, total)
        }
    }
}
Updates
Stave - Android 158.026
Kerf - Android 157.026
Kiln - Android 157.026
Wedge - Android 156.026
Whittle - Linux 155.026

Menu
Calendar
Project Tin (024/029)
Miter
RSS Feed
User Avatar
@vgmlr
=SUM(parts)
0.00262
260,740 (+1,154)