diff options
| author | Alessio Leonardo Tomei <122273875+T0mexX@users.noreply.github.com> | 2024-08-22 14:40:57 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-08-22 14:40:57 +0200 |
| commit | 4f98fb2bf8204f6af52cd6eeb3313d21c6ca95bc (patch) | |
| tree | a9a67ce01615cfccfc787573626ae8c9f2ff740c /opendc-common | |
| parent | b2877899b9479458dba5e7dcba2891f1248b5d6d (diff) | |
Added Unit of measurament system with new deserialization (#242)
Diffstat (limited to 'opendc-common')
12 files changed, 2023 insertions, 0 deletions
diff --git a/opendc-common/build.gradle.kts b/opendc-common/build.gradle.kts index e0524f3c..2dd35d83 100644 --- a/opendc-common/build.gradle.kts +++ b/opendc-common/build.gradle.kts @@ -26,10 +26,15 @@ description = "Common functionality used across OpenDC modules" // Build configuration plugins { `kotlin-library-conventions` + kotlin("plugin.serialization") version "1.9.22" } +val serializationVersion = "1.6.0" + dependencies { api(libs.kotlinx.coroutines) + implementation(libs.kotlin.logging) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") testImplementation(projects.opendcSimulator.opendcSimulatorCore) } diff --git a/opendc-common/src/main/kotlin/org/opendc/common/annotations/InternalUse.kt b/opendc-common/src/main/kotlin/org/opendc/common/annotations/InternalUse.kt new file mode 100644 index 00000000..e32aa811 --- /dev/null +++ b/opendc-common/src/main/kotlin/org/opendc/common/annotations/InternalUse.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.opendc.common.annotations + +@RequiresOptIn(message = "This symbol is for internal use only") +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.CONSTRUCTOR) +public annotation class InternalUse diff --git a/opendc-common/src/main/kotlin/org/opendc/common/units/DataRate.kt b/opendc-common/src/main/kotlin/org/opendc/common/units/DataRate.kt new file mode 100644 index 00000000..2af45b7b --- /dev/null +++ b/opendc-common/src/main/kotlin/org/opendc/common/units/DataRate.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +@file:OptIn(InternalUse::class) + +package org.opendc.common.units + +import kotlinx.serialization.Serializable +import org.opendc.common.annotations.InternalUse +import org.opendc.common.units.Time.Companion.toTime +import org.opendc.common.utils.ifNeg0thenPos0 +import java.time.Duration + +/** + * Represents data-rate values. + * @see[Unit] + */ +@JvmInline +@Serializable(with = DataRate.Companion.DataRateSerializer::class) +public value class DataRate private constructor( + // In bits/s. + override val value: Double, +) : Unit<DataRate> { + @InternalUse + override fun new(value: Double): DataRate = DataRate(value.ifNeg0thenPos0()) + + public fun tobps(): Double = value + + public fun toKibps(): Double = value / 1024 + + public fun toKbps(): Double = value / 1e3 + + public fun toKiBps(): Double = toKibps() / 8 + + public fun toKBps(): Double = toKbps() / 8 + + public fun toMibps(): Double = toKibps() / 1024 + + public fun toMbps(): Double = toKbps() / 1e3 + + public fun toMiBps(): Double = toMibps() / 8 + + public fun toMBps(): Double = toMbps() / 8 + + public fun toGibps(): Double = toMibps() / 1024 + + public fun toGbps(): Double = toMbps() / 1e3 + + public fun toGiBps(): Double = toGibps() / 8 + + public fun toGBps(): Double = toGbps() / 8 + + override fun toString(): String = fmtValue() + + public override fun fmtValue(fmt: String): String = + when (abs()) { + in ZERO..ofBps(100) -> "${String.format(fmt, tobps())} bps" + in ofbps(100)..ofKbps(100) -> "${String.format(fmt, toKbps())} Kbps" + in ofKbps(100)..ofMbps(100) -> "${String.format(fmt, toMbps())} Mbps" + else -> "${String.format(fmt, toGbps())} Gbps" + } + + public operator fun times(time: Time): DataSize = DataSize.ofKiB(toKiBps() * time.toSec()) + + public operator fun times(duration: Duration): DataSize = this * duration.toTime() + + public companion object { + @JvmStatic public val ZERO: DataRate = DataRate(.0) + + @JvmStatic + @JvmName("ofbps") + public fun ofbps(bps: Number): DataRate = DataRate(bps.toDouble()) + + @JvmStatic + @JvmName("ofBps") + public fun ofBps(Bps: Number): DataRate = ofbps(Bps.toDouble() * 8) + + @JvmStatic + @JvmName("ofKibps") + public fun ofKibps(kibps: Number): DataRate = ofbps(kibps.toDouble() * 1024) + + @JvmStatic + @JvmName("ofKbps") + public fun ofKbps(kbps: Number): DataRate = ofbps(kbps.toDouble() * 1e3) + + @JvmStatic + @JvmName("ofKiBps") + public fun ofKiBps(kiBps: Number): DataRate = ofKibps(kiBps.toDouble() * 8) + + @JvmStatic + @JvmName("ofKBps") + public fun ofKBps(kBps: Number): DataRate = ofKbps(kBps.toDouble() * 8) + + @JvmStatic + @JvmName("ofMibps") + public fun ofMibps(mibps: Number): DataRate = ofKibps(mibps.toDouble() * 1024) + + @JvmStatic + @JvmName("ofMbps") + public fun ofMbps(mbps: Number): DataRate = ofKbps(mbps.toDouble() * 1e3) + + @JvmStatic + @JvmName("ofMiBps") + public fun ofMiBps(miBps: Number): DataRate = ofMibps(miBps.toDouble() * 8) + + @JvmStatic + @JvmName("ofMBps") + public fun ofMBps(mBps: Number): DataRate = ofMbps(mBps.toDouble() * 8) + + @JvmStatic + @JvmName("ofGibps") + public fun ofGibps(gibps: Number): DataRate = ofMibps(gibps.toDouble() * 1024) + + @JvmStatic + @JvmName("ofGbps") + public fun ofGbps(gbps: Number): DataRate = ofMbps(gbps.toDouble() * 1e3) + + @JvmStatic + @JvmName("ofGiBps") + public fun ofGiBps(giBps: Number): DataRate = ofGibps(giBps.toDouble() * 8) + + @JvmStatic + @JvmName("ofGBps") + public fun ofGBps(gBps: Number): DataRate = ofGbps(gBps.toDouble() * 8) + + /** + * Serializer for [DataRate] value class. It needs to be a compile + * time constant in order to be used as serializer automatically, + * hence `object :` instead of class instantiation. + * + * ```json + * // e.g. + * "data-rate": "1 Gbps" + * "data-rate": "10KBps" + * "data-rate": " 0.3 GBps " + * // etc. + * ``` + */ + internal object DataRateSerializer : UnitSerializer<DataRate>( + ifNumber = { + LOG.warn( + "deserialization of number with no unit of measure, assuming it is in Kibps." + + "Keep in mind that you can also specify the value as '$it Kibps'", + ) + ofKibps(it.toDouble()) + }, + serializerFun = { this.encodeString(it.toString()) }, + ifMatches("$NUM_GROUP$BITS$PER$SEC") { ofbps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$BYTES$PER$SEC") { ofBps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KIBI$BITS$PER$SEC") { ofKibps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KILO$BITS$PER$SEC") { ofKbps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KIBI$BYTES$PER$SEC") { ofKiBps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KILO$BYTES$PER$SEC") { ofKBps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MEBI$BITS$PER$SEC") { ofMibps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MEGA$BITS$PER$SEC") { ofMbps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MEBI$BYTES$PER$SEC") { ofMiBps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MEGA$BYTES$PER$SEC") { ofMBps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$GIBI$BITS$PER$SEC") { ofGibps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$GIGA$BITS$PER$SEC") { ofGbps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$GIBI$BYTES$PER$SEC") { ofGiBps(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$GIGA$BYTES$PER$SEC") { ofGBps(json.decNumFromStr(groupValues[1])) }, + ) + } +} diff --git a/opendc-common/src/main/kotlin/org/opendc/common/units/DataSize.kt b/opendc-common/src/main/kotlin/org/opendc/common/units/DataSize.kt new file mode 100644 index 00000000..e32d9e88 --- /dev/null +++ b/opendc-common/src/main/kotlin/org/opendc/common/units/DataSize.kt @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +@file:OptIn(InternalUse::class) + +package org.opendc.common.units + +import kotlinx.serialization.Serializable +import org.opendc.common.annotations.InternalUse +import org.opendc.common.units.Time.Companion.toTime +import org.opendc.common.utils.fmt +import java.time.Duration + +/** + * Represents data size value. + * @see[Unit] + */ +@JvmInline +@Serializable(with = DataSize.Companion.DataSerializer::class) +public value class DataSize private constructor( + // In MiB. + override val value: Double, +) : Unit<DataSize> { + @InternalUse + override fun new(value: Double): DataSize = DataSize(value) + + public fun toBits(): Double = toKib() * 1024 + + public fun toBytes(): Double = toKiB() * 1024 + + // Metric prefixes. + + public fun toKb(): Double = toBits() / 1e3 + + public fun toKB(): Double = toBytes() / 1e3 + + public fun toMb(): Double = toKb() / 1e3 + + public fun toMB(): Double = toKB() / 1e3 + + public fun toGb(): Double = toMb() / 1e3 + + public fun toGB(): Double = toMB() / 1e3 + + public fun toTb(): Double = toGb() / 1e3 + + public fun toTB(): Double = toGB() / 1e3 + + // Binary prefixes. + + public fun toKib(): Double = toMib() * 1024 + + public fun toKiB(): Double = toMiB() * 1024 + + public fun toMib(): Double = toMiB() * 8 + + public fun toMiB(): Double = value + + public fun toGib(): Double = toMib() / 1024 + + public fun toGiB(): Double = toMiB() / 1024 + + public fun toTib(): Double = toGib() / 1024 + + public fun toTiB(): Double = toGiB() / 1024 + + override fun toString(): String = fmtValue() + + override fun fmtValue(fmt: String): String = + when (abs()) { + in ZERO..ofBytes(100) -> "${toBytes().fmt(fmt)} Bytes" + in ofBytes(100)..ofKiB(100) -> "${toKiB().fmt(fmt)} KiB" + in ofKiB(100)..ofMiB(100) -> "${toMiB().fmt(fmt)} MiB" + else -> "${toGiB().fmt(fmt)} GiB" + } + + public operator fun div(time: Time): DataRate = DataRate.ofKBps(this.toKiB() / time.toSec()) + + public operator fun div(duration: Duration): DataRate = this / duration.toTime() + + public companion object { + @JvmStatic public val ZERO: DataSize = DataSize(.0) + + @JvmStatic + @JvmName("ofBits") + public fun ofBits(bits: Number): DataSize = ofKib(bits.toDouble() / 1024) + + @JvmStatic + @JvmName("ofBytes") + public fun ofBytes(bytes: Number): DataSize = ofKiB(bytes.toDouble() / 1024) + + // Metric prefixes. + + @JvmStatic + @JvmName("ofKb") + public fun ofKb(kb: Number): DataSize = ofBits(kb.toDouble() * 1e3) + + @JvmStatic + @JvmName("ofKB") + public fun ofKB(kB: Number): DataSize = ofBytes(kB.toDouble() * 1e3) + + @JvmStatic + @JvmName("ofMb") + public fun ofMb(mb: Number): DataSize = ofKb(mb.toDouble() * 1e3) + + @JvmStatic + @JvmName("ofMB") + public fun ofMB(mB: Number): DataSize = ofKB(mB.toDouble() * 1e3) + + @JvmStatic + @JvmName("ofGb") + public fun ofGb(gb: Number): DataSize = ofMb(gb.toDouble() * 1e3) + + @JvmStatic + @JvmName("ofGB") + public fun ofGB(gB: Number): DataSize = ofMB(gB.toDouble() * 1e3) + + @JvmStatic + @JvmName("ofTb") + public fun ofTb(tb: Number): DataSize = ofGb(tb.toDouble() * 1e3) + + @JvmStatic + @JvmName("ofTB") + public fun ofTB(tB: Number): DataSize = ofGB(tB.toDouble() * 1e3) + + // Binary prefixes. + + @JvmStatic + @JvmName("ofKib") + public fun ofKib(kib: Number): DataSize = ofMib(kib.toDouble() / 1024) + + @JvmStatic + @JvmName("ofKiB") + public fun ofKiB(kiB: Number): DataSize = ofMiB(kiB.toDouble() / 1024) + + @JvmStatic + @JvmName("ofMib") + public fun ofMib(mib: Number): DataSize = ofMiB(mib.toDouble() / 8) + + @JvmStatic + @JvmName("ofMiB") + public fun ofMiB(miB: Number): DataSize = DataSize(miB.toDouble()) + + @JvmStatic + @JvmName("ofGib") + public fun ofGib(gib: Number): DataSize = ofMib(gib.toDouble() * 1024) + + @JvmStatic + @JvmName("ofGiB") + public fun ofGiB(giB: Number): DataSize = ofMiB(giB.toDouble() * 1024) + + @JvmStatic + @JvmName("ofTib") + public fun ofTib(tib: Number): DataSize = ofGib(tib.toDouble() * 1024) + + @JvmStatic + @JvmName("ofTiB") + public fun ofTiB(tiB: Number): DataSize = ofGiB(tiB.toDouble() * 1024) + + /** + * Serializer for [DataSize] value class. It needs to be a compile + * time constant in order to be used as serializer automatically, + * hence `object :` instead of class instantiation. + * + * ```json + * // e.g. + * "data": "100GB" + * "data": " 1 MB " + * // etc. + * ``` + */ + internal object DataSerializer : UnitSerializer<DataSize>( + ifNumber = { + LOG.warn( + "deserialization of number with no unit of measure for unit 'DataSize', " + + "assuming it is in MiB. Keep in mind that you can also specify the value as '$it MiB'", + ) + ofMiB(it.toDouble()) + }, + serializerFun = { this.encodeString(it.toString()) }, + ifMatches("$NUM_GROUP$BITS") { ofBits(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$BYTES") { ofBytes(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KIBI$BITS") { ofKib(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KILO$BITS") { ofKb(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KIBI$BYTES") { ofKiB(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KILO$BYTES") { ofKB(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MEBI$BITS") { ofMib(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MEGA$BITS") { ofMb(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MEBI$BYTES") { ofMiB(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MEGA$BYTES") { ofMB(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$GIBI$BITS") { ofGib(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$GIGA$BITS") { ofGb(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$GIBI$BYTES") { ofGiB(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$GIGA$BYTES") { ofGB(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$TEBI$BITS") { ofTib(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$TERA$BITS") { ofTb(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$TEBI$BYTES") { ofTiB(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$TERA$BYTES") { ofTB(json.decNumFromStr(groupValues[1])) }, + ) + } +} diff --git a/opendc-common/src/main/kotlin/org/opendc/common/units/Energy.kt b/opendc-common/src/main/kotlin/org/opendc/common/units/Energy.kt new file mode 100644 index 00000000..467192a0 --- /dev/null +++ b/opendc-common/src/main/kotlin/org/opendc/common/units/Energy.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +@file:OptIn(InternalUse::class) + +package org.opendc.common.units + +import kotlinx.serialization.Serializable +import org.opendc.common.annotations.InternalUse +import org.opendc.common.units.Time.Companion.toTime +import org.opendc.common.utils.fmt +import org.opendc.common.utils.ifNeg0thenPos0 +import java.time.Duration +import kotlin.text.RegexOption.IGNORE_CASE + +/** + * Represents energy values. + * @see[Unit] + */ +@JvmInline +@Serializable(with = Energy.Companion.EnergySerializer::class) +public value class Energy private constructor( + // In Joule + override val value: Double, +) : Unit<Energy> { + override fun new(value: Double): Energy = Energy(value.ifNeg0thenPos0()) + + public fun toJoule(): Double = value + + public fun toKJoule(): Double = value / 1000 + + public fun toWh(): Double = value / 3600 + + public fun toKWh(): Double = toWh() / 1000 + + override fun toString(): String = fmtValue() + + override fun fmtValue(fmt: String): String = + if (value >= 1000.0) { + "${toJoule().fmt(fmt)} Joule" + } else { + "${toKJoule().fmt(fmt)} KJoule" + } + + public operator fun div(time: Time): Power = Power.ofWatts(toWh() / time.toHours()) + + public operator fun div(duration: Duration): Power = this / duration.toTime() + + public companion object { + @JvmStatic + public val ZERO: Energy = Energy(.0) + + @JvmStatic + @JvmName("ofJoule") + public fun ofJoule(joule: Number): Energy = Energy(joule.toDouble()) + + @JvmStatic + @JvmName("ofKJoule") + public fun ofKJoule(kJoule: Number): Energy = ofJoule(kJoule.toDouble() * 1000) + + @JvmStatic + @JvmName("ofWh") + public fun ofWh(wh: Number): Energy = ofJoule(wh.toDouble() * 3600) + + @JvmStatic + @JvmName("ofKWh") + public fun ofKWh(kWh: Number): Energy = ofWh(kWh.toDouble() * 1000.0) + + private val JOULES = Regex("\\s*(?:j|(?:joule|Joule)(?:|s))") + + /** + * Serializer for [Energy] value class. It needs to be a compile + * time constant in order to be used as serializer automatically, + * hence `object :` instead of class instantiation. + * + * ```json + * // e.g. + * "energy": "1 KWh" + * "energy": " 3 watts-hour " + * "energy": "10.5 Joules" + * // etc. + * ``` + */ + internal object EnergySerializer : UnitSerializer<Energy>( + ifNumber = { + LOG.warn( + "deserialization of number with no unit of measure, assuming it is in Joule" + + "Keep in mind that you can also specify the value as '$it Joule'", + ) + ofJoule(it.toDouble()) + }, + serializerFun = { this.encodeString(it.toString()) }, + ifMatches("$NUM_GROUP$WATTS$PER$HOUR", IGNORE_CASE) { ofWh(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KILO$WATTS$PER$HOUR", IGNORE_CASE) { ofKWh(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$JOULES", IGNORE_CASE) { ofJoule(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KILO$JOULES", IGNORE_CASE) { ofKJoule(json.decNumFromStr(groupValues[1])) }, + ) + } +} diff --git a/opendc-common/src/main/kotlin/org/opendc/common/units/Frequency.kt b/opendc-common/src/main/kotlin/org/opendc/common/units/Frequency.kt new file mode 100644 index 00000000..df1b49f6 --- /dev/null +++ b/opendc-common/src/main/kotlin/org/opendc/common/units/Frequency.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +@file:OptIn(InternalUse::class) + +package org.opendc.common.units + +import kotlinx.serialization.Serializable +import org.opendc.common.annotations.InternalUse +import org.opendc.common.units.Time.Companion.toTime +import org.opendc.common.utils.fmt +import org.opendc.common.utils.ifNeg0thenPos0 +import java.time.Duration +import kotlin.text.RegexOption.IGNORE_CASE + +/** + * Represents frequency values. + * @see[Unit] + */ +@JvmInline +@Serializable(with = Frequency.Companion.FrequencySerializer::class) +public value class Frequency private constructor( + // As MHz. + override val value: Double, +) : Unit<Frequency> { + override fun new(value: Double): Frequency = Frequency(value.ifNeg0thenPos0().also { check(it >= .0) }) + + public fun toHz(): Double = value * 1e6 + + public fun toKHz(): Double = value * 1e3 + + public fun toMHz(): Double = value + + public fun toGHz(): Double = value / 1e3 + + override fun toString(): String = fmtValue() + + override fun fmtValue(fmt: String): String = + when (abs()) { + in ZERO..ofHz(500) -> "${toHz().fmt(fmt)} Hz" + in ofHz(500)..ofKHz(500) -> "${toKHz().fmt(fmt)} KHz" + in ofKHz(500)..ofMHz(500) -> "${toMHz().fmt(fmt)} MHz" + else -> "${toGHz().fmt(fmt)} GHz" + } + + public operator fun times(time: Time): Double = toHz() * time.toSec() + + public operator fun times(duration: Duration): Double = toHz() * duration.toTime().toSec() + + public companion object { + @JvmStatic public val ZERO: Frequency = Frequency(.0) + + @JvmStatic + @JvmName("ofHz") + public fun ofHz(hz: Number): Frequency = ofMHz(hz.toDouble() / 1e6) + + @JvmStatic + @JvmName("ofKHz") + public fun ofKHz(kHz: Number): Frequency = ofMHz(kHz.toDouble() / 1e3) + + @JvmStatic + @JvmName("ofMHz") + public fun ofMHz(mHz: Number): Frequency = Frequency(mHz.toDouble()) + + @JvmStatic + @JvmName("ofGHz") + public fun ofGHz(gHz: Number): Frequency = ofMHz(gHz.toDouble() * 1e3) + + private val HERTZ = Regex("\\s*(?:Hz|Hertz|hz|hertz)\\s*?") + + /** + * Serializer for [Frequency] value class. It needs to be a compile + * time constant in order to be used as serializer automatically, + * hence `object :` instead of class instantiation. + * + * ```json + * // e.g. + * "frequency": "1000 Hz" + * "frequency": " 10 GHz " + * "frequency": "2megahertz" + * // etc. + * ``` + */ + internal object FrequencySerializer : UnitSerializer<Frequency>( + ifNumber = { + LOG.warn("deserialization of number with no unit of measure, assuming it is in MHz...") + ofMHz(it.toDouble()) + }, + serializerFun = { this.encodeString(it.toString()) }, + ifMatches("$NUM_GROUP$HERTZ", IGNORE_CASE) { ofHz(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KILO$HERTZ", IGNORE_CASE) { ofKHz(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MEGA$HERTZ", IGNORE_CASE) { ofMHz(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$GIGA$HERTZ", IGNORE_CASE) { ofGHz(json.decNumFromStr(groupValues[1])) }, + ) + } +} diff --git a/opendc-common/src/main/kotlin/org/opendc/common/units/Percentage.kt b/opendc-common/src/main/kotlin/org/opendc/common/units/Percentage.kt new file mode 100644 index 00000000..377fdecc --- /dev/null +++ b/opendc-common/src/main/kotlin/org/opendc/common/units/Percentage.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +@file:OptIn(InternalUse::class) + +package org.opendc.common.units + +import kotlinx.serialization.Serializable +import mu.KotlinLogging +import org.opendc.common.annotations.InternalUse +import org.opendc.common.utils.fmt +import org.opendc.common.utils.ifNeg0thenPos0 +import kotlin.text.RegexOption.IGNORE_CASE + +/** + * Represents a percentage. This interface has 2 value classes implementations. + * + * Using the interface instead of its implementation will likely result in worse + * performances compared to using the value-classes themselves, + * since the jvm will allocate an object for the interface. Therefore, it is suggested + * to use the interface as little as possible. Operations between the same implementation + * ([BoundedPercentage] + [BoundedPercentage]) will result in the same return type. + * + * [BoundedPercentage]s are adjusted to remain in range 0-100%, + * logging warning whenever an adjustment has been made. + * + * As all [Unit]s, offers the vast majority + * of mathematical operations that one would perform on a simple [Double]. + */ +@Serializable(with = Percentage.Companion.PercentageSerializer::class) +public sealed interface Percentage : Unit<Percentage> { + override val value: Double + + /** + * @return the value as a ratio (e.g. 50% -> 0.5) + */ + public fun toRatio(): Double = value + + /** + * @return the value as percentage (50.6% -> 50.6) + */ + public fun toPercentageValue(): Double = value * 1e2 + + /** + * @return *this* percentage converted to [BoundedPercentage]. + */ + public fun toBoundedPercentage(): BoundedPercentage + + /** + * @return *this* percentage converted to [UnboundedPercentage]. + */ + public fun toUnboundedPercentage(): UnboundedPercentage + + /** + * ```kotlin + * // e.g. + * val perc: Percentage = Percentage.ofRatio(0.123456789) + * perc.fmtValue("%.4f") // "12.3456%" + * ``` + * + * @see[Unit.fmtValue] + */ + override fun fmtValue(fmt: String): String = "${toPercentageValue().fmt(fmt)}%" + + public companion object { + @JvmStatic public val ZERO: Percentage = UnboundedPercentage(.0) + + @JvmStatic + @JvmName("ofRatio") + public fun ofRatio(ratio: Double): UnboundedPercentage = UnboundedPercentage(ratio) + + @JvmStatic + @JvmName("ofRatioBounded") + public fun ofRatioBounded(ratio: Double): BoundedPercentage = BoundedPercentage(ratio) + + @JvmStatic + @JvmName("ofPercentage") + public fun ofPercentage(percentage: Number): UnboundedPercentage = UnboundedPercentage(percentage.toDouble() / 100) + + @JvmStatic + @JvmName("ofPercentageBounded") + public fun ofPercentageBounded(percentage: Double): BoundedPercentage = BoundedPercentage(percentage / 100) + + /** + * @return the percentage resulting from [this] / [other]. + */ + public infix fun Number.percentageOf(other: Number): UnboundedPercentage = UnboundedPercentage(this.toDouble() / other.toDouble()) + + /** + * @return the *bounded* percentage resulting from [this] / [other]. + */ + public infix fun Number.boundedPercentageOf(other: Number): BoundedPercentage = + BoundedPercentage(this.toDouble() / other.toDouble()) + + /** + * @return the percentage resulting from [this] / [other], applicable on all [Unit]s of same type. + */ + public infix fun <T : Unit<T>> T.percentageOf(other: T): UnboundedPercentage = UnboundedPercentage(this.value / other.value) + + /** + * @return the *bounded* percentage resulting from [this] / [other], applicable on all [Unit]s of same type. + */ + public infix fun <T : Unit<T>> T.boundedPercentageOf(other: T): BoundedPercentage = BoundedPercentage(this.value / other.value) + + private val PERCENTAGE = Regex("\\s*(?:percentage|Percentage|%)\\s*?") + + /** + * Serializer for [Percentage] value class. It needs to be a compile + * time constant in order to be used as serializer automatically, + * hence `object :` instead of class instantiation. + * + * For implementation purposes it always deserialize an [UnboundedPercentage] as [Percentage]. + * + * ```json + * // e.g. + * "percentage": 0.5 // 50% with warning + * "percentage": " 30% " + * "percentage": "120%" // 120% (unbounded) + * // etc. + * ``` + */ + internal object PercentageSerializer : UnitSerializer<Percentage>( + ifNumber = { + LOG.warn( + "deserialization of number with no unit of measure, assuming it is a ratio." + + "Keep in mind that you can also specify the value as '${it.toDouble() * 100}%'", + ) + ofRatio(it.toDouble()) + }, + serializerFun = { this.encodeString(it.toString()) }, + ifMatches("$NUM_GROUP$PERCENTAGE", IGNORE_CASE) { ofPercentage(json.decNumFromStr(groupValues[1])) }, + ) + } +} + +/** + * Bounded implementation of [Percentage], meaning the + * percentage value is adjusted to always be in the range 0-100%, + * logging a warning whenever an adjustment has been made. + */ +@JvmInline +public value class BoundedPercentage + @InternalUse + internal constructor( + override val value: Double, + ) : Percentage { + override fun toBoundedPercentage(): BoundedPercentage = this + + override fun toUnboundedPercentage(): UnboundedPercentage = UnboundedPercentage(value) + + override fun new(value: Double): BoundedPercentage = BoundedPercentage(value.forceInRange().ifNeg0thenPos0()) + + override fun toString(): String = fmtValue() + + /** + * "Override" to return [BoundedPercentage] insteadof [Percentage]. + * @see[Unit.plus] + */ + public infix operator fun plus(other: BoundedPercentage): BoundedPercentage = BoundedPercentage(this.value + other.value) + + /** + * "Override" to return [BoundedPercentage] insteadof [Percentage]. + * @see[Unit.minus] + */ + public infix operator fun minus(other: BoundedPercentage): BoundedPercentage = BoundedPercentage(this.value - other.value) + + /** + * Override to return [BoundedPercentage] insteadof [Percentage]. + * @see[Unit.times] + */ + override operator fun times(scalar: Number): BoundedPercentage = BoundedPercentage(this.value * scalar.toDouble()) + + /** + * Override to return [BoundedPercentage] insteadof [Percentage]. + * @see[Unit.div] + */ + override operator fun div(scalar: Number): BoundedPercentage = BoundedPercentage(this.value / scalar.toDouble()) + + private fun Double.forceInRange( + from: Double = .0, + to: Double = 1.0, + ): Double = + if (this < from) { + LOG.warn("bounded percentage has been rounded up (from ${this * 1e2}% to ${from * 1e2}%") + from + } else if (this > to) { + LOG.warn("bounded percentage has been rounded down (from ${this * 1e2}% to ${to * 1e2}%") + to + } else { + this + } + + public companion object { + // TODO: replace with `by logger()` if pr #241 is approved + private val LOG = KotlinLogging.logger(name = this::class.java.enclosingClass.simpleName) + } + } + +/** + * Unbounded implementation of [Percentage], meaning the + * percentage value is allowed to be outside the range 0-100%. + */ +@JvmInline +public value class UnboundedPercentage + @InternalUse + internal constructor( + override val value: Double, + ) : Percentage { + override fun toBoundedPercentage(): BoundedPercentage = BoundedPercentage(value.ifNeg0thenPos0()) + + override fun toUnboundedPercentage(): UnboundedPercentage = this + + @InternalUse + override fun new(value: Double): UnboundedPercentage = UnboundedPercentage(value) + + override fun toString(): String = fmtValue() + + /** + * "Override" to return [UnboundedPercentage] insteadof [Percentage]. + * @see[Unit.plus] + */ + public infix operator fun plus(other: UnboundedPercentage): UnboundedPercentage = UnboundedPercentage(this.value + other.value) + + /** + * "Override" to return [UnboundedPercentage] insteadof [Percentage]. + * @see[Unit.minus] + */ + public infix operator fun minus(other: UnboundedPercentage): UnboundedPercentage = UnboundedPercentage(this.value - other.value) + + /** + * Override to return [UnboundedPercentage] insteadof [Percentage]. + * @see[Unit.times] + */ + override operator fun times(scalar: Number): UnboundedPercentage = UnboundedPercentage(this.value * scalar.toDouble()) + + /** + * Override to return [UnboundedPercentage] insteadof [Percentage]. + * @see[Unit.div] + */ + override operator fun div(scalar: Number): UnboundedPercentage = UnboundedPercentage(this.value / scalar.toDouble()) + } diff --git a/opendc-common/src/main/kotlin/org/opendc/common/units/Power.kt b/opendc-common/src/main/kotlin/org/opendc/common/units/Power.kt new file mode 100644 index 00000000..fc9f6bf4 --- /dev/null +++ b/opendc-common/src/main/kotlin/org/opendc/common/units/Power.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +@file:OptIn(InternalUse::class) + +package org.opendc.common.units + +import kotlinx.serialization.Serializable +import org.opendc.common.annotations.InternalUse +import org.opendc.common.units.Time.Companion.toTime +import org.opendc.common.utils.fmt +import org.opendc.common.utils.ifNeg0thenPos0 +import java.time.Duration +import kotlin.text.RegexOption.IGNORE_CASE + +/** + * Represents power values. + * @see[Unit] + */ +@JvmInline +@Serializable(with = Power.Companion.PowerSerializer::class) +public value class Power private constructor( + // In Watts. + override val value: Double, +) : Unit<Power> { + @InternalUse + override fun new(value: Double): Power = Power(value.ifNeg0thenPos0()) + + public fun toWatts(): Double = value + + public fun toKWatts(): Double = value / 1000.0 + + override fun toString(): String = fmtValue() + + override fun fmtValue(fmt: String): String = + if (value >= 1000.0) { + "${toKWatts().fmt(fmt)} KWatts" + } else { + "${toWatts().fmt(fmt)} Watts" + } + + public operator fun times(time: Time): Energy = Energy.ofWh(toWatts() * time.toHours()) + + public operator fun times(duration: Duration): Energy = this * duration.toTime() + + public companion object { + @JvmStatic + public val ZERO: Power = Power(.0) + + @JvmStatic + @JvmName("ofWatts") + public fun ofWatts(watts: Number): Power = Power(watts.toDouble()) + + @JvmStatic + @JvmName("ofKWatts") + public fun ofKWatts(kWatts: Number): Power = Power(kWatts.toDouble() * 1000.0) + + /** + * Serializer for [Power] value class. It needs to be a compile + * time constant in order to be used as serializer automatically, + * hence `object :` instead of class instantiation. + * + * ```json + * // e.g. + * "power-draw": "4 watts" + * "power-draw": " 1 KWatt " + * // etc. + * ``` + */ + internal object PowerSerializer : UnitSerializer<Power>( + ifNumber = { + LOG.warn( + "deserialization of number with no unit of measure, assuming it is in Watts." + + "Keep in mind that you can also specify the value as '$it W'", + ) + ofWatts(it.toDouble()) + }, + serializerFun = { this.encodeString(it.toString()) }, + ifMatches("$NUM_GROUP$WATTS", IGNORE_CASE) { ofWatts(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$KILO$WATTS", IGNORE_CASE) { ofKWatts(json.decNumFromStr(groupValues[1])) }, + ) + } +} diff --git a/opendc-common/src/main/kotlin/org/opendc/common/units/Time.kt b/opendc-common/src/main/kotlin/org/opendc/common/units/Time.kt new file mode 100644 index 00000000..9d72ddfc --- /dev/null +++ b/opendc-common/src/main/kotlin/org/opendc/common/units/Time.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +@file:OptIn(InternalUse::class) + +package org.opendc.common.units + +import kotlinx.serialization.Serializable +import org.opendc.common.annotations.InternalUse +import org.opendc.common.utils.ifNeg0thenPos0 +import java.time.Duration +import java.time.Instant +import kotlin.text.RegexOption.IGNORE_CASE + +/** + * Represents time values. + * @see[Unit] + */ +@JvmInline +@Serializable(with = Time.Companion.TimeSerializer::class) +public value class Time private constructor( + // In milliseconds. + public override val value: Double, +) : Unit<Time> { + @InternalUse + override fun new(value: Double): Time = Time(value.ifNeg0thenPos0()) + + public fun toNs(): Double = value * 1e6 + + public fun toMicros(): Double = value * 1e3 + + public fun toMs(): Double = value + + public fun toMsLong(): Long = value.toLong() + + public fun toSec(): Double = value / 1000.0 + + public fun toMin(): Double = toSec() / 60 + + public fun toHours(): Double = toMin() / 60 + + public fun toInstantFromEpoch(): Instant = Instant.ofEpochMilli(value.toLong()) + + override fun toString(): String = fmtValue() + + /** + * @return the [Duration] [toString] result of this time value. + */ + override fun fmtValue(fmt: String): String = Duration.ofMillis(value.toLong()).toString() + + public operator fun times(power: Power): Energy = Energy.ofWh(toHours() * power.toWatts()) + + public operator fun times(dataRate: DataRate): DataSize = DataSize.ofKB(toSec() * dataRate.toKBps()) + + public companion object { + @JvmStatic public val ZERO: Time = Time(.0) + + @JvmStatic + @JvmName("ofNanos") + public fun ofNanos(nanos: Number): Time = Time(nanos.toDouble() / 1e6) + + @JvmStatic + @JvmName("ofMicros") + public fun ofMicros(micros: Number): Time = Time(micros.toDouble() / 1e3) + + @JvmStatic + @JvmName("ofMillis") + public fun ofMillis(ms: Number): Time = Time(ms.toDouble()) + + @JvmStatic + @JvmName("ofSec") + public fun ofSec(sec: Number): Time = Time(sec.toDouble() * 1000.0) + + @JvmStatic + @JvmName("ofMin") + public fun ofMin(min: Number): Time = Time(min.toDouble() * 60 * 1000.0) + + @JvmStatic + @JvmName("ofHours") + public fun ofHours(hours: Number): Time = Time(hours.toDouble() * 60 * 60 * 1000.0) + + @JvmStatic + @JvmName("ofDuration") + public fun ofDuration(duration: Duration): Time = duration.toTime() + + @JvmStatic + @JvmName("ofInstantFromEpoch") + public fun ofInstantFromEpoch(instant: Instant): Time = ofMillis(instant.toEpochMilli()) + + /** + * Serializer for [Time] value class. It needs to be a compile + * time constant in order to be used as serializer automatically, + * hence `object :` instead of class instantiation. + * + * ```json + * // e.g. + * "time": "10 hours" + * "time": " 30 minutes " + * "time": "1 ms" + * "time": "PT13H" + * // etc. + * ``` + */ + internal object TimeSerializer : UnitSerializer<Time>( + ifNumber = { + LOG.warn( + "deserialization of number with no unit of measure, assuming it is in milliseconds." + + "Keep in mind that you can also specify the value as '$it ms'", + ) + ofMillis(it.toDouble()) + }, + serializerFun = { this.encodeString(it.toString()) }, + ifMatches("$NUM_GROUP$NANO$SEC(?:|s)\\s*", IGNORE_CASE) { ofNanos(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MICRO$SEC(?:|s)\\s*", IGNORE_CASE) { ofMicros(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MILLI$SEC(?:|s)\\s*", IGNORE_CASE) { ofMillis(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$SEC(?:|s)\\s*", IGNORE_CASE) { ofSec(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$MIN(?:|s)\\s*") { ofMin(json.decNumFromStr(groupValues[1])) }, + ifMatches("$NUM_GROUP$HOUR(?:|s)\\s*") { ofHours(json.decNumFromStr(groupValues[1])) }, + ifNoExc { ofDuration(Duration.parse(this)) }, + ifNoExc { ofInstantFromEpoch(Instant.parse(this)) }, + ) + + /** + * @return [this] converted to a [Time] value, with the highest possible accuracy. + * + * @throws RuntimeException if [this] cannot be represented as nanos, millis, seconds, minutes or hours with a [Long]. + */ + public fun Duration.toTime(): Time { + fun tryNoThrow(block: () -> Time?) = + try { + block() + } catch (_: Exception) { + null + } + + return tryNoThrow { ofNanos(this.toNanos()) } + ?: tryNoThrow { ofMillis(this.toMillis()) } + ?: tryNoThrow { ofSec(this.toSeconds()) } + ?: tryNoThrow { ofMin(this.toMinutes()) } + ?: tryNoThrow { ofHours(this.toHours()) } + ?: throw RuntimeException( + "duration $this cannot be converted to ${Time::class.simpleName}, " + + "duration value overflow Long representation of nanos, millis, seconds, minutes and hours", + ) + } + } +} diff --git a/opendc-common/src/main/kotlin/org/opendc/common/units/Unit.kt b/opendc-common/src/main/kotlin/org/opendc/common/units/Unit.kt new file mode 100644 index 00000000..8bcbb148 --- /dev/null +++ b/opendc-common/src/main/kotlin/org/opendc/common/units/Unit.kt @@ -0,0 +1,386 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +@file:OptIn(InternalUse::class) + +package org.opendc.common.units + +import org.opendc.common.annotations.InternalUse +import org.opendc.common.units.Time.Companion.toTime +import org.opendc.common.utils.DFLT_MIN_EPS +import org.opendc.common.utils.adaptiveEps +import org.opendc.common.utils.approx +import org.opendc.common.utils.approxLarger +import org.opendc.common.utils.approxLargerOrEq +import org.opendc.common.utils.approxSmaller +import org.opendc.common.utils.approxSmallerOrEq +import java.time.Duration +import kotlin.experimental.ExperimentalTypeInference + +/** + * Value classes can extend this interface to represent + * unit of measure with much higher type safety than [Double] (*If used from kotlin*) + * and approximately same performances. + * ```kotlin + * // e.g. + * @JvmInline value class DataRate(override val value) : Unit<DataRate> { } + * ``` + * This interface provides most of the utility functions and + * mathematical operations that are available to [Double] (including threshold comparison methods), + * but applicable to [T] (also with scalar multiplication and division), + * and operations between different unit of measures. + * + * ``` + * // e.g. sum of data-rates + * val a: DataRate = DataRate.ofMibps(100) + * val b: DataRate = DataRate.ofGibps(1) + * val c: DataRate = a + b + * c.fmt("%.3f") // "1.097 Gibps" + * + * // e.g. data-rate times scalar + * val e: DataRate = a * 2 + * e.fmt() // "200 Mibps" + * + * // e.g. threshold comparison + * if (e approx a) { ... } + * + * // e.g. operations between different unit of measures + * val a: DataRate = DataRate.ofMBps(1) + * val b: Time = Time.ofSec(3) + * val c: DataSize = a * b + * c.fmt() // "3MB" + * ``` + * + * ###### Java interoperability + * Functions that concern inline classes are not callable from java by default (at least for now). + * Hence, the JvmName annotation is needed for java interoperability. **Only methods that allow java + * to interact with kotlin code concerning inline classes should be made accessible to java.** + * Java will never be able to invoke instance methods, only static ones. + * + * Java sees value classes as the standard data type they represent (in this case double). + * Meaning there is no type safety from java, nevertheless functions can be invoked + * to provide methods the correct unit value (and for improved understandability). + * + * ```kotlin + * // kotlin + * @JvmStatic @JvmName("function") + * fun function(time: Time) { } + * ``` + * ```java + * // java + * double time = Time.ofHours(2); + * function(time) + * // or + * function(Time.ofHours(2)) + * ``` + * + * @param[T] the unit of measure that is represented (e.g. [DataRate]) + */ +public sealed interface Unit<T : Unit<T>> : Comparable<T> { + /** + * The actual value of this unit of measure used for computation and comparisons. + * + * What magnitude this value represents (e.g. Kbps, Mbps etc.) is up to the interface implementation, + * and it does not interfere with the operations, hence this property should be reserved for internal use. + */ + @InternalUse + public val value: Double + + /** + * @return the sum with [other] as [T]. + */ + public operator fun plus(other: T): T = new(value + other.value) + + /** + * @return the subtraction of [other] from *this* as [T]. + */ + public operator fun minus(other: T): T = new(value - other.value) + + /** + * @return *this* divided by scalar [scalar] as [T]. + */ + public operator fun div(scalar: Number): T = new(value / scalar.toDouble()) + + /** + * @return *this* divided by [other] as [Double]. + */ + public operator fun div(other: T): Double = value / other.value + + /** + * @return *this* multiplied by scalar [scalar] as [T]. + */ + public operator fun times(scalar: Number): T = new(value * scalar.toDouble()) + + /** + * @return *this* negated. + */ + public operator fun unaryMinus(): T = new(-value) + + public override operator fun compareTo(other: T): Int = this.value.compareTo(other.value) + + /** + * @return `true` if *this* is equal to 0 (using `==` operator). + */ + public fun isZero(): Boolean = value == .0 || value == -.0 + + /** + * @return `true` if *this* is approximately equal to 0. + * @see[Double.approx] + */ + public fun approxZero(epsilon: Double = DFLT_MIN_EPS): Boolean = value.approx(.0, epsilon = epsilon) + + /** + * @see[Double.approx] + */ + public fun approx( + other: T, + minEpsilon: Double = DFLT_MIN_EPS, + epsilon: Double = adaptiveEps(this.value, other.value, minEpsilon), + ): Boolean = this == other || this.value.approx(other.value, minEpsilon, epsilon) + + /** + * @see[Double.approx] + */ + public infix fun approx(other: T): Boolean = approx(other, minEpsilon = DFLT_MIN_EPS) + + /** + * @see[Double.approxLarger] + */ + public fun approxLarger( + other: T, + minEpsilon: Double = DFLT_MIN_EPS, + epsilon: Double = adaptiveEps(this.value, other.value, minEpsilon), + ): Boolean = this.value.approxLarger(other.value, minEpsilon, epsilon) + + /** + * @see[Double.approxLarger] + */ + public infix fun approxLarger(other: T): Boolean = approxLarger(other, minEpsilon = DFLT_MIN_EPS) + + /** + * @see[Double.approxLargerOrEq] + */ + public fun approxLargerOrEq( + other: T, + minEpsilon: Double = DFLT_MIN_EPS, + epsilon: Double = adaptiveEps(this.value, other.value, minEpsilon), + ): Boolean = this.value.approxLargerOrEq(other.value, minEpsilon, epsilon) + + /** + * @see[Double.approxLargerOrEq] + */ + public infix fun approxLargerOrEq(other: T): Boolean = approxLargerOrEq(other, minEpsilon = DFLT_MIN_EPS) + + /** + * @see[Double.approxSmaller] + */ + public fun approxSmaller( + other: T, + minEpsilon: Double = DFLT_MIN_EPS, + epsilon: Double = adaptiveEps(this.value, other.value, minEpsilon), + ): Boolean = this.value.approxSmaller(other.value, minEpsilon, epsilon) + + /** + * @see[Double.approxSmaller] + */ + public infix fun approxSmaller(other: T): Boolean = approxSmaller(other, minEpsilon = DFLT_MIN_EPS) + + /** + * @see[Double.approxSmallerOrEq] + */ + public fun approxSmallerOrEq( + other: T, + minEpsilon: Double = DFLT_MIN_EPS, + epsilon: Double = adaptiveEps(this.value, other.value, minEpsilon), + ): Boolean = this.value.approxSmallerOrEq(other.value, minEpsilon, epsilon) + + /** + * @see[Double.approxSmallerOrEq] + */ + public infix fun approxSmallerOrEq(other: T): Boolean = approxSmallerOrEq(other, minEpsilon = DFLT_MIN_EPS) + + /** + * @return the max value between *this* and [other]. + */ + @Suppress("UNCHECKED_CAST") + public infix fun max(other: T): T = if (this.value > other.value) this as T else other + + /** + * @return the minimum value between *this* and [other]. + */ + @Suppress("UNCHECKED_CAST") + public infix fun min(other: T): T = if (this.value < other.value) this as T else other + + /** + * @return the absolute value of *this*. + */ + public fun abs(): T = new(kotlin.math.abs(value)) + + /** + * @return *this* approximated to [to] if within `0 - epsilon` and `0 + epsilon`. + */ + @Suppress("UNCHECKED_CAST") + public fun roundToIfWithinEpsilon( + to: T, + epsilon: Double = DFLT_MIN_EPS, + ): T = + if (this.value in (to.value - epsilon)..(to.value + epsilon)) { + to + } else { + this as T + } + + /** + * The "constructor" of [T] that this interface uses to + * instantiate new [T] when performing operations. + */ + @InternalUse + public fun new(value: Double): T + + /** + * Returns the formatted string representation of the unit of measure (e.g. "1.2 Gbps") + * with the formatter [fmt] applied to the value part of the resulting string. + * + * ```kotlin + * val dr = DataRate.ofGbps(1.234567) + * dr.fmtValue() // "1.234567 Gbps" + * dr.fmtValue("%.2f") // "1.23 Gbps" + * ``` + */ + public fun fmtValue(fmt: String = "%f"): String + + public companion object { + /** + * @return [unit] multiplied by scalar [this]. + */ + public operator fun <T : Unit<T>> Number.times(unit: T): T = unit * this + + /** + * @return minimum value between [a] and [b]. + */ + public fun <T : Unit<T>> min( + a: T, + b: T, + ): T = if (a.value < b.value) a else b + + /** + * @return minimum value between [units]. + */ + public fun <T : Unit<T>> minOf(vararg units: T): T = units.minBy { it.value } + + /** + * @return maximum value between [a] and [b]. + */ + public fun <T : Unit<T>> max( + a: T, + b: T, + ): T = if (a.value > b.value) a else b + + /** + * @return maximum value between [units]. + */ + public fun <T : Unit<T>> maxOf(vararg units: T): T = units.maxBy { it.value } + + // maxBy and minBy need to be defined in implementations. + + // Operations whose 'this' is a `Unit` are defined here. + // Operations whose 'this' is not a `Unit` are defined in their classes + // and not as extension function so that they do not need to be imported + + public operator fun Duration.times(dataRate: DataRate): DataSize = toTime() * dataRate + + public operator fun Duration.times(power: Power): Energy = toTime() * power + + public operator fun Number.div(time: Time): Frequency = Frequency.ofHz(this.toDouble() / time.toSec()) + + public operator fun Number.div(duration: Duration): Frequency = this / duration.toTime() + + // Defined here so that they can overload the same method name, instead of having a different name forEach unit. + // You can not overload `sumOf` and using that name results in not being able to use the overloads for unit and for number in the same file. + + // A reified version that does not need overloads can be also be defined, with a switch statement on the reified unit type for the base value. + // Then, if a unit is not included in the switch, a runtime error occurs, not compile time. + + @OptIn(ExperimentalTypeInference::class) + @OverloadResolutionByLambdaReturnType + @JvmName("sumOfDataRate") + public inline fun <T> Iterable<T>.sumOfUnit(selector: (T) -> DataRate): DataRate { + var sum: DataRate = DataRate.ZERO + forEach { sum += selector(it) } + return sum + } + + @OptIn(ExperimentalTypeInference::class) + @OverloadResolutionByLambdaReturnType + @JvmName("sumOfDataSize") + public inline fun <T> Iterable<T>.sumOfUnit(selector: (T) -> DataSize): DataSize { + var sum: DataSize = DataSize.ZERO + forEach { sum += selector(it) } + return sum + } + + @OptIn(ExperimentalTypeInference::class) + @OverloadResolutionByLambdaReturnType + @JvmName("sumOfEnergy") + public inline fun <T> Iterable<T>.sumOfUnit(selector: (T) -> Energy): Energy { + var sum: Energy = Energy.ZERO + forEach { sum += selector(it) } + return sum + } + + @OptIn(ExperimentalTypeInference::class) + @OverloadResolutionByLambdaReturnType + @JvmName("sumOfPower") + public inline fun <T> Iterable<T>.sumOfUnit(selector: (T) -> Power): Power { + var sum: Power = Power.ZERO + forEach { sum += selector(it) } + return sum + } + + @OptIn(ExperimentalTypeInference::class) + @OverloadResolutionByLambdaReturnType + @JvmName("sumOfTime") + public inline fun <T> Iterable<T>.sumOfUnit(selector: (T) -> Time): Time { + var sum: Time = Time.ZERO + forEach { sum += selector(it) } + return sum + } + + @OptIn(ExperimentalTypeInference::class) + @OverloadResolutionByLambdaReturnType + @JvmName("sumOfFrequency") + public inline fun <T> Iterable<T>.sumOfUnit(selector: (T) -> Frequency): Frequency { + var sum: Frequency = Frequency.ZERO + forEach { sum += selector(it) } + return sum + } + + @OptIn(ExperimentalTypeInference::class) + @OverloadResolutionByLambdaReturnType + @JvmName("sumOfPercentage") + public inline fun <T> Iterable<T>.sumOfUnit(selector: (T) -> Percentage): Percentage { + var sum: Percentage = Percentage.ZERO + forEach { sum += selector(it) } + return sum + } + } +} diff --git a/opendc-common/src/main/kotlin/org/opendc/common/units/UnitSerializer.kt b/opendc-common/src/main/kotlin/org/opendc/common/units/UnitSerializer.kt new file mode 100644 index 00000000..aaf18498 --- /dev/null +++ b/opendc-common/src/main/kotlin/org/opendc/common/units/UnitSerializer.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.opendc.common.units + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer +import mu.KotlinLogging + +/** + * Serializer for [T]. + * @param[ifNumber] function invoked if the value to parse is a number without unit of measure. + * ```json + * // json e.g. + * "value": 3 + * // or + * "value": "3" + * ``` + * @param[serializerFun] function invoked when [T] needs to be serialized. + * + * @param[conditions] conditions used during the deserialization process. + * If the condition returns [T] then it is considered as the result of the deserialization. + * If the condition returns `null` the next condition is tested, until one + * satisfied condition is found, throws exception otherwise. + */ +internal open class UnitSerializer<T : Unit<T>>( + ifNumber: (Number) -> T, + serializerFun: Encoder.(T) -> kotlin.Unit, + vararg conditions: String.() -> T?, +) : OnlyString<T>( + object : KSerializer<T> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("unit-serializer", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): T { + val strField = decoder.decodeString() + try { + // If the field is a number. + return ifNumber(json.decodeFromString<Double>(strField)) + } catch (e: Exception) { + // No ops. + } + + conditions.forEach { condition -> + // If condition satisfied return result. + strField.condition()?.let { return it } + } + + throw RuntimeException("unable to parse unit of measure $strField") + } + + override fun serialize( + encoder: Encoder, + value: T, + ) { + serializerFun(encoder, value) + } + }, + ) { + companion object { + // TODO: replace with `by logger()` if pr #241 is approved + val LOG = KotlinLogging.logger(name = this::class.java.enclosingClass.simpleName) + + val json = Json + + /** + * @return a lambda that can be passed as condition to [UnitSerializer] constructor. + */ + fun <T> ifMatches( + regex: Regex, + block: MatchResult.() -> T, + ): String.() -> T? = + { + regex.matchEntire(this)?.block() + } + + /** + * @return a lambda that can be passed as condition to [UnitSerializer] constructor. + */ + fun <T> ifMatches( + regexStr: String, + vararg options: RegexOption = emptyArray(), + block: MatchResult.() -> T, + ): String.() -> T? = + { + Regex(regexStr, options.toSet()).matchEntire(this)?.block() + } + + /** + * @return a lambda that can be passed as condition to [UnitSerializer] constructor. + */ + fun <T> ifNoExc(block: String.() -> T): String.() -> T? = + { + try { + block() + } catch (_: Exception) { + null + } + } + + // Constants that are used by multiple serializers to build consistent + // (and easy to change) regexes for deserialization. + // There is no guarantee that they are used with `IGNORE_CASE` option. + + @JvmStatic + protected val NUM_GROUP = Regex("\\s*([\\de.-]+)\\s*?") + + @JvmStatic + protected val BITS = Regex("\\s*(?:b|(?:bit|Bit)(?:|s))\\s?") + + @JvmStatic + protected val BYTES = Regex("\\s*(?:B|(?:byte|Byte)(?:|s))\\s?") + + @JvmStatic + protected val NANO = Regex("\\s*(?:n|nano|Nano)\\s*?") + + @JvmStatic + protected val MICRO = Regex("\\s*(?:micro|Micro)\\s*?") + + @JvmStatic + protected val MILLI = Regex("\\s*(?:m|milli|Milli)\\s*?") + + @JvmStatic + protected val KILO = Regex("\\s*(?:K|Kilo|k|kilo)\\s*?") + + @JvmStatic + protected val KIBI = Regex("\\s*(?:Ki|Kibi|ki|kibi)\\s?") + + @JvmStatic + protected val MEGA = Regex("\\s*(?:M|Mega|m|mega)\\s*?") + + @JvmStatic + protected val MEBI = Regex("\\s*(?:Mi|Mebi|mi|mebi)\\s*?") + + @JvmStatic + protected val GIGA = Regex("\\s*(?:G|Giga|g|giga)\\s*?") + + @JvmStatic + protected val GIBI = Regex("\\s*(?:Gi|Gibi|gi|gibi)\\s*?") + + @JvmStatic + protected val TERA = Regex("\\s*(?:T|Tera|t|tera)\\s*?") + + @JvmStatic + protected val TEBI = Regex("\\s*(?:Ti|Tebi|ti|tebi)\\s*?") + + @JvmStatic + protected val WATTS = Regex("\\s*(?:w|watts|W|Watts)\\s*?") + + @JvmStatic + protected val PER = Regex("\\s*(?:p|per|/)\\s*?") + + @JvmStatic + protected val SEC = Regex("\\s*(?:s|sec|Sec|second|Second)\\s*?") + + @JvmStatic + protected val MIN = Regex("\\s*(?:m|min|Min|minute|Minute)\\s*?") + + @JvmStatic + protected val HOUR = Regex("\\s*(?:h|hour|Hour)\\s*?") + } +} + +/** + * Allows manipulating an abstract JSON representation of the class before serialization or deserialization. + * Maps a [JsonPrimitive] to its [String] representation. + * + * ```json + * // e.g. + * "value": 3 + * // for deserialization becomes + * "value": "3" + */ +internal open class OnlyString<T : Any>(tSerial: KSerializer<T>) : JsonTransformingSerializer<T>(tSerial) { + override fun transformDeserialize(element: JsonElement): JsonElement = JsonPrimitive(element.toString().trim('"')) +} + +/** + * Kotlin's serialization plugin does not have a serializer for [Number]. + * ```kotlin + * // This function allows, when the type inferred without + * // type parameter is Number, to replace + * Json.decodeFromString<Double>(str) + * // with + * Json.decNumFromStr(str) + * + * ``` + */ +internal fun Json.decNumFromStr(str: String): Number = decodeFromString<Double>(str) diff --git a/opendc-common/src/main/kotlin/org/opendc/common/utils/DoubleUtils.kt b/opendc-common/src/main/kotlin/org/opendc/common/utils/DoubleUtils.kt new file mode 100644 index 00000000..ebf6ad21 --- /dev/null +++ b/opendc-common/src/main/kotlin/org/opendc/common/utils/DoubleUtils.kt @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2024 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.opendc.common.utils + +import org.slf4j.Logger +import kotlin.math.abs + +/** + * When comparing 2 doubles, `==` can produce wrong results. The threshold comparison method check that + * the difference between both numbers is within a specified tolerance, commonly called epsilon. + * In this case we use adaptive epsilons, meaning the epsilon is adjusted proportionally to + * the values that are being compared. + * + * This value represents the default epsilon multiplier used if an epsilon is not provided. + */ +internal const val DFLT_EPS_MULTIPLIER: Double = 1e-05 +internal const val DFLT_MIN_EPS: Double = 1.0e-06 + +/** + * Compares [this] with [other] using threshold comparison method with epsilon = [epsilon]. + * + * @param[minEpsilon] the minimum epsilon that can be computed when [epsilon] is not provided. + * If [epsilon] is provided, this param has no effect. + * @param[epsilon] represent the tolerance of the comparison. + * If not provided an adaptive epsilon is computed (based on the largest value in the comparison). + * @return `true` if [this] is considered equal to [other], `false` otherwise. + */ +@JvmOverloads +public fun Double.approx( + other: Double, + minEpsilon: Double = DFLT_MIN_EPS, + epsilon: Double = adaptiveEps(this, other, minEpsilon), +): Boolean = this == other || abs(this - other) <= epsilon + +/** + * Infix version of [approx]. + * @see[approx] + */ +@JvmSynthetic +@JvmName("approx, jvm name to avoid same jvm signature (not invokable from java)") +public infix fun Double.approx(other: Double): Boolean = approx(other, epsilon = DFLT_EPS_MULTIPLIER) + +/** + * @return [this] approximated to [to] if within `[to] - epsilon` and `[to] + epsilon`. + */ +@JvmOverloads +public fun Double.roundToIfWithinEpsilon( + to: Double, + epsilon: Double = DFLT_MIN_EPS, +): Double = + if (this in (to - epsilon)..(to + epsilon)) { + to + } else { + this + } + +/** + * Compares [this] with [other] using threshold comparison method with epsilon = [epsilon]. + * + * @param[minEpsilon] the minimum epsilon that can be computed when [epsilon] is not provided. + * If [epsilon] is provided, this param has no effect. + * @param[epsilon] represent the tolerance of the comparison. + * If not provided an adaptive epsilon is computed (based on the largest value in the comparison). + * @return `true` if [this] is considered larger than [other], `false` otherwise. + */ +@JvmOverloads +public fun Double.approxLarger( + other: Double, + minEpsilon: Double = DFLT_MIN_EPS, + epsilon: Double = adaptiveEps(this, other, minEpsilon), +): Boolean = (this - other) > epsilon + +/** + * Infix version of [approxLarger]. + * @see[approxLarger] + */ +@JvmSynthetic +@JvmName("approxLarger, jvm name to avoid same jvm signature (not invokable from java)") +public infix fun Double.approxLarger(other: Double): Boolean = this.approxLarger(other, epsilon = DFLT_EPS_MULTIPLIER) + +/** + * Compares [this] with [other] using threshold comparison method with epsilon = [epsilon]. + * + * @param[minEpsilon] the minimum epsilon that can be computed when [epsilon] is not provided. + * If [epsilon] is provided, this param has no effect. + * @param[epsilon] represent the tolerance of the comparison. + * If not provided an adaptive epsilon is computed (based on the largest value in the comparison). + * @return `true` if [this] is considered larger or equal than [other], `false` otherwise. + */ +@JvmOverloads +public fun Double.approxLargerOrEq( + other: Double, + minEpsilon: Double = DFLT_MIN_EPS, + epsilon: Double = adaptiveEps(this, other, minEpsilon), +): Boolean = (this - other) > -epsilon + +/** + * Infix version of [approxLargerOrEq]. + * @see[approxLargerOrEq] + */ +@JvmSynthetic +@JvmName("approxLargerOrEq, jvm name to avoid same jvm signature (not invokable from java)") +public infix fun Double.approxLargerOrEq(other: Double): Boolean = this.approxLargerOrEq(other, epsilon = DFLT_EPS_MULTIPLIER) + +/** + * Compares [this] with [other] using threshold comparison method with epsilon = [epsilon]. + * + * @param[minEpsilon] the minimum epsilon that can be computed when [epsilon] is not provided. + * If [epsilon] is provided, this param has no effect. + * @param[epsilon] represent the tolerance of the comparison. + * If not provided an adaptive epsilon is computed (based on the largest value in the comparison). + * @return `true` if [this] is considered smaller than [other], `false` otherwise. + */ +@JvmOverloads +public fun Double.approxSmaller( + other: Double, + minEpsilon: Double = DFLT_MIN_EPS, + epsilon: Double = adaptiveEps(this, other, minEpsilon), +): Boolean = (this - other) < -epsilon + +/** + * Infix version of [approxLarger]. + * @see[approxLarger] + */ +@JvmSynthetic +@JvmName("approxSmaller, jvm name to avoid same jvm signature (not invokable from java)") +public infix fun Double.approxSmaller(other: Double): Boolean = this.approxLarger(other, epsilon = DFLT_EPS_MULTIPLIER) + +/** + * Compares [this] with [other] using threshold comparison method with epsilon = [epsilon]. + * + * @param[minEpsilon] the minimum epsilon that can be computed when [epsilon] is not provided. + * If [epsilon] is provided, this param has no effect. + * @param[epsilon] represent the tolerance of the comparison. + * If not provided an adaptive epsilon is computed (based on the largest value in the comparison). + * @return `true` if [this] is considered smaller or equal than [other], `false` otherwise. + */ +@JvmOverloads +public fun Double.approxSmallerOrEq( + other: Double, + minEpsilon: Double = DFLT_MIN_EPS, + epsilon: Double = adaptiveEps(this, other, minEpsilon), +): Boolean = this - other < epsilon + +/** + * Infix version of [approxSmallerOrEq]. + * @see[approxSmallerOrEq] + */ +@JvmSynthetic +@JvmName("approxSmallerOrEq, jvm name to avoid same jvm signature (not invokable from java)") +public infix fun Double.approxSmallerOrEq(other: Double): Boolean = approxSmallerOrEq(other, DFLT_EPS_MULTIPLIER) + +/** + * @return the result of [block] if [this] is NaN, [this] otherwise. + */ +public inline infix fun Double.ifNaN(block: () -> Double): Double = + if (this.isNaN()) { + block() + } else { + this + } + +/** + * @return [replacement] if [this] is NaN, [this] otherwise. + */ +public infix fun Double.ifNaN(replacement: Double): Double = + if (this.isNaN()) { + replacement + } else { + this + } + +/** + * @return adaptive epsilon computed proportionally to the max absolute value of [a] and [b] + */ +internal fun adaptiveEps( + a: Double, + b: Double, + minEpsilon: Double = DFLT_MIN_EPS, +): Double = DFLT_EPS_MULTIPLIER * maxOf(minEpsilon, abs(a), abs(b)) + +/** + * ```kotlin + * // replace + * String.format("%.3f", doubleValue) + * // with + * doubleValue.fmt("%.3f") + * ``` + * + * @return [this] formatted by [fmt]. + */ +public fun Double.fmt(fmt: String): String = String.format(fmt, this) + +/** + * If [this] is a `-.0` [Double], it converts it to a `+.0` one. + * Useful for comparisons, since `-.0 >= +.0` is `false`. + * @param[warnLogger] the [Logger] to use to log the warning msg if any. + */ +public fun Double.ifNeg0thenPos0(warnLogger: Logger? = null): Double = + if (this == -.0) { + warnLogger?.warn("negative 0 floating point converted to positive 0") + .0 + } else { + this + } |
