From 3fbb4b904c84c3c3a1fc1071e698d759a7e82fd9 Mon Sep 17 00:00:00 2001 From: brandl Date: Fri, 13 Dec 2024 10:21:12 +0100 Subject: [PATCH] v1.0.3 release --- build.gradle.kts | 5 +- docs/userguide/docs/changes.md | 4 + docs/userguide/docs/monitors.md | 12 +- docs/userguide/docs/setup.md | 4 +- experimental/optimization/build.gradle.kts | 2 +- modules/kravis/build.gradle.kts | 2 +- src/main/kotlin/org/kalasim/Component.kt | 3 +- .../org/kalasim/monitors/CategoryMonitor.kt | 8 ++ .../org/kalasim/monitors/CategoryTimeline.kt | 112 ++++++++++++++++-- .../org/kalasim/monitors/ValueTimeline.kt | 2 + .../kotlin/org/kalasim/test/MonitorTests.kt | 49 +++++++- 11 files changed, 182 insertions(+), 21 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 20c19a32..43628223 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { id("io.github.gradle-nexus.publish-plugin") version "2.0.0" } -version = "1.0.2" +version = "1.0.3" allprojects { @@ -34,8 +34,7 @@ dependencies { api("io.github.oshai:kotlin-logging-jvm:6.0.9") -// api("org.jetbrains.kotlinx:dataframe-core:0.14.1") - api("com.github.holgerbrandl:kdfutils:1.4.5") + api("com.github.holgerbrandl:kdfutils:1.5.0") implementation("com.google.code.gson:gson:2.10.1") // implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.21") diff --git a/docs/userguide/docs/changes.md b/docs/userguide/docs/changes.md index bb8ede9b..4004a3bc 100644 --- a/docs/userguide/docs/changes.md +++ b/docs/userguide/docs/changes.md @@ -1,5 +1,9 @@ # Kalasim Release History +## 1.0.3 + +* Added slicing for `CategoryTimeline` + ## 1.0 diff --git a/docs/userguide/docs/monitors.md b/docs/userguide/docs/monitors.md index 87510878..b8343a9d 100644 --- a/docs/userguide/docs/monitors.md +++ b/docs/userguide/docs/monitors.md @@ -284,7 +284,17 @@ See [`MergeMonitorTests`](https://github.com/holgerbrandl/kalasim/blob/master/sr ## Slicing of monitors -**Note**: Slicing of monitors as in [salabim](https://www.salabim.org/manual/Monitor.html#slicing-of-monitors) is not yet supported. If needed please file a [ticket](https://github.com/holgerbrandl/kalasim/issues). +Slicing of monitors is supported for `CategoryTimeline` + +```kotlin +val ct = CategoryTimeline("foo") + +// compute frequency table for range +ct.summed(now-3.hours, now.hours) + +// same but output EnumeratedDistribution +val dist: EnumeratedDistribution = ct.valueDistribution(now-3.hours, now.hours) +``` Use-cases for slicing are diff --git a/docs/userguide/docs/setup.md b/docs/userguide/docs/setup.md index fe2f9d83..7d3d4a54 100644 --- a/docs/userguide/docs/setup.md +++ b/docs/userguide/docs/setup.md @@ -4,10 +4,10 @@ ## Gradle -To get started simply add it as a dependency: +To get started, simply add it as a dependency: ``` dependencies { - implementation 'com.github.holgerbrandl:kalasim:1.0.2' + implementation 'com.github.holgerbrandl:kalasim:1.0.3' } ``` diff --git a/experimental/optimization/build.gradle.kts b/experimental/optimization/build.gradle.kts index 6a7d4a54..be56f161 100644 --- a/experimental/optimization/build.gradle.kts +++ b/experimental/optimization/build.gradle.kts @@ -25,7 +25,7 @@ dependencies { api("ch.qos.logback:logback-classic:1.4.12") implementation("com.google.code.gson:gson:2.10") - api("org.jetbrains.kotlinx:dataframe-excel:0.12.1") + api("org.jetbrains.kotlinx:dataframe-excel:0.15.0") testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.0") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.0") diff --git a/modules/kravis/build.gradle.kts b/modules/kravis/build.gradle.kts index a9bddebc..bfadb7f4 100644 --- a/modules/kravis/build.gradle.kts +++ b/modules/kravis/build.gradle.kts @@ -15,7 +15,7 @@ repositories { dependencies { api(project(":")) - api("com.github.holgerbrandl:kravis:1.0.2") + api("com.github.holgerbrandl:kravis:1.0.4") testImplementation(kotlin("test")) } diff --git a/src/main/kotlin/org/kalasim/Component.kt b/src/main/kotlin/org/kalasim/Component.kt index 4b5ae33d..6d2b8699 100644 --- a/src/main/kotlin/org/kalasim/Component.kt +++ b/src/main/kotlin/org/kalasim/Component.kt @@ -18,6 +18,7 @@ import java.util.logging.Logger import kotlin.math.* import kotlin.reflect.* import kotlin.time.Duration +import kotlin.time.DurationUnit internal const val EPS = 1E-8 @@ -2125,7 +2126,7 @@ data class ComponentLifecycleRecord( fun Component.toLifeCycleRecord(): ComponentLifecycleRecord { val c = this - val histogram: Map = c.stateTimeline.summed() + val histogram = c.stateTimeline.summed().mapValues{ it.value.toDouble(DurationUnit.MINUTES)} return ComponentLifecycleRecord( c.name, diff --git a/src/main/kotlin/org/kalasim/monitors/CategoryMonitor.kt b/src/main/kotlin/org/kalasim/monitors/CategoryMonitor.kt index 5dad8105..fabb28fc 100644 --- a/src/main/kotlin/org/kalasim/monitors/CategoryMonitor.kt +++ b/src/main/kotlin/org/kalasim/monitors/CategoryMonitor.kt @@ -5,6 +5,9 @@ import org.kalasim.misc.* import org.koin.core.Koin import kotlin.math.roundToInt + +import org.apache.commons.math3.distribution.EnumeratedDistribution +import org.apache.commons.math3.util.Pair /** * Frequency tally levels irrespective of current (simulation) time. * @@ -65,6 +68,11 @@ fun CategoryMonitor.printHistogram(values: List? = null, sortByWeight: typealias FrequencyTable = Map +fun FrequencyTable.toDistribution(): EnumeratedDistribution { + val distribution = this.map { Pair(it.key, it.value) } + return EnumeratedDistribution(distribution) +} + fun FrequencyTable.printConsole( colWidth: Double = 40.0, sortByWeight: Boolean = false, diff --git a/src/main/kotlin/org/kalasim/monitors/CategoryTimeline.kt b/src/main/kotlin/org/kalasim/monitors/CategoryTimeline.kt index 42c6f6fc..17226404 100644 --- a/src/main/kotlin/org/kalasim/monitors/CategoryTimeline.kt +++ b/src/main/kotlin/org/kalasim/monitors/CategoryTimeline.kt @@ -1,9 +1,12 @@ package org.kalasim.monitors +import kotlinx.datetime.Instant +import org.apache.commons.math3.distribution.EnumeratedDistribution import org.kalasim.* import org.kalasim.misc.AmbiguousDuration import org.kalasim.misc.DependencyContext import org.kalasim.misc.time.sum +import org.kalasim.misc.time.sumOf import org.koin.core.Koin import kotlin.time.Duration import kotlin.time.DurationUnit @@ -37,7 +40,7 @@ class CategoryTimeline( } override fun addValue(value: T) { - if(!enabled) return + if (!enabled) return timestamps.add(env.now) values.add(value) @@ -50,7 +53,7 @@ class CategoryTimeline( .zip(values) .groupBy { it.second } .mapValues { (_, values) -> - values.sumOf { it.first } + values.map { it.first }.sum() } val total = freqHist.values.sum() @@ -58,11 +61,11 @@ class CategoryTimeline( return (freqHist[value] ?: error("Invalid or non-observed state")) / total } - private fun xDuration(): DoubleArray = + private fun xDuration(unit: DurationUnit = DurationUnit.MINUTES): DoubleArray = timestamps.toMutableList() .apply { add(env.now) } .zipWithNext { first, second -> second - first } - .map { it.toDouble(DurationUnit.MINUTES) } + .map { it.toDouble(unit) } .toDoubleArray() @@ -100,7 +103,7 @@ class CategoryTimeline( println("# Levels: ${this.values.distinct().size}") println() - if(this.values.size <= 1) { + if (this.values.size <= 1) { println("Skipping histogram of '$name' because of to few data") return } @@ -108,13 +111,97 @@ class CategoryTimeline( // val ed = EnumeratedDistribution(hist.asCM()) // repeat(1000){ ed.sample()}.c - summed().printConsole(sortByWeight = sortByWeight, values = values) + summed() + .map { it.key to it.value.toDouble(unit = DurationUnit.MINUTES) } + .toMap() + .printConsole(sortByWeight = sortByWeight, values = values) } - /** Accumulated retention time of the ComponentState. Only visited states will be included. */ - fun summed(): FrequencyTable = xDuration().zip(this.values) - .groupBy { (_, value) -> value } - .map { kv -> kv.key to kv.value.sumOf { (it.first) } }.toMap() + + /** + * Calculates the distribution of timeline values within a specified time range or all available data. + * + * @param start The start time of the interval for which the distribution is to be computed. + * Defaults to `null`, indicating that the distribution starts from the beginning of the timeline. + * + * @param end The end time of the interval for which the distribution is to be computed. + * Defaults to `null`, indicating that the distribution extends to the current time. + * + * @param includeAll If `true`, includes all distinct values in the distribution even if they + * have no associated duration in the given time range. Defaults to `false`. + * + * @return An `EnumeratedDistribution` instance representing the probability distribution of values + * based on their accumulated duration within the specified time range. + * + * @throws IllegalArgumentException If the specified start time is before the timeline's earliest value + * or the specified end time is after the current time. + */ + fun valueDistribution( + start: SimTime? = null, + end: SimTime? = null, + includeAll: Boolean = false + ): EnumeratedDistribution { + val map = summed(start, end) + .map { it.key to it.value.toDouble(DurationUnit.MINUTES) }.toMap() + +// val if(includeAll) else this. + val data = if (includeAll) values.distinct().associateWith { map[it]!! } else map + + return data + .toDistribution() + } + + /** + * Sums the timeline duration of values within a specified time range or all available data. + * + * @param start The start time of the interval for which the distribution is to be computed. + * Defaults to `null`, indicating that the distribution starts from the beginning of the timeline. + * + * @param end The end time of the interval for which the distribution is to be computed. + * Defaults to `null`, indicating that the distribution extends to the current time. + * + * @param includeAll If `true`, includes all distinct values in the distribution even if they + * have no associated duration in the given time range. Defaults to `false`. + * + * @return A map where the keys are the distinct values from the timeline and the values are + * their summed durations within the specified time range. + * + * @throws IllegalArgumentException If the specified start time is before the timeline's earliest value + * or the specified end time is after the current time. + */ + fun summed( + start: SimTime? = null, + end: SimTime? = null, + ): Map { + require(start == null || start >= timestamps.first()) { "start $start is out of timeline range [${timestamps.first()}, $now}]" } + require(end == null || end <= now) { "end $end is out of timeline range [${timestamps.first()}, $now}]" } + + val statsData = statsData().asList(false) + + val queryInterval = (start ?: Instant.DISTANT_PAST)..(end ?: Instant.DISTANT_FUTURE) + + fun ClosedRange.intersection(other: ClosedRange): ClosedRange? { + val newStart = maxOf(this.start, other.start) + val newEnd = minOf(this.endInclusive, other.endInclusive) + return if (newStart <= newEnd) newStart..newEnd else null + } + + val trimmed = statsData.map { + val segment = it.timestamp..(it.timestamp + it.duration!!) + val intersect = queryInterval.intersection(segment) + + if (intersect != null) { + it.copy(timestamp = intersect.start, duration = intersect.endInclusive - intersect.start) + } else { + null + } + }.filterNotNull().filter { it.duration!! > Duration.ZERO } + + return trimmed + .map { it.duration!! to it.value } + .groupBy { (_, value) -> value } + .map { kv -> kv.key to kv.value.sumOf { (it.first) } }.toMap() + } override fun statisticsSummary() = statsData().statisticalSummary() @@ -127,6 +214,7 @@ class CategoryTimeline( val timepointsExt = timestamps + env.now val durations = timepointsExt.toMutableList().zipWithNext { first, second -> second - first } +// xDuration() return LevelStatsData(valuesLst, timestamps, durations) } @@ -140,7 +228,7 @@ class CategoryTimeline( override fun clearHistory(before: SimTime) { val startFromIdx = timestamps.withIndex().firstOrNull { before > it.value }?.index ?: return - for(i in 0 until startFromIdx) { + for (i in 0 until startFromIdx) { val newTime = timestamps.subList(0, startFromIdx) val newValues = values.subList(0, startFromIdx) @@ -148,4 +236,6 @@ class CategoryTimeline( values.apply { clear(); addAll(newValues) } } } + + } \ No newline at end of file diff --git a/src/main/kotlin/org/kalasim/monitors/ValueTimeline.kt b/src/main/kotlin/org/kalasim/monitors/ValueTimeline.kt index 2ea34ba1..0fb689ef 100644 --- a/src/main/kotlin/org/kalasim/monitors/ValueTimeline.kt +++ b/src/main/kotlin/org/kalasim/monitors/ValueTimeline.kt @@ -37,6 +37,8 @@ interface ValueTimeline { /** Discards all history before the given time. */ fun clearHistory(before: SimTime) + +// operator fun get(from: SimTime, to: SimTime): ValueTimeline } // replacement for Pair to get better auto-conversion to data-frame diff --git a/src/test/kotlin/org/kalasim/test/MonitorTests.kt b/src/test/kotlin/org/kalasim/test/MonitorTests.kt index b57206b1..ba00ebd9 100644 --- a/src/test/kotlin/org/kalasim/test/MonitorTests.kt +++ b/src/test/kotlin/org/kalasim/test/MonitorTests.kt @@ -6,6 +6,7 @@ import io.kotest.matchers.shouldBe import org.apache.commons.math3.distribution.EnumeratedDistribution import org.apache.commons.math3.stat.descriptive.StatisticalSummaryValues import org.junit.jupiter.api.Test +import org.kalasim.get import org.kalasim.hour import org.kalasim.misc.* import org.kalasim.monitors.* @@ -199,7 +200,53 @@ class MonitorTests { } } - // TODO test the others + + @Test + fun `CategoryTimeline should allow to retrieve range selection`() = + createTestSimulation { + val ct = CategoryTimeline("A") + run(2.minutes) + + ct.addValue("B") + run(2.minutes) + + ct.addValue("C") + run(2.minutes) + + ct.addValue("D") + run(4.minutes) + ct.addValue("A") + run(3.minutes) + + + val rangeDist = ct.valueDistribution(startDate + 1.minutes, startDate + 4.minutes) + + println(rangeDist.pmf) + + rangeDist["A"] shouldBe 1.0/3 + rangeDist["B"] shouldBe 2.0/3 + + shouldThrow { + ct.valueDistribution(startDate - 10.minutes, startDate + 4.minutes) + } + shouldThrow { + ct.valueDistribution(startDate - 10.minutes, now + 12.minutes) + } + + // now we try to get a value for `now` + ct[now] shouldBe "A" + + val rangeDistEnd = ct.valueDistribution(now-5.minutes) + println(rangeDistEnd) + + rangeDistEnd["D"] shouldBe 0.4 + rangeDistEnd["A"] shouldBe 0.6 + + // repeat over another range but include all values + val initRangeAll = ct.valueDistribution(end = now-5.minutes, includeAll = true) + initRangeAll.pmf.size shouldBe 4 + } + }