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..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 @@ -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: Int = 2, val exportPath: String = createTempDirectory("alchemist-export") .absolutePathString() @@ -100,17 +102,21 @@ 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).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) { @@ -122,7 +128,7 @@ constructor( """.trimIndent(), ) } - data.values.joinToString(" ") + roundValues(data.values, decimalPlacesCount).joinToString(" ") } } } @@ -140,6 +146,15 @@ constructor( } } + 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 { /** * 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..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 @@ -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 @@ -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