From 65d1e4bb1cb836d6f4b4194482b27bd2ec134039 Mon Sep 17 00:00:00 2001 From: nathaniel Date: Wed, 15 Jul 2026 10:55:18 +0200 Subject: [PATCH 1/3] feat: allow to round values in CSVExporter --- .../src/test/resources/testbase.yml | 2 ++ .../it/unibo/alchemist/boundary/exporters/CSVExporter.kt | 9 ++++++++- .../kotlin/it/unibo/alchemist/test/TestCSVExporter.kt | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/alchemist-incarnation-protelis/src/test/resources/testbase.yml b/alchemist-incarnation-protelis/src/test/resources/testbase.yml index 706ab5da8b..126e1cd58f 100644 --- a/alchemist-incarnation-protelis/src/test/resources/testbase.yml +++ b/alchemist-incarnation-protelis/src/test/resources/testbase.yml @@ -3,6 +3,8 @@ export: - type: CSVExporter parameters: fileNameRoot: "test_base" + interval: 1.0 + decimalPlacesCount: 3 data: - time diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/exporters/CSVExporter.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/exporters/CSVExporter.kt index 6599ebbbd4..1d6ce8d248 100644 --- a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/exporters/CSVExporter.kt +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/exporters/CSVExporter.kt @@ -37,12 +37,14 @@ import org.slf4j.LoggerFactory * @property exportPath the directory to write exported files (temporary folder is used when omitted) * @property appendTime if true a timestamp is appended to the file name to avoid overwriting * @property fileExtension the extension for the exported files (default: 'csv') + * @property decimalPlacesCount the count of places after decimal */ class CSVExporter> @JvmOverloads constructor( private val fileNameRoot: String = "", val interval: Double = DEFAULT_INTERVAL, + val decimalPlacesCount: String = "2", val exportPath: String = createTempDirectory("alchemist-export") .absolutePathString() @@ -100,7 +102,8 @@ constructor( val data = extractor.extractDataAsText(environment, reaction, time, step) val names = extractor.columnNames when { - data.size <= 1 -> data.values.joinToString(" ") + data.size <= 1 -> + roundValues(data.values, decimalPlacesCount.toInt()).joinToString(" ") // Labels and keys match data.size == names.size && data.keys.containsAll(names) -> names.joinToString(" ") { @@ -140,6 +143,10 @@ constructor( } } + private fun roundValues(values: Collection, decimalsNumber: Int): Collection = values.map { + if (it.toDouble() % 0 == 0.0) it else String.format(Locale.US, "%.${decimalsNumber}f", it.toDouble()) + } + private companion object { /** * Character used to separate comments from data on export files. diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt index 9f07baa69d..afa266feb1 100644 --- a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt @@ -57,7 +57,7 @@ class TestCSVExporter> : } "should have limited-length decimals" { val limitedDecimalsFile = simulation.csvExporters()[1].dataFile("fixed-decimals_") - val precision2 = """(0\.0*\d\d|\d\.0*\d|\d\.\d|\d\d)(e(-|\+)\d+)?""" + val precision2 = """(\d\d.\d\d|0\.0*\d\d|\d\.0*\d|\d\.\d|\d\d)(e(-|\+)\d+)?""" val lineRegex = Regex("""^$precision2(\s($precision2))+$""") limitedDecimalsFile.useLines { lines -> lines From 339314ba07fc35734dafa6c96f5144f1440f2c00 Mon Sep 17 00:00:00 2001 From: nathaniel Date: Wed, 15 Jul 2026 10:55:18 +0200 Subject: [PATCH 2/3] feat: allow to round values in CSVExporter --- .../boundary/exporters/CSVExporter.kt | 34 ++++++++++++------- .../unibo/alchemist/test/TestCSVExporter.kt | 2 +- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/exporters/CSVExporter.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/exporters/CSVExporter.kt index 1d6ce8d248..eb69a209dd 100644 --- a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/exporters/CSVExporter.kt +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/exporters/CSVExporter.kt @@ -44,7 +44,7 @@ class CSVExporter> constructor( private val fileNameRoot: String = "", val interval: Double = DEFAULT_INTERVAL, - val decimalPlacesCount: String = "2", + val decimalPlacesCount: Int = 2, val exportPath: String = createTempDirectory("alchemist-export") .absolutePathString() @@ -103,17 +103,20 @@ constructor( val names = extractor.columnNames when { data.size <= 1 -> - roundValues(data.values, decimalPlacesCount.toInt()).joinToString(" ") + roundValues(data.values, decimalPlacesCount).joinToString(" ") // Labels and keys match data.size == names.size && data.keys.containsAll(names) -> - names.joinToString(" ") { - requireNotNull(data[it]) { - BugReporting.reportBug( - "Bug in ${this::class.simpleName}", - mapOf("key" to it, "data" to data), - ) - } - } + roundValues( + names.map { name -> + requireNotNull(data[name]) { + BugReporting.reportBug( + "Bug in ${this::class.simpleName}", + mapOf("key" to name, "data" to data), + ) + } + }, + decimalPlacesCount, + ).joinToString(" ") // If the labels do not match keys, require predictable iteration order else -> { require(data.hasPredictableIteration) { @@ -125,7 +128,7 @@ constructor( """.trimIndent(), ) } - data.values.joinToString(" ") + roundValues(data.values, decimalPlacesCount).joinToString(" ") } } } @@ -143,8 +146,13 @@ constructor( } } - private fun roundValues(values: Collection, decimalsNumber: Int): Collection = values.map { - if (it.toDouble() % 0 == 0.0) it else String.format(Locale.US, "%.${decimalsNumber}f", it.toDouble()) + private fun roundValues(values: Collection, decimalsNumber: Int): Collection = values.map { raw -> + val number = raw.toDoubleOrNull() + if (number == null || !number.isFinite() || number % 1.0 == 0.0) { + raw + } else { + String.format(Locale.US, "%.${decimalsNumber}f", number) + } } private companion object { diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt index afa266feb1..b39ce25da9 100644 --- a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt @@ -57,7 +57,7 @@ class TestCSVExporter> : } "should have limited-length decimals" { val limitedDecimalsFile = simulation.csvExporters()[1].dataFile("fixed-decimals_") - val precision2 = """(\d\d.\d\d|0\.0*\d\d|\d\.0*\d|\d\.\d|\d\d)(e(-|\+)\d+)?""" + val precision2 = """(\d\d\.\d\d|0\.0*\d\d|\d\.0*\d|\d\.\d|\d\d)(e(-|\+)\d+)?""" val lineRegex = Regex("""^$precision2(\s($precision2))+$""") limitedDecimalsFile.useLines { lines -> lines From c795acbd0606fe4105774c4721979be0626d58af Mon Sep 17 00:00:00 2001 From: Nathekip Date: Mon, 10 Aug 2026 15:51:32 +0200 Subject: [PATCH 3/3] feat: allow to round values in CSVExporter --- .../unibo/alchemist/test/TestCSVExporter.kt | 23 +++++++++ .../testCSVExporterMultipleColumns.yml | 48 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 alchemist-loading/src/test/resources/testCSVExporterMultipleColumns.yml diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt index b39ce25da9..c2103be42c 100644 --- a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/test/TestCSVExporter.kt @@ -85,6 +85,29 @@ class TestCSVExporter> : exporterFirstLine.shouldNotBeEmpty() exporterFirstLine.shouldContain("0 1 2 3") } + "should round every column independently" { + val simulation: Simulation = loadAlchemist("testCSVExporterMultipleColumns.yml") + simulation.runInCurrentThread() + + val exporter = simulation.csvExporters().first() + + val outputFile = File(exporter.exportPath) + .listFiles() + ?.first { it.name.startsWith("multiple-columns") } + .shouldNotBeNull() + + val firstDataLine = outputFile + .readLines() + .first { line -> line.isNotBlank() && !line.startsWith("#") } + + firstDataLine.split(" ") shouldBe listOf( + "0.0", + "1.23", + "2.35", + "3.0", + "4.57", + ) + } }) { // common utility functions companion object { diff --git a/alchemist-loading/src/test/resources/testCSVExporterMultipleColumns.yml b/alchemist-loading/src/test/resources/testCSVExporterMultipleColumns.yml new file mode 100644 index 0000000000..61e0a9107c --- /dev/null +++ b/alchemist-loading/src/test/resources/testCSVExporterMultipleColumns.yml @@ -0,0 +1,48 @@ +incarnation: protelis + +variables: + seed: &seed + default: 0 + min: 0 + max: 99 + step: 1 + scenario_seed: &scenario_seed + formula: (seed + 31) * seed + +seeds: + simulation: *seed + scenario: *scenario_seed + +export: + - type: CSVExporter + parameters: + fileNameRoot: "multiple-columns" + interval: 1.0 + decimalPlacesCount: 2 + data: + - time + - molecule: "default_module:default_program" + property: 1.23456 + aggregators: [ mean ] + - molecule: "default_module:default_program" + property: 2.34567 + aggregators: [ mean ] + - molecule: "default_module:default_program" + property: 3 + aggregators: [ mean ] + - molecule: "default_module:default_program" + property: 4.56789 + aggregators: [ mean ] + +environment: + type: Continuous2DEnvironment + +deployments: + - type: Point + parameters: [1, 0, 0] + programs: + - program: "0" + +terminate: + - type: AfterTime + parameters: 1 \ No newline at end of file