diff --git a/.gitignore b/.gitignore index 4257e9f0d8..d906ce99c2 100644 --- a/.gitignore +++ b/.gitignore @@ -68,4 +68,3 @@ scala_compiler.xml # Exceptions # Keep 'build' packages in src directories !**/src/**/build/ -!**/src/**/build/** diff --git a/.idea/vcs.xml b/.idea/vcs.xml index e7dbf5c4f7..b3f968953f 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -5,4 +5,4 @@ - \ No newline at end of file + diff --git a/alchemist-api/build.gradle.kts b/alchemist-api/build.gradle.kts index 8b9292aa9f..74cf14ebd1 100644 --- a/alchemist-api/build.gradle.kts +++ b/alchemist-api/build.gradle.kts @@ -16,7 +16,6 @@ dependencies { api(libs.jool) api(libs.listset) implementation(libs.kotlin.reflect) - testImplementation(libs.kotlin.test) } diff --git a/alchemist-api/src/main/kotlin/it/unibo/alchemist/boundary/dsl/AlchemistKotlinDSL.kt b/alchemist-api/src/main/kotlin/it/unibo/alchemist/boundary/dsl/AlchemistKotlinDSL.kt new file mode 100644 index 0000000000..7f66c8492a --- /dev/null +++ b/alchemist-api/src/main/kotlin/it/unibo/alchemist/boundary/dsl/AlchemistKotlinDSL.kt @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl + +/** + * Annotation used to mark classes that should have DSL builder functions generated. + * When applied to a class, the DSL processor will generate a builder function + * that can be used in Alchemist DSL scripts to create instances of the annotated class. + */ +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.SOURCE) +annotation class AlchemistKotlinDSL diff --git a/alchemist-dsl-processor/build.gradle.kts b/alchemist-dsl-processor/build.gradle.kts new file mode 100644 index 0000000000..9552fe4017 --- /dev/null +++ b/alchemist-dsl-processor/build.gradle.kts @@ -0,0 +1,16 @@ +import Libs.alchemist + +plugins { + id("kotlin-multiplatform-convention") +} + +kotlin { + sourceSets { + val jvmMain by getting { + dependencies { + api(alchemist("api")) + implementation(libs.ksp.api) + } + } + } +} diff --git a/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/DslBuilderProcessor.kt b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/DslBuilderProcessor.kt new file mode 100644 index 0000000000..5350ed6964 --- /dev/null +++ b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/DslBuilderProcessor.kt @@ -0,0 +1,166 @@ +package it.unibo.alchemist.boundary.dsl.processor + +import com.google.devtools.ksp.getClassDeclarationByName +import com.google.devtools.ksp.getConstructors +import com.google.devtools.ksp.isPublic +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.validate +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL +import it.unibo.alchemist.boundary.dsl.processor.data.InjectableConstructor +import it.unibo.alchemist.boundary.dsl.processor.extensions.asString +import it.unibo.alchemist.boundary.dsl.processor.extensions.nameOrTypeName +import it.unibo.alchemist.boundary.dsl.processor.extensions.typeName +import it.unibo.alchemist.core.Simulation +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Incarnation +import it.unibo.alchemist.model.LinkingRule +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.Reaction +import it.unibo.alchemist.model.TimeDistribution +import java.io.PrintWriter +import java.nio.charset.StandardCharsets +import org.apache.commons.math3.random.RandomGenerator + +/** Symbol processor that emits DSL helpers for `@AlchemistKotlinDSL` classes. */ +class DslBuilderProcessor(private val codeGenerator: CodeGenerator, private val logger: KSPLogger) : SymbolProcessor { + + /** + * Processes every `@AlchemistKotlinDSL` symbol, + * generating helpers and returning unresolved ones. + */ + override fun process(resolver: Resolver): List { + context(resolver) { + logger.dslInfo("Starting processing") + val annotationName = AlchemistKotlinDSL::class.qualifiedName + check(!annotationName.isNullOrBlank()) { + "The Alchemist Kotlin DSL annotation name is invalid or missing: '$annotationName'" + } + logger.dslInfo("Alchemist DSL annotation: $annotationName") + return resolver.getSymbolsWithAnnotation(annotationName) + .fold(emptyList()) { invalidElements, symbol -> + when { + !symbol.validate() -> invalidElements + symbol + else -> { + if (symbol is KSClassDeclaration) { + processClass(symbol) + } + invalidElements + } + } + } + } + } + + context(resolver: Resolver) + private fun processClass(classDeclaration: KSClassDeclaration) { + logger.dslInfo("Processing class ${classDeclaration.simpleName.asString()}") + logger.dslInfo("Class qualified name: ${classDeclaration.qualifiedName?.asString()}") + val file = codeGenerator.createNewFile( + dependencies = classDeclaration.containingFile + ?.let { Dependencies(false, it) } + ?: Dependencies.ALL_FILES, + packageName = classDeclaration.packageName.asString(), + fileName = "${classDeclaration.simpleName.asString()}Factory.kt", + ) + val writer = PrintWriter(file, true, StandardCharsets.UTF_8) + writer.println("// This file is generated by Alchemist DSL Processor. Do not edit manually.") + writer.println() + writer.println("package ${classDeclaration.packageName.asString()}") + writer.println() + val functionName = classDeclaration.simpleName.asString().replaceFirstChar { it.lowercaseChar() } + val injectableConstructors = classDeclaration.getConstructors() + .filter { it.isPublic() } + .mapNotNull { InjectableConstructor(it) } + .toList() + if (injectableConstructors.isEmpty()) { + logger.warn( + "No injectable constructors, ${AlchemistKotlinDSL::class.qualifiedName} will have no effect.", + classDeclaration, + ) + } + injectableConstructors.forEach { (constructor, injectableParams, preservedParams) -> + val context = injectableParams.joinToString(prefix = "context(", postfix = ")") { param -> + "${param.nameOrTypeName()}: ${param.type.asString()}" + } + writer.println(context) + val typeParameters = constructor.typeParameters.takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "<", postfix = "> ") { typeArgument -> + buildString { + if (typeArgument.isReified) { + append("reified ") + } + append(typeArgument.name.asString()) + } + } + .orEmpty() + val parameters = preservedParams.joinToString(separator = NEWLINE_INDENT) { parameter -> + buildString { + if (parameter.isCrossInline) { + append("crossinline ") + } + if (parameter.isNoInline) { + append("noinline ") + } + if (parameter.isVararg) { + append("vararg ") + } + append( + "${parameter.name?.asString()}: ${parameter.type.asString()}", + ) + } + } + val whereClause = constructor.typeParameters + .flatMap { typeParam -> + typeParam.bounds.map { bound -> + "${typeParam.simpleName.asString()} : ${bound.asString()}" + } + } + .takeIf { it.isNotEmpty() } + ?.joinToString(separator = NEWLINE_INDENT, prefix = "where\n ", postfix = "\n") + .orEmpty() + val arguments = constructor.parameters.joinToString(NEWLINE_INDENT) { + buildString { + if (it.isVararg) { + append("*") + } + append(it.nameOrTypeName()) + } + } + writer.println( + """ + |fun $typeParameters$functionName( + | $parameters + |) $whereClause= ${classDeclaration.typeName}( + | $arguments + |) + """.trimMargin(), + ) + } + } + + internal companion object { + + const val NEWLINE_INDENT = ",\n| " + + private fun KSPLogger.dslInfo(message: String) = info("${DslBuilderProcessor::class.simpleName}: $message") + + context(resolver: Resolver) + fun injectableTypes(): Set = sequenceOf( + resolver.getClassDeclarationByName>(), + resolver.getClassDeclarationByName>(), + resolver.getClassDeclarationByName>(), + resolver.getClassDeclarationByName>(), + resolver.getClassDeclarationByName(), + resolver.getClassDeclarationByName>(), + resolver.getClassDeclarationByName>(), + resolver.getClassDeclarationByName>(), + ).map { checkNotNull(it).asStarProjectedType() }.toSet() + } +} diff --git a/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/DslBuilderProcessorProvider.kt b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/DslBuilderProcessorProvider.kt new file mode 100644 index 0000000000..ed5d044265 --- /dev/null +++ b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/DslBuilderProcessorProvider.kt @@ -0,0 +1,13 @@ +package it.unibo.alchemist.boundary.dsl.processor + +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +/** + * Provider for [DslBuilderProcessor] that creates instances for KSP processing. + */ +class DslBuilderProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + DslBuilderProcessor(environment.codeGenerator, environment.logger) +} diff --git a/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/data/InjectableConstructor.kt b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/data/InjectableConstructor.kt new file mode 100644 index 0000000000..935bcc95bf --- /dev/null +++ b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/data/InjectableConstructor.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.processor.data + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSValueParameter +import it.unibo.alchemist.boundary.dsl.processor.extensions.isInjectable + +internal data class InjectableConstructor( + val constructor: KSFunctionDeclaration, + val injectableParameters: List, + val preservedParameters: List, +) { + companion object { + context(resolver: Resolver) + operator fun invoke(constructor: KSFunctionDeclaration): InjectableConstructor? { + val (injectable, preserved) = constructor.parameters.partition { it.type.resolve().isInjectable() } + return when { + injectable.isNotEmpty() && injectable.toSet().size == injectable.size && injectable.none { + it.isVararg + } -> + InjectableConstructor(constructor, injectable, preserved) + else -> null + } + } + } +} diff --git a/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSDeclarationExtensions.kt b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSDeclarationExtensions.kt new file mode 100644 index 0000000000..8dfe02e767 --- /dev/null +++ b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSDeclarationExtensions.kt @@ -0,0 +1,15 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.processor.extensions + +import com.google.devtools.ksp.symbol.KSDeclaration + +internal val KSDeclaration.typeName: String + get() = qualifiedName?.asString()?.takeIf { it.isNotEmpty() } ?: simpleName.asString() diff --git a/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSFunctionDeclarationExtensions.kt b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSFunctionDeclarationExtensions.kt new file mode 100644 index 0000000000..e2e5c01b73 --- /dev/null +++ b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSFunctionDeclarationExtensions.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSValueParameter + +/** + * Finds a public constructor for the given class declaration. + * Prefers the primary constructor, otherwise returns the constructor + * with the most parameters. + * + * @return The found constructor, or null if no suitable constructor exists + */ +internal val KSFunctionDeclaration.parameterTypes: List get() = parameters.map { it.type.resolve() } diff --git a/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSTypeExtensions.kt b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSTypeExtensions.kt new file mode 100644 index 0000000000..e7cc1e85c7 --- /dev/null +++ b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSTypeExtensions.kt @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.processor.extensions + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSType +import it.unibo.alchemist.boundary.dsl.processor.DslBuilderProcessor + +context(resolver: Resolver) +internal fun KSType.isInjectable() = DslBuilderProcessor.injectableTypes().any { it.isAssignableFrom(this) } diff --git a/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSTypeReferenceExtensions.kt b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSTypeReferenceExtensions.kt new file mode 100644 index 0000000000..6fb40f2eb9 --- /dev/null +++ b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSTypeReferenceExtensions.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.processor.extensions + +import com.google.devtools.ksp.symbol.KSTypeParameter +import com.google.devtools.ksp.symbol.KSTypeReference + +internal fun KSTypeReference.asString(): String = buildString { + val type = resolve() + when (val declaration = type.declaration) { + is KSTypeParameter -> append(declaration.simpleName.asString()) + else -> append(declaration.typeName) + } + if (type.arguments.isNotEmpty()) { + append( + type.arguments.joinToString(prefix = "<", postfix = ">") { + it.type?.asString() ?: "*" + }, + ) + } +} diff --git a/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSValueParameterExtensions.kt b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSValueParameterExtensions.kt new file mode 100644 index 0000000000..d0921c5e3d --- /dev/null +++ b/alchemist-dsl-processor/src/jvmMain/kotlin/it/unibo/alchemist/boundary/dsl/processor/extensions/KSValueParameterExtensions.kt @@ -0,0 +1,15 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.processor.extensions + +import com.google.devtools.ksp.symbol.KSValueParameter + +internal fun KSValueParameter.nameOrTypeName(): String = name?.asString() + ?: type.resolve().declaration.simpleName.asString().replaceFirstChar { it.lowercase() } diff --git a/alchemist-dsl-processor/src/jvmMain/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/alchemist-dsl-processor/src/jvmMain/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 0000000000..a7f2e32765 --- /dev/null +++ b/alchemist-dsl-processor/src/jvmMain/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +it.unibo.alchemist.boundary.dsl.processor.DslBuilderProcessorProvider \ No newline at end of file diff --git a/alchemist-full/build.gradle.kts b/alchemist-full/build.gradle.kts index b29372d13d..1a032774ce 100644 --- a/alchemist-full/build.gradle.kts +++ b/alchemist-full/build.gradle.kts @@ -280,7 +280,7 @@ val packageTasks = validFormats.filterIsInstance().map { packagi tasks.assemble.configure { dependsOn(packageTasks) } tasks.withType { - duplicatesStrategy = DuplicatesStrategy.WARN + duplicatesStrategy = DuplicatesStrategy.INCLUDE } publishing.publications { diff --git a/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/timedistributions/ExponentialTime.java b/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/timedistributions/ExponentialTime.java index b9104d95e1..adc9134d7c 100644 --- a/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/timedistributions/ExponentialTime.java +++ b/alchemist-implementationbase/src/main/java/it/unibo/alchemist/model/timedistributions/ExponentialTime.java @@ -10,6 +10,7 @@ package it.unibo.alchemist.model.timedistributions; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL; import it.unibo.alchemist.model.Environment; import it.unibo.alchemist.model.Node; import it.unibo.alchemist.model.Time; @@ -25,6 +26,7 @@ * * @param concentration type */ +@AlchemistKotlinDSL public class ExponentialTime extends AbstractDistribution { @Serial diff --git a/alchemist-loading/build.gradle.kts b/alchemist-loading/build.gradle.kts index f7d25a6b9c..49e7cc2f93 100644 --- a/alchemist-loading/build.gradle.kts +++ b/alchemist-loading/build.gradle.kts @@ -19,6 +19,7 @@ import Libs.incarnation */ plugins { id("kotlin-jvm-convention") + id("com.google.devtools.ksp") } dependencies { @@ -41,17 +42,34 @@ dependencies { implementation(libs.mongodb) implementation(libs.snakeyaml) + implementation("org.jetbrains.kotlin:kotlin-scripting-common:${libs.versions.kotlin.get()}") + implementation("org.jetbrains.kotlin:kotlin-scripting-jvm:${libs.versions.kotlin.get()}") + implementation("org.jetbrains.kotlin:kotlin-scripting-jvm-host:${libs.versions.kotlin.get()}") + runtimeOnly(libs.groovy.jsr223) runtimeOnly(kotlin("scripting-jsr223")) runtimeOnly(libs.scala.compiler) testImplementation(alchemist("engine")) testImplementation(alchemist("maps")) + testImplementation(alchemist("test")) testImplementation(libs.appdirs) testImplementation(libs.caffeine) testImplementation(libs.embedmongo) testRuntimeOnly(incarnation("sapere")) testRuntimeOnly(incarnation("protelis")) + implementation(kotlin("script-runtime")) + + ksp(project(":alchemist-dsl-processor")) +} + +kotlin { + sourceSets.main { + kotlin.srcDir("build/generated/ksp/main/kotlin") + } + sourceSets.test { + kotlin.srcDir("build/generated/ksp/test/kotlin") + } } tasks.withType { @@ -68,6 +86,7 @@ tasks.withType { compilerOptions { freeCompilerArgs.addAll( "-opt-in=kotlin.time.ExperimentalTime", + "-Xcontext-parameters", ) } } diff --git a/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Circle.java b/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Circle.java index 8d34438e40..195873e696 100644 --- a/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Circle.java +++ b/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Circle.java @@ -9,6 +9,7 @@ package it.unibo.alchemist.model.deployments; +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL; import it.unibo.alchemist.model.Environment; import it.unibo.alchemist.model.Position; import org.apache.commons.math3.random.RandomGenerator; @@ -23,6 +24,7 @@ /** * @param

{@link Position} type */ +@AlchemistKotlinDSL public final class Circle

> extends AbstractRandomDeployment

{ private final double centerX; diff --git a/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/GeometricGradientRectangle.java b/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/GeometricGradientRectangle.java index aee38e8ea6..c403d76996 100644 --- a/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/GeometricGradientRectangle.java +++ b/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/GeometricGradientRectangle.java @@ -9,6 +9,7 @@ package it.unibo.alchemist.model.deployments; +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL; import it.unibo.alchemist.model.Environment; import it.unibo.alchemist.model.Position; import org.apache.commons.math3.distribution.ExponentialDistribution; @@ -22,6 +23,7 @@ * * @param

position type */ +@AlchemistKotlinDSL public final class GeometricGradientRectangle

> extends Rectangle

{ private final ExponentialDistribution exp; diff --git a/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Point.java b/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Point.java index d4f6fa7bd2..16c1cfdcee 100644 --- a/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Point.java +++ b/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Point.java @@ -9,6 +9,7 @@ package it.unibo.alchemist.model.deployments; +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL; import it.unibo.alchemist.model.Deployment; import it.unibo.alchemist.model.Environment; import it.unibo.alchemist.model.Position; @@ -21,6 +22,7 @@ * * @param

position type */ +@AlchemistKotlinDSL public final class Point

> implements Deployment

{ private final double x; diff --git a/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Rectangle.java b/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Rectangle.java index 50f4d2f6fb..f12ae1b5e4 100644 --- a/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Rectangle.java +++ b/alchemist-loading/src/main/java/it/unibo/alchemist/model/deployments/Rectangle.java @@ -9,6 +9,7 @@ package it.unibo.alchemist.model.deployments; +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL; import it.unibo.alchemist.model.Environment; import it.unibo.alchemist.model.Position; import org.apache.commons.math3.random.RandomGenerator; @@ -18,6 +19,7 @@ /** * @param

position type */ +@AlchemistKotlinDSL public class Rectangle

> extends AbstractRandomDeployment

{ private final double x; diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/AlchemistLoaderProvider.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/AlchemistLoaderProvider.kt new file mode 100644 index 0000000000..a1936ac090 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/AlchemistLoaderProvider.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary + +import java.io.InputStream +import java.io.Reader +import java.net.URL + +/** + * Provider interface for loading Alchemist simulations from various input sources. + */ +interface AlchemistLoaderProvider : Extensions { + + /** + * Creates a [Loader] from a string input. + * + * @param input The input string containing the simulation configuration. + * @return A [Loader] instance. + */ + fun from(input: String): Loader + + /** + * Creates a [Loader] from a [Reader] input. + * + * @param input The reader containing the simulation configuration. + * @return A [Loader] instance. + */ + fun from(input: Reader): Loader = from(input.readText()) + + /** + * Creates a [Loader] from an [InputStream] input. + * + * @param input The input stream containing the simulation configuration. + * @return A [Loader] instance. + */ + fun from(input: InputStream): Loader = from(input.reader()) + + /** + * Creates a [Loader] from a [URL] input. + * + * @param input The URL pointing to the simulation configuration. + * @return A [Loader] instance. + */ + fun from(input: URL): Loader = from(input.openStream()) +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/AlchemistModelProvider.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/AlchemistModelProvider.kt index 5a1dc02d4e..10215365a1 100644 --- a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/AlchemistModelProvider.kt +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/AlchemistModelProvider.kt @@ -16,11 +16,7 @@ import java.net.URL /** * Translates inputs to a Map representing the Alchemist model. */ -interface AlchemistModelProvider { - /** - * A [Regex] matching the file extensions supported by this provider. - */ - val fileExtensions: Regex +interface AlchemistModelProvider : Extensions { /** * Reads [input] from a [String]. diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/Extensions.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/Extensions.kt new file mode 100644 index 0000000000..32f9befeb1 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/Extensions.kt @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary + +/** + * Interface for extensions that provide file extension matching. + */ +interface Extensions { + /** + * A [Regex] matching the file extensions supported. + */ + val fileExtensions: Regex +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/LoadAlchemist.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/LoadAlchemist.kt index 70ff3a74de..b2972d0119 100644 --- a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/LoadAlchemist.kt +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/LoadAlchemist.kt @@ -72,16 +72,28 @@ object LoadAlchemist { */ @JvmStatic @JvmOverloads - fun from(url: URL, overrides: List = emptyList()): Loader = - from(url, modelForExtension(url.path.takeLastWhile { it != '.' }), overrides) + fun from(url: URL, overrides: List = emptyList()): Loader { + val ext = url.path.takeLastWhile { it != '.' } + return if (ext.equals("kts", ignoreCase = true)) { + loaderProviderFor(ext).from(url.openStream()) + } else { + from(url, modelForExtension(ext), overrides) + } + } /** * Load from a [file] with overrides. */ @JvmStatic @JvmOverloads - fun from(file: File, overrides: List = emptyList()): Loader = - from(file.inputStream(), modelForExtension(file.extension), overrides) + fun from(file: File, overrides: List = emptyList()): Loader { + val ext = file.extension + return if (ext.equals("kts", ignoreCase = true)) { + loaderProviderFor(ext).from(file.inputStream()) + } else { + from(file.inputStream(), modelForExtension(ext), overrides) + } + } /** * Load from a [string] with overrides. @@ -91,8 +103,14 @@ object LoadAlchemist { fun from(string: String, overrides: List = emptyList()): Loader = from(File(string), overrides) @JvmStatic - private fun modelForExtension(extension: String) = ClassPathScanner - .subTypesOf(extractPackageFrom()) + private fun modelForExtension(extension: String) = loadForExtension(extension) + + @JvmStatic + private fun loaderProviderFor(extension: String) = loadForExtension(extension) + + @JvmStatic + private inline fun loadForExtension(extension: String) = ClassPathScanner + .subTypesOf(extractPackageFrom()) .mapNotNull { it.kotlin.objectInstance } .filter { it.fileExtensions.matches(extension) } .also { require(it.size == 1) { "None or conflicting loaders for extension $extension: $it" } } diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/DSLLoader.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/DSLLoader.kt new file mode 100644 index 0000000000..9964889d3b --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/DSLLoader.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl + +import it.unibo.alchemist.boundary.Exporter +import it.unibo.alchemist.boundary.Loader +import it.unibo.alchemist.boundary.dsl.model.SimulationContext +import it.unibo.alchemist.boundary.dsl.model.SimulationContextImpl +import it.unibo.alchemist.boundary.exporters.GlobalExporter +import it.unibo.alchemist.core.Engine +import it.unibo.alchemist.core.Simulation +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Position +import java.util.concurrent.Semaphore + +/** + * Abstract base class for single-use DSL loaders. + * + * @param ctx The simulation context. + */ +abstract class DSLLoader>( + private val ctx: SimulationContext<*, *>, + private val envFactory: () -> Environment<*, *>, +) : Loader { + override fun > getWith(values: Map): Simulation = + SingleUseLoader(ctx).load(values) + private inner class SingleUseLoader(private val ctx: SimulationContext<*, *>) { + private val mutex = Semaphore(1) + private var consumed = false + + @Suppress("UNCHECKED_CAST") + fun > load(values: Map): Simulation { + try { + mutex.acquireUninterruptibly() + check(!consumed) { "This loader has already been consumed! This is a bug in Alchemist" } + consumed = true + } finally { + mutex.release() + } + val typedCtx = ctx as SimulationContextImpl + val envInstance = envFactory() as Environment + val unknownVariableNames = values.keys - this@DSLLoader.variables.keys + require(unknownVariableNames.isEmpty()) { + "Unknown variables provided: $unknownVariableNames." + + " Valid names: ${this@DSLLoader.variables.keys}. Provided: ${values.keys}" + } + // VARIABLE REIFICATION + ctx.variablesContext.addReferences( + ctx.variablesContext.dependentVariables.map { (k, v) -> + k to v() + }.toMap(), + ) + val simulationIstance = typedCtx.build(envInstance, values) + val environment = simulationIstance.environment + val engine = Engine(environment) + // MONITORS + simulationIstance.monitors.forEach { monitor -> + engine.addOutputMonitor(monitor) + } + // EXPORTERS + val exporters = simulationIstance.exporters.map { + it.type.apply { + it.type?.bindDataExtractors(it.extractors) + } + } as List> + exporters.forEach { it.bindVariables(ctx.variablesContext.references.get()) } + if (exporters.isNotEmpty()) { + engine.addOutputMonitor(GlobalExporter(exporters)) + } + return engine + } + } +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/Dsl.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/Dsl.kt new file mode 100644 index 0000000000..2ded8dfc7e --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/Dsl.kt @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl + +import it.unibo.alchemist.boundary.DependentVariable +import it.unibo.alchemist.boundary.Launcher +import it.unibo.alchemist.boundary.Loader +import it.unibo.alchemist.boundary.Variable +import it.unibo.alchemist.boundary.dsl.model.AvailableIncarnations as Inc +import it.unibo.alchemist.boundary.dsl.model.SimulationContext +import it.unibo.alchemist.boundary.dsl.model.SimulationContextImpl +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Incarnation +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.SupportedIncarnations +import it.unibo.alchemist.model.environments.Continuous2DEnvironment +import it.unibo.alchemist.model.positions.Euclidean2DPosition +import kotlin.jvm.optionals.getOrElse +import org.apache.commons.math3.random.RandomGenerator + +/** + * Marker annotation for Alchemist DSL elements. + * + * This annotation is used to mark DSL context classes and functions, + * preventing scope pollution in DSL blocks. + */ +@DslMarker +annotation class AlchemistDsl + +/** + * Main DSL object for creating Alchemist simulations. + * + * This object provides factory methods for creating simulation loaders + * and configuring Alchemist simulations using a type-safe DSL. + */ +object Dsl { + /** + * Creates a loader from a simulation context. + * + * @param dsl The simulation context. + * @return A loader instance. + */ + fun > createLoader( + builder: SimulationContextImpl, + envBuilder: () -> Environment, + ): Loader = object : DSLLoader(builder, envBuilder) { + override val constants: Map = emptyMap() + override val dependentVariables: Map> = emptyMap() + override val variables: Map> = builder.variablesContext.variables + override val remoteDependencies: List = emptyList() + override val launcher: Launcher = builder.launcher + } + + /** + * Converts an Incarnation enum to an Incarnation instance. + * + * @return The incarnation instance. + */ + fun > Inc.incarnation(): Incarnation = + SupportedIncarnations.get(this.name).getOrElse { + throw IllegalArgumentException("Incarnation $this not supported") + } + + /** + * Creates a simulation with a custom environment. + * + * @param incarnation The incarnation instance. + * @param environment The environment instance. + * @param block The simulation configuration block. + * @return A loader instance. + */ + fun > simulation( + incarnation: Incarnation, + environment: () -> Environment, + block: SimulationContext.() -> Unit, + ): Loader { + val ctx = SimulationContextImpl(incarnation) + @Suppress("UNCHECKED_CAST") + ctx.apply(block) + return createLoader(ctx, environment) + } + + /** + * Creates a simulation with a default 2D continuous environment. + * + * @param incarnation The incarnation instance. + * @param block The simulation configuration block. + * @return A loader instance. + */ + fun simulation( + incarnation: Incarnation, + block: context( + RandomGenerator, + Environment + ) SimulationContext.() -> Unit, + ): Loader { + @Suppress("UNCHECKED_CAST") + val defaultEnv = { Continuous2DEnvironment(incarnation) } + val ctx = SimulationContextImpl(incarnation) + @Suppress("UNCHECKED_CAST") + ctx.apply { + context(ctx.simulationGenerator, ctx.environment) { + block() + } + } + return createLoader(ctx, defaultEnv) + } +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/aliases/Extractors.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/aliases/Extractors.kt new file mode 100644 index 0000000000..85b7a0418d --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/aliases/Extractors.kt @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.aliases + +import it.unibo.alchemist.boundary.extractors.Time + +/** + * Helper to disambiguate Time() in scripts: resolves to the extractor, not the model type. + */ +fun Time(precision: Int? = null): Time = Time(precision) diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/AvailableIncarnations.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/AvailableIncarnations.kt new file mode 100644 index 0000000000..43ffe0ce0c --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/AvailableIncarnations.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +/** + * Enumeration of available Alchemist incarnations. + */ +enum class AvailableIncarnations { + /** + * SAPERE incarnation. + */ + SAPERE, + + /** + * PROTELIS incarnation. + */ + PROTELIS, + + /** + * SCAFI incarnation. + */ + SCAFI, + + /** + * BIOCHEMISTRY incarnation. + */ + BIOCHEMISTRY, +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/DeploymentContext.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/DeploymentContext.kt new file mode 100644 index 0000000000..36efda469e --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/DeploymentContext.kt @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.dsl.AlchemistDsl +import it.unibo.alchemist.model.Deployment +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.PositionBasedFilter +import org.apache.commons.math3.random.RandomGenerator + +/** + * Context interface for managing node deployments in a simulation. + * + * Deployments define where nodes are placed in the environment and can be configured + * with content (molecules and concentrations), programs (reactions), and properties. + * + * ## Usage Example + * + * ```kotlin + * simulation(incarnation) { + * deployments { + * deploy(grid(-5.0, -5.0, 5.0, 5.0, 0.25, 0.25)) { + * all { + * molecule = "token" + * concentration = 1.0 + * } + * programs { + * all { + * program = "{token} --> {firing}" + * } + * } + * } + * } + * } + * ``` + * + * @param T The type of molecule concentration. + * @param P The type of position, must extend [Position]. + * + * @see [SimulationContext.deployments] for configuring deployments in a simulation + * @see [Deployment] for the deployment interface + * @see [DeploymentContext] for configuring individual deployments + */ +@Suppress("UndocumentedPublicFunction") // Detekt false positive with context parameters +interface DeploymentsContext> { + /** + * The simulation context this deployments context belongs to. + */ + val ctx: SimulationContext + + /** + * The random number generator for scenario generation. + * + * Used for random deployments and position perturbations. + * + * @see [RandomGenerator] + */ + val generator: RandomGenerator + + /** + * Deploys nodes using a deployment with a configuration block. + * + * The configuration block allows setting content, programs, properties, and custom node factories. + * + * ```kotlin + * deploy(grid(-5.0, -5.0, 5.0, 5.0, 0.25, 0.25)) { + * all { molecule = "token" } + * } + * ``` + * + * @param deployment The deployment that defines node positions. + * @param block The configuration block for the deployment. + * @see [Deployment] + */ + fun deploy(deployment: Deployment<*>, block: DeploymentContext.() -> Unit) + + /** + * Deploys nodes using a deployment without additional configuration. + * + * Nodes are created at the positions defined by the deployment with default settings. + * + * @param deployment The deployment that defines node positions. + * @see [Deployment] + */ + context(environment: Environment) + fun deploy(deployment: Deployment<*>) +} + +/** + * Context interface for configuring a single deployment. + * + * This context allows configuring content (molecules and concentrations), programs (reactions), + * properties, and custom node factories for nodes deployed at positions defined by the deployment. + * + * @param T The type of molecule concentration. + * @param P The type of position, must extend [Position]. + * + * @see [DeploymentsContext] for the parent context + * @see [ContentContext] for configuring node content + * @see [ProgramsContext] for configuring node programs + * @see [PropertiesContext] for configuring node properties + */ +interface DeploymentContext> { + /** + * The deployments context this deployment context belongs to. + */ + val ctx: DeploymentsContext + + /** + * Configures content (molecules and concentrations) for all positions in the deployment. + * + * All nodes deployed at positions defined by this deployment will receive the configured content. + * + * ```kotlin + * all { + * molecule = "token" + * concentration = 1.0 + * } + * ``` + * + * @param block The content configuration block. + * @see [ContentContext] + */ + fun all(block: ContentContext.() -> Unit) + + /** + * Configures content for positions inside a filter. + * + * Only nodes deployed at positions matching the filter will receive the configured content. + * + * ```kotlin + * inside(RectangleFilter(-1.0, -1.0, 2.0, 2.0)) { + * molecule = "specialToken" + * } + * ``` + * + * @param filter The position filter to apply. + * @param block The content configuration block. + * @see [PositionBasedFilter] + * @see [ContentContext] + */ + fun inside(filter: PositionBasedFilter<*>, block: ContentContext.() -> Unit) + + /** + * Configures programs (reactions) for this deployment. + * + * Programs define the behavior of nodes through reactions. + * + * ```kotlin + * programs { + * all { + * program = "{token} --> {firing}" + * } + * } + * ``` + * + * @param block The programs configuration block. + * @see [ProgramsContext] + */ + fun programs(block: ProgramsContext.() -> Unit) + + /** + * Sets a custom node factory for this deployment. + * + * By default, nodes are created using the incarnation's node factory. + * This allows using custom node types. + * + * ```kotlin + * nodes { MyCustomNode() } + * ``` + * + * @param factory The factory function for creating nodes. + * @see [Node] + * @see [it.unibo.alchemist.model.Incarnation.createNode] + */ + fun nodes(factory: (DeploymentContext) -> Node) + + /** + * Configures properties for this deployment. + * + * Properties can be assigned to nodes based on their position. + * + * ```kotlin + * properties { + * inside(RectangleFilter(-3.0, -3.0, 2.0, 2.0)) { + * add(MyNodeProperty()) + * } + * } + * ``` + * + * @param block The properties configuration block. + * @see [PropertiesContext] + */ + fun properties(block: PropertiesContext.() -> Unit) +} + +/** + * Context interface for configuring node content (molecules and concentrations). + * + * This context is used within [DeploymentContext] blocks to define the initial + * content of nodes deployed at specific positions. + * + * @param T The type of molecule concentration. + * @param P The type of position, must extend [Position]. + * + * @see [DeploymentContext] for the parent context + * @see [it.unibo.alchemist.model.Incarnation.createMolecule] + * @see [it.unibo.alchemist.model.Incarnation.createConcentration] + */ +interface ContentContext> { + /** + * The optional position filter applied to this content context. + * + * If set, content is only applied to nodes at positions matching this filter. + */ + val filter: PositionBasedFilter

? + + /** + * The molecule name to inject into nodes. + * + * The molecule is created using the incarnation's molecule factory. + * + * @see [it.unibo.alchemist.model.Incarnation.createMolecule] + */ + var molecule: String? + + /** + * The concentration value for the molecule. + * + * The concentration is created using the incarnation's concentration factory. + * + * @see [it.unibo.alchemist.model.Incarnation.createConcentration] + */ + var concentration: T? +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/DeploymentsContextImpl.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/DeploymentsContextImpl.kt new file mode 100644 index 0000000000..ec1690371d --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/DeploymentsContextImpl.kt @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.dsl.util.LoadingSystemLogger.logger +import it.unibo.alchemist.model.Actionable +import it.unibo.alchemist.model.Deployment +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.PositionBasedFilter +import it.unibo.alchemist.model.linkingrules.CombinedLinkingRule +import it.unibo.alchemist.model.linkingrules.NoLinks +import org.apache.commons.math3.random.RandomGenerator + +/** + * Context for managing deployments in a simulation. + * + * @param T The type of molecule concentration. + * @param P The type of position. + * @param ctx The simulation context. + */ +open class DeploymentsContextImpl>(override val ctx: SimulationContext) : + DeploymentsContext { + + override val generator: RandomGenerator + get() = ctx.scenarioGenerator + + private val inc = ctx.incarnation + + override fun deploy(deployment: Deployment<*>, block: context(DeploymentContext) () -> Unit) { + logger.debug("Deploying deployment: {}", deployment) + @Suppress("UNCHECKED_CAST") + val d = DeploymentContextImpl(deployment as Deployment

).apply(block) + // populate + populateDeployment(d) + } + + context(environment: Environment) + override fun deploy(deployment: Deployment<*>) { + @Suppress("UNCHECKED_CAST") + this.deploy(deployment) {} + } + private fun populateDeployment(deploymentContext: DeploymentContextImpl) { + val deployment = deploymentContext.deployment + // Additional linking rules + deployment.getAssociatedLinkingRule()?.let { newLinkingRule -> + val composedLinkingRule = + when (val linkingRule = ctx.environment.linkingRule) { + is NoLinks -> newLinkingRule + is CombinedLinkingRule -> CombinedLinkingRule(linkingRule.subRules + listOf(newLinkingRule)) + else -> CombinedLinkingRule(listOf(linkingRule, newLinkingRule)) + } + ctx.environment.linkingRule = composedLinkingRule + } + deployment.stream().forEach { position -> + logger.debug("visiting position: {} for deployment: {}", position, deployment) + logger.debug("creaing node for deployment: {}", deployment) + val node = deploymentContext.nodeFactory?.invoke(deploymentContext) + ?: inc.createNode( + ctx.simulationGenerator, // Match YAML loader: uses simulationRNG for node creation + ctx.environment, + null, + ) + // load properties + deploymentContext.propertiesContext.applyToNode(node, position) + // load contents + val contents = deploymentContext.contents + for (content in contents) { + deploymentContext.applyToNodes(node, position, content) + } + // load programs + val programs = deploymentContext.programsContext.programs + val createdPrograms = mutableListOf?, Actionable>>() + for (programEntry in programs) { + val pp = deploymentContext.programsContext.applyToNodes( + node, + position, + programEntry.program, + programEntry.filter, + ) + createdPrograms.add(pp) + } + logger.debug("programs={}", createdPrograms) + logger.debug("Adding node to environment at position: {}", position) + ctx.environment.addNode(node, position) + } + } + + /** + * Context for configuring a single deployment. + * + * @param deployment The deployment being configured. + */ + inner class DeploymentContextImpl(val deployment: Deployment

) : DeploymentContext { + override val ctx: DeploymentsContext = this@DeploymentsContextImpl + + /** + * The list of content contexts for this deployment. + */ + val contents: MutableList = mutableListOf() + + /** + * Optional factory for creating custom nodes. + */ + var nodeFactory: ( + context(DeploymentContext) + () -> Node + )? = null + + /** + * The properties context for this deployment. + */ + var propertiesContext: PropertiesContextImpl = PropertiesContextImpl(this@DeploymentContextImpl) + + /** + * The programs context for this deployment. + */ + val programsContext: ProgramsContextImpl = ProgramsContextImpl(this@DeploymentContextImpl) + init { + logger.debug("Visiting deployment: {}", deployment) + } + + override fun all(block: ContentContext.() -> Unit) { + logger.debug("Adding content for all positions") + val c = ContentContextImpl().apply(block) + contents.add(c) + } + + override fun inside(filter: PositionBasedFilter<*>, block: ContentContext.() -> Unit) { + @Suppress("UNCHECKED_CAST") + val typedFilter = filter as PositionBasedFilter

+ logger.debug("Adding content for positions inside filter: {}", typedFilter) + val c = ContentContextImpl(typedFilter).apply(block) + contents.add(c) + } + + override fun programs(block: ProgramsContext.() -> Unit) { + programsContext.apply(block) + } + + override fun nodes(factory: DeploymentContext.() -> Node) { + nodeFactory = factory + } + + override fun properties(block: PropertiesContext.() -> Unit) { + propertiesContext.apply(block) + } + + /** + * Applies content to nodes at a specific position. + * + * @param node The node to apply content to. + * @param position The position of the node. + * @param content The content context to apply. + */ + fun applyToNodes(node: Node, position: P, content: ContentContextImpl) { + logger.debug("Applying node to nodes for position: {}, deployment {}", position, deployment) + if (content.filter == null || content.filter.contains(position)) { + logger.debug("Creating molecule for node at position: {}", position) + val mol = ctx.ctx.incarnation.createMolecule( + content.molecule + ?: error("Molecule not specified"), + ) + logger.debug("Creating concentration for molecule: {}", mol) + val conc = ctx.ctx.incarnation.createConcentration(content.concentration) + logger.debug("Setting concentration for molecule: {} to node at position: {}", mol, position) + node.setConcentration(mol, conc) + } + } + + /** + * Context for configuring content (molecules and concentrations) for nodes. + * + * @param filter Optional position filter for applying content. + */ + inner class ContentContextImpl(override val filter: PositionBasedFilter

? = null) : ContentContext { + /** + * The molecule name. + */ + override var molecule: String? = null + + /** + * The concentration value. + */ + override var concentration: T? = null + } + } +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ExporterContext.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ExporterContext.kt new file mode 100644 index 0000000000..68e67625c7 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ExporterContext.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.Exporter +import it.unibo.alchemist.boundary.Extractor +import it.unibo.alchemist.boundary.dsl.AlchemistDsl +import it.unibo.alchemist.model.Position + +/** + * Context interface for configuring data exporters in a simulation. + * + * Exporters define how simulation data is extracted and exported, supporting formats + * such as CSV, MongoDB, and custom formats. + * Data can be exported per-node or aggregated + * using statistical functions. + * + * ## Usage Example + * + * ```kotlin +* exporter { +* type = CSVExporter("output", 4.0) +* data(Time(), moleculeReader("moleculeName")) +* } + * ``` + * + * @param T The type of molecule concentration. + * @param P The type of position, must extend [Position]. + * + * @see [SimulationContext.exporter] for adding exporters to a simulation + * @see [Exporter] for the exporter interface + * @see [Extractor] for data extraction + */ +@AlchemistDsl +interface ExporterContext> { + + /** The parent simulation context. */ + val ctx: SimulationContext + + /** + * The exporter instance that handles data output. + * + * @see [Exporter] + */ + var type: Exporter? + + /** + * Sets the data extractors for this exporter. + * + * Extractors define which data should be exported from the simulation. + * + * ```kotlin + * data(Time(), moleculeReader("moleculeName")) + * ``` + * + * @param extractors The extractors to use for data extraction. + * @see [Extractor] + */ + fun data(vararg extractors: Extractor<*>) +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ExporterContextImpl.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ExporterContextImpl.kt new file mode 100644 index 0000000000..45d3a2c853 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ExporterContextImpl.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.Exporter +import it.unibo.alchemist.boundary.Extractor +import it.unibo.alchemist.model.Position + +/** + * Context for configuring exporters in a simulation. + * + * @param T The type of molecule concentration. + * @param P The type of position. + */ +class ExporterContextImpl>(override val ctx: SimulationContext) : ExporterContext { + override var type: Exporter? = null + + /** + * The list of data extractors. + */ + var extractors: List> = emptyList() + + override fun data(vararg extractors: Extractor<*>) { + this.extractors = extractors.toList() + } +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/GlobalProgramsContext.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/GlobalProgramsContext.kt new file mode 100644 index 0000000000..472752a80b --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/GlobalProgramsContext.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.model.GlobalReaction +import it.unibo.alchemist.model.Position + +/** + * Context for configuring global reactions in a simulation. + * + * @param T The type of molecule concentration. + * @param P The type of position. + */ +interface GlobalProgramsContext> { + /** The parent simulation context. */ + val ctx: SimulationContext + + /** + * Adds a global reaction to the simulation. + * + * @param this The global reaction to add. + */ + operator fun GlobalReaction.unaryPlus() +} + +/** + * Implementation of [GlobalProgramsContext]. + * + * @param T The type of molecule concentration. + * @param P The type of position. + */ +class GlobalProgramsContextImpl>(override val ctx: SimulationContext) : + GlobalProgramsContext { + override fun GlobalReaction.unaryPlus() { + ctx.environment.addGlobalReaction(this) + } +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/LayerContext.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/LayerContext.kt new file mode 100644 index 0000000000..67f9f4b116 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/LayerContext.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.dsl.AlchemistDsl +import it.unibo.alchemist.model.Layer +import it.unibo.alchemist.model.Position + +/** + * Context interface for configuring spatial layers in a simulation. + * + * Layers define overlays of data that can be sensed everywhere in the environment. + * They can be used to model physical properties such as pollution, light, temperature, etc. + * + * ## Usage Example + * + * ```kotlin +* layer { +* molecule = "A" +* layer = StepLayer(2.0, 2.0, 100.0, 0.0) +* } + * ``` + * + * @param T The type of molecule concentration. + * @param P The type of position, must extend [Position]. + * + * @see [SimulationContext.layer] for adding layers to a simulation + * @see [Layer] for the layer interface + */ +@AlchemistDsl +interface LayerContext> { + /** + * The molecule name associated with this layer. + * + */ + var molecule: String? + + /** + * The layer instance that provides spatial data. + * + * @see [Layer] + */ + var layer: Layer? +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/LayerContextImpl.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/LayerContextImpl.kt new file mode 100644 index 0000000000..89d546c573 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/LayerContextImpl.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.model.Layer +import it.unibo.alchemist.model.Position + +/** + * Context for configuring layers in a simulation. + * + * @param T The type of molecule concentration. + * @param P The type of position. + */ +class LayerContextImpl> : LayerContext { + override var molecule: String? = null + + override var layer: Layer? = null +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/OutputMonitorsContext.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/OutputMonitorsContext.kt new file mode 100644 index 0000000000..4d607c4aeb --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/OutputMonitorsContext.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.OutputMonitor +import it.unibo.alchemist.model.Position + +/** + * Context for configuring output monitors in a simulation. + * + * @param T The type of molecule concentration. + * @param P The type of position. + */ +interface OutputMonitorsContext> { + /** The parent simulation context. */ + val ctx: SimulationContext + + /** + * Adds an output monitor to the simulation. + * + * @param this The output monitor to add. + */ + operator fun OutputMonitor.unaryPlus() +} + +/** + * Implementation of [OutputMonitorsContext]. + * + * @param T The type of molecule concentration. + * @param P The type of position. + */ +class OutputMonitorsContextImpl>(override val ctx: SimulationContextImpl) : + OutputMonitorsContext { + override fun OutputMonitor.unaryPlus() { + ctx.monitors += this + } +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ProgramsContext.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ProgramsContext.kt new file mode 100644 index 0000000000..ebd4dc072c --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ProgramsContext.kt @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.dsl.AlchemistDsl +import it.unibo.alchemist.model.Action +import it.unibo.alchemist.model.Condition +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.PositionBasedFilter +import it.unibo.alchemist.model.Reaction +import it.unibo.alchemist.model.TimeDistribution + +/** + * Context interface for configuring programs (reactions) in a deployment. + * + * Programs define the behavior of nodes through reactions that execute actions + * when conditions are met. Programs can be applied to all nodes or filtered by position. + * + * ## Usage Example + * + * ```kotlin + * deployments { + * deploy(deployment) { + * programs { + * all { + * timeDistribution("1") + * program = "{token} --> {firing}" + * } + * inside(RectangleFilter(-1.0, -1.0, 2.0, 2.0)) { + * program = "{firing} --> +{token}" + * } + * } + * } + * } + * ``` + * + * @param T The type of molecule concentration. + * @param P The type of position, must extend [Position]. + * + * @see [DeploymentContext.programs] for configuring programs in a deployment + * @see [Reaction] for the reaction interface + * @see [TimeDistribution] for time distribution configuration + */ +@AlchemistDsl +interface ProgramsContext> { + /** + * The deployment context this programs context belongs to. + */ + val ctx: DeploymentContext + + /** + * Configures a program for all nodes in the deployment. + * + * @param block The program configuration block. + */ + fun all(block: ProgramContext.() -> Unit) + + /** + * Configures a program for nodes inside a position filter. + * + * Only nodes whose positions match the filter will receive the configured program. + * + * @param filter The position filter to apply. + * @param block The program configuration block. + * @see [PositionBasedFilter] + */ + fun inside(filter: PositionBasedFilter

, block: ProgramContext.() -> Unit) +} + +/** + * Context interface for configuring a single program (reaction) for a node. + * + * This context is used within [ProgramsContext] blocks to define reactions with + * their time distributions, conditions, and actions. + * + * @param T The type of molecule concentration. + * @param P The type of position, must extend [Position]. + * + * @see [ProgramsContext] for the parent context + * @see [Reaction] for the reaction interface + * @see [TimeDistribution] for time distribution + * @see [Action] for reaction actions + * @see [Condition] for reaction conditions + */ +@AlchemistDsl +interface ProgramContext> { + /** + * The programs context this program context belongs to. + */ + val ctx: ProgramsContext + + /** + * The node this program context is configuring. + */ + val node: Node + + /** + * The program specification as a string. + * + * The format depends on the incarnation being used. + */ + var program: String? + + /** + * The time distribution for the reaction. + * + * @see [TimeDistribution] + */ + var timeDistribution: TimeDistribution? + + /** + * An optional custom reaction instance. + * + * If provided, this reaction will be used instead of creating one from [program]. + * + * @see [Reaction] + */ + var reaction: Reaction? + + /** + * Sets the time distribution using a string specification. + * + * The string is processed by the incarnation to create a [TimeDistribution]. + * + * ```kotlin + * timeDistribution("1") + * ``` + * + * @param td The time distribution specification string. + * @see [TimeDistribution] + * @see [it.unibo.alchemist.model.Incarnation.createTimeDistribution] + */ + fun timeDistribution(td: String) + + /** + * Adds an action to the program. + * + * Actions are executed when the reaction fires and all conditions are met. + * + * @param block A factory function that creates the action. + * @see [Action] + */ + fun addAction(block: () -> Action) + + /** + * Adds an action to the program using the incarnation createAction function. + * + * @param action the action + * @see [it.unibo.alchemist.model.Incarnation.createAction] + */ + fun addAction(action: String) + + /** + * Adds a condition to the program. + * + * Conditions must all be satisfied for the reaction to fire. + * + * @param block A factory function that creates the condition. + * @see [Condition] + */ + fun addCondition(block: () -> Condition) + + /** + * Adds a condition to the program, using the incarnation createCondition function. + * + * @param condition the condition + * @see [it.unibo.alchemist.model.Incarnation.createCondition] + */ + fun addCondition(condition: String) + + /** + * Unary plus operator for type-safe casting of [TimeDistribution]. + * + * This operator allows casting a [TimeDistribution] with wildcard type parameter + * to a specific type parameter, enabling type-safe usage in generic contexts. + * + * @return The same [TimeDistribution] instance cast to the specified type parameter. + */ + @Suppress("UNCHECKED_CAST") + operator fun TimeDistribution<*>.unaryPlus(): TimeDistribution = this as TimeDistribution +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ProgramsContextImpl.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ProgramsContextImpl.kt new file mode 100644 index 0000000000..917467ddd4 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/ProgramsContextImpl.kt @@ -0,0 +1,173 @@ +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.dsl.util.LoadingSystemLogger.logger +import it.unibo.alchemist.model.Action +import it.unibo.alchemist.model.Actionable +import it.unibo.alchemist.model.Condition +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.PositionBasedFilter +import it.unibo.alchemist.model.Reaction +import it.unibo.alchemist.model.TimeDistribution + +/** + * Context for managing programs (reactions) in a simulation. + * + * @param T The type of molecule concentration. + * @param P The type of position. + * @param ctx The deployments context. + */ +class ProgramsContextImpl>(override val ctx: DeploymentContext) : ProgramsContext { + /** + * Entry representing a program with its filter. + * + * @param filter Optional position filter. + * @param program The program configuration block. + */ + inner class ProgramEntry( + val filter: PositionBasedFilter

?, + val program: ProgramsContextImpl.ProgramContextImpl.() -> Unit, + ) + + /** + * List of program entries. + */ + val programs: MutableList = mutableListOf() + + override fun all(block: ProgramContext.() -> Unit) { + logger.debug("Adding program for all nodes") + programs.add(ProgramEntry(null, block)) + } + + override fun inside(filter: PositionBasedFilter

, block: ProgramContext.() -> Unit) { + logger.debug("Adding program for nodes inside filter: {}", filter) + programs.add(ProgramEntry(filter, block)) + } + + /** + * Applies a program to nodes at a specific position. + * + * @param node The node to apply the program to. + * @param position The position of the node. + * @param program The program configuration block. + * @param filter Optional position filter. + */ + fun applyToNodes( + node: Node, + position: P, + program: ProgramContextImpl.() -> Unit, + filter: PositionBasedFilter

?, + ): Pair?, Actionable> { + logger.debug("Applying program to node at position: {}", position) + val c = ProgramContextImpl(node).apply(program) + val context = ctx.ctx.ctx + logger.debug("Creating time distribution for program") + val timeDistribution = c.timeDistribution + ?: context.incarnation.createTimeDistribution( + context.simulationGenerator, + context.environment, + node, + null, + ) + logger.debug("Creating reaction for program") + val r = c.reaction + ?: // Create a basic reaction with custom actions/conditions + context.incarnation.createReaction( + context.simulationGenerator, + context.environment, + node, + timeDistribution, + c.program, + ) + logger.debug("Adding actions to reaction") + r.actions += c.actions.map { it() } + logger.debug("Adding conditions to reaction") + r.conditions += c.conditions.map { it() } + logger.debug("Adding reaction to node") + if (filter == null || filter.contains(position)) { + node.addReaction(r) + } + return filter to r + } + + /** + * Context for configuring a single program (reaction). + * + * @param node The node this program is associated with. + * @param ctx The programs' context. + */ + open inner class ProgramContextImpl(override val node: Node) : ProgramContext { + + override val ctx: ProgramsContext = this@ProgramsContextImpl + private val context = ctx.ctx.ctx.ctx + + /** + * The program name. + */ + override var program: String? = null + + /** + * Collection of action factories. + */ + var actions: Collection<() -> Action> = emptyList() + + /** + * Collection of condition factories. + */ + var conditions: Collection<() -> Condition> = emptyList() + + /** + * The time distribution for the reaction. + */ + override var timeDistribution: TimeDistribution? = null + + /** + * Optional custom reaction instance. + */ + override var reaction: Reaction? = null + + override fun timeDistribution(td: String) { + timeDistribution = context.incarnation.createTimeDistribution( + context.simulationGenerator, + context.environment, + node, + td, + ) + } + + override fun addAction(block: () -> Action) { + actions += block + } + + override fun addAction(action: String) { + actions += { + context.incarnation + .createAction( + context.simulationGenerator, + context.environment, + node, + timeDistribution, + reaction, + action, + ) + } + } + + override fun addCondition(block: () -> Condition) { + conditions += block + } + + override fun addCondition(condition: String) { + conditions += { + context.incarnation.createCondition( + context.simulationGenerator, + context.environment, + node, + timeDistribution, + reaction, + condition, + ) + } + } + } +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/PropertiesContext.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/PropertiesContext.kt new file mode 100644 index 0000000000..fd237670d6 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/PropertiesContext.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.dsl.AlchemistDsl +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.NodeProperty +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.PositionBasedFilter + +/** + * Context interface for configuring node properties in a deployment. + * + * Properties can be assigned to nodes based on their position using filters, + * or applied to all nodes in the deployment. + * + * ## Usage Example + * + * ```kotlin + * deployments { + * deploy(deployment) { + * properties { + * inside(RectangleFilter(-3.0, -3.0, 2.0, 2.0)) { + * add(MyNodeProperty()) + * } + * all { + * add(CommonProperty()) + * } + * } + * } + * } + * ``` + * + * @param T The type of molecule concentration. + * @param P The type of position, must extend [Position]. + * + * @see [DeploymentContext.properties] for configuring properties in a deployment + * @see [NodeProperty] for the property interface + * @see [PositionBasedFilter] for position filtering + */ + +@AlchemistDsl +interface PropertiesContext> { + /** + * The deployment context this properties context belongs to. + */ + val ctx: DeploymentContext + + /** + * Configures properties for nodes inside a position filter. + * + * Only nodes whose positions match the filter will receive the configured properties. + * + * @param filter The position filter to apply. + * @param block The property configuration block. + * @see [PositionBasedFilter] + */ + fun inside(filter: PositionBasedFilter<*>, block: PropertyContext.() -> Unit) + + /** + * Configures properties for all nodes in the deployment. + * + * @param block The property configuration block. + */ + fun all(block: PropertyContext.() -> Unit) +} + +/** + * Context interface for configuring properties for a specific node. + * + * This context is used within [PropertiesContext] blocks to add properties to nodes. + * + * @param T The type of molecule concentration. + * @param P The type of position, must extend [Position]. + * + * @see [PropertiesContext] for the parent context + * @see [NodeProperty] for the property interface + */ +@AlchemistDsl +interface PropertyContext> { + /** + * The properties context this property context belongs to. + */ + val ctx: PropertiesContext + + /** + * The optional position filter applied to this property context. + */ + val filter: PositionBasedFilter

? + + /** + * The node this property context is configuring. + */ + val node: Node + + /** + * Adds a property to the node. + * + * @param property The property to add to the node. + * @see [NodeProperty] + */ + operator fun NodeProperty.unaryPlus() +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/PropertiesContextImpl.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/PropertiesContextImpl.kt new file mode 100644 index 0000000000..cbe494b978 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/PropertiesContextImpl.kt @@ -0,0 +1,72 @@ +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.dsl.util.LoadingSystemLogger.logger +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.NodeProperty +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.PositionBasedFilter + +/** + * Context for managing node properties in a simulation. + * + * @param T The type of molecule concentration. + * @param P The type of position. + */ +class PropertiesContextImpl>(override val ctx: DeploymentContext) : PropertiesContext { + /** + * List of property contexts with their associated filters. + */ + val propertiesCtx: MutableList Unit, PositionBasedFilter

?>> = mutableListOf() + + override fun inside(filter: PositionBasedFilter<*>, block: PropertyContext.() -> Unit) { + @Suppress("UNCHECKED_CAST") + val typedFilter = filter as PositionBasedFilter

+ propertiesCtx.add(block to typedFilter) + logger.debug("Adding property for nodes inside filter: {}", typedFilter) + } + + override fun all(block: PropertyContext.() -> Unit) { + propertiesCtx.add(block to null) + logger.debug("Adding property for all nodes") + } + + /** + * Applies configured properties to a node at a specific position. + * + * @param node The node to apply properties to. + * @param position The position of the node. + */ + fun applyToNode(node: Node, position: P) { + propertiesCtx.forEach { (propertyCtx, filter) -> + if (filter == null || filter.contains(position)) { + val properties = PropertyContextImpl(filter, node) + .apply(propertyCtx) + .properties + properties.forEach { property -> + logger.debug("Applying property: {} to node: {}", property, node) + node.addProperty(property) + } + } + } + } + + /** + * Context for configuring properties for a specific node. + * + * @param filter Optional position filter. + * @param node The node to configure properties for. + */ + inner class PropertyContextImpl(override val filter: PositionBasedFilter

?, override val node: Node) : + PropertyContext { + override val ctx: PropertiesContext = this@PropertiesContextImpl + + /** + * List of properties to add to the node. + */ + val properties: MutableList> = mutableListOf() + + override operator fun NodeProperty.unaryPlus() { + properties.add(this) + } + } +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/SimulationContext.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/SimulationContext.kt new file mode 100644 index 0000000000..74c486dedf --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/SimulationContext.kt @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.Launcher +import it.unibo.alchemist.boundary.OutputMonitor +import it.unibo.alchemist.boundary.Variable +import it.unibo.alchemist.boundary.dsl.AlchemistDsl +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.GlobalReaction +import it.unibo.alchemist.model.Incarnation +import it.unibo.alchemist.model.LinkingRule +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.TerminationPredicate +import java.io.Serializable +import org.apache.commons.math3.random.RandomGenerator + +/** + * Main context interface for building and configuring Alchemist simulations using the DSL. + * + * This interface provides a type-safe way to configure simulations programmatically. + * It serves as the entry point for DSL users to define + * all aspects of a simulation including deployments, programs, monitors, exporters, and more. + * + * ## Usage Example + * + * ```kotlin + * simulation(incarnation, environment) { + * networkModel = ConnectWithinDistance(0.5) + * deployments { + * deploy(Grid(-5, -5, 5, 5, 0.25, 0.25)) { + * all { + * molecule = "moleculeName" + * concentration = 1.0 + * } + * } + * } + * } + * ``` + * + * @param T The type of molecule concentration used in the simulation + * @param P The type of position used in the environment, must extend [Position] + * + * @see [it.unibo.alchemist.boundary.dsl.Dsl] for creating simulation contexts + * @see [DeploymentsContextImpl] for deployment configuration + * @see [ProgramsContextImpl] for program configuration + * @see [ExporterContextImpl] for exporter configuration + * @see [LayerContextImpl] for layer configuration + */ +@Suppress("UndocumentedPublicFunction") // Detekt false positive with context parameters +interface SimulationContext> { + /** + * The incarnation instance that defines how molecules, nodes, and reactions are created. + * + * ## Creating an Incarnation + * + * Incarnations are created from the [AvailableIncarnations] enum using the extension function: + * ```kotlin + * + * simulation(AvailableIncarnations.SAPERE.incarnation(), environment) { + * // simulation configuration + * } + * ``` + * + * + * @see [AvailableIncarnations] for the DSL enum of available incarnations + * @see [Incarnation] for the incarnation interface + * @see [it.unibo.alchemist.boundary.dsl.Dsl.incarnation] for converting enum to instance + */ + val incarnation: Incarnation + + /** + * The environment where the simulation takes place. + * + * @see [Environment] + */ + val environment: Environment + + /** + * The launcher responsible for executing the simulation. + * + * Some implementations are available in [it.unibo.alchemist.boundary.launchers]. + * + * @see [Launcher] + */ + var launcher: Launcher + + /** + * Random number generator controlling the evolution of the events of the simulation. + * + * @see [RandomGenerator] + */ + var simulationGenerator: RandomGenerator + + /** + * Random number generator controlling the position of random deployments. + * + * @see [RandomGenerator] + */ + var scenarioGenerator: RandomGenerator + + /** + * The network model (linking rule) that defines how nodes connect in the environment. + * + * @see [LinkingRule] + */ + var networkModel: LinkingRule + + /** + * Configures node deployments for the simulation. + * + * ## Usage Example + * ```kotlin + * deployments { + * deploy(point(0,0)) + * ... + * } + * ``` + * + * @see [DeploymentsContextImpl] to configure deployments + */ + context(randomGenerator: RandomGenerator, environment: Environment) + fun deployments(block: DeploymentsContext.() -> Unit) + + /** + * Adds a termination predicate to the simulation. + * + * @param terminator The termination predicate to add + * @see [TerminationPredicate] + */ + fun terminators(block: TerminatorsContext.() -> Unit) + + /** + * Adds an output monitor to the simulation. + * + * @param monitor The output monitor to add + * @see [OutputMonitor] + */ + fun monitors(block: OutputMonitorsContext.() -> Unit) + + /** + * Add an exporter to the simulation for data output. + * + * @param block The configuration block + * @see [ExporterContextImpl] + */ + fun exporter(block: ExporterContext.() -> Unit) + + /** + * Configures a global program. + * + * @param program the global reaction to add + * @see [GlobalReaction] + */ + fun programs(block: GlobalProgramsContext.() -> Unit) + + /** + * Schedules a block of code to execute later during the loading process. + * + * This is useful for debug purposes or for operations that need to be deferred + * + * Example: + * ```kotlin + * runLater { + * environment.nodes.forEach { node -> + * println("Node: ${node}") + * } + * } + * ``` + * + * @param block The block of code to execute later + */ + fun runLater(block: context(SimulationContext) () -> Unit) + + /** + * Add a spatial layer for a molecule. + * + * It is possible to define overlays (layers) of data that can be sensed + * everywhere in the environment + * + * @param block The configuration block + * @see [LayerContextImpl] + */ + fun layer(block: LayerContext.() -> Unit) + + /** + * Registers a Linear Variable for batch simulations. + * + * Example usage with a range variable: + * ```kotlin + * var myParam by variable(RangeVariable(0.0, 10.0, 0.5)) + * ``` + * + * @param source The variable source that provides the range of values + * @see [Variable] + */ + fun variable(source: Variable): VariablesContext.VariableProvider + + /** + * Registers a dependent variable that is computed from other variables. + * + * Example usage:: + * ```kotlin + * var param by variable(RangeVariable(0.0, 10.0, 0.5)) + * var computedParam by variable { param * 2.0 } + * ``` + * + * @param source A function that computes the variable value + */ + fun variable(source: () -> A): VariablesContext.DependentVariableProvider +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/SimulationContextImpl.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/SimulationContextImpl.kt new file mode 100644 index 0000000000..82b91a5823 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/SimulationContextImpl.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +@file:Suppress("UNCHECKED_CAST") + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.Launcher +import it.unibo.alchemist.boundary.OutputMonitor +import it.unibo.alchemist.boundary.Variable +import it.unibo.alchemist.boundary.dsl.util.LoadingSystemLogger.logger +import it.unibo.alchemist.boundary.launchers.DefaultLauncher +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Incarnation +import it.unibo.alchemist.model.Layer +import it.unibo.alchemist.model.LinkingRule +import it.unibo.alchemist.model.Position +import java.io.Serializable +import org.apache.commons.math3.random.MersenneTwister +import org.apache.commons.math3.random.RandomGenerator + +/** + * Main context for building and configuring a simulation. + * + * @param T The type of molecule concentration. + * @param P The type of position. + */ + +class SimulationContextImpl>( + override val incarnation: Incarnation, + private var envIstance: Environment? = null, +) : SimulationContext { + /** The environment instance (internal use). */ + override val environment: Environment + get() = requireNotNull(envIstance) { "Environment has not been initialized yet" } + + /** + * List of build steps to execute. + */ + val buildSteps: MutableList.() -> Unit> = mutableListOf() + + /** + * List of output . + */ + val monitors: MutableList> = mutableListOf() + + /** + * List of exporters. + */ + val exporters: MutableList> = mutableListOf() + + override var launcher: Launcher = DefaultLauncher() + + /** + * Map of variable references. + */ + val references: MutableMap = mutableMapOf() + + private var _scenarioGenerator: RandomGenerator? = null + private var _simulationGenerator: RandomGenerator? = null + + override var scenarioGenerator: RandomGenerator + get() { + return _scenarioGenerator ?: MersenneTwister(0L).also { _scenarioGenerator = it } + } + set(value) { + buildSteps.add { this._scenarioGenerator = value } + } + + override var simulationGenerator: RandomGenerator + get() { + return _simulationGenerator ?: MersenneTwister(0L).also { _simulationGenerator = it } + } + set(value) { + buildSteps.add { this._simulationGenerator = value } + } + + private val layers: MutableMap> = HashMap() + + override var networkModel: LinkingRule + get() = environment.linkingRule + set(value) { + buildSteps.add { this.environment.linkingRule = value } + } + + /** + * The variables context for managing simulation variables. + */ + val variablesContext = VariablesContext() + + /** + * Build a fresh new simulation context instance, and applies + * all the build steps to it. + * To ensure that each instance has + * its own variables spaces: check the [VariablesContext] documentation for more details. + * @see [VariablesContext] + */ + fun build(envInstance: Environment, values: Map): SimulationContextImpl { + val batchContext = SimulationContextImpl(incarnation) + batchContext.envIstance = envInstance + batchContext.variablesContext.variables += this.variablesContext.variables + batchContext.variablesContext.dependentVariables += this.variablesContext.dependentVariables + logger.debug("Binding variables to batchInstance: {}", values) + this.variablesContext.addReferences(values) + buildSteps.forEach { batchContext.apply(it) } + return batchContext + } + + context(randomGenerator: RandomGenerator, environment: Environment) + override fun deployments(block: DeploymentsContext.() -> Unit) { + logger.debug("adding deployments block inside {}", this) + buildSteps.add { + logger.debug("Configuring deployments inside {}", this) + DeploymentsContextImpl(this).apply(block) + } + } + + override fun terminators(block: TerminatorsContext.() -> Unit) { + @Suppress("UNCHECKED_CAST") + buildSteps.add { TerminatorsContextImpl(this).block() } + } + + override fun monitors(block: OutputMonitorsContext.() -> Unit) { + buildSteps.add { OutputMonitorsContextImpl(this).block() } + } + + override fun exporter(block: ExporterContext.() -> Unit) { + buildSteps.add { this.exporters.add(ExporterContextImpl(this).apply(block)) } + } + + override fun programs(block: GlobalProgramsContext.() -> Unit) { + buildSteps.add { GlobalProgramsContextImpl(this).block() } + } + + override fun runLater(block: context(SimulationContext)() -> Unit) { + buildSteps.add { block() } + } + + override fun layer(block: LayerContext.() -> Unit) { + buildSteps.add { + val l = LayerContextImpl().apply(block) + val layer = requireNotNull(l.layer) { "Layer must be specified" } + val moleculeName = requireNotNull(l.molecule) { "Molecule must be specified" } + require(!this.layers.containsKey(moleculeName)) { + "Inconsistent layer definition for molecule $moleculeName. " + + "There must be a single layer per molecule" + } + val molecule = incarnation.createMolecule(moleculeName) + logger.debug("Adding layer for molecule {}: {}", moleculeName, layer) + this.layers[moleculeName] = layer + this.environment.addLayer(molecule, layer) + } + } + + override fun variable(source: Variable): VariablesContext.VariableProvider = + variablesContext.register(source) + + override fun variable(source: () -> A): VariablesContext.DependentVariableProvider = + variablesContext.dependent(source) +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/TerminatorsContext.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/TerminatorsContext.kt new file mode 100644 index 0000000000..2a0bc66008 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/TerminatorsContext.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.dsl.AlchemistDsl +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.TerminationPredicate + +/** + * Context for configuring termination predicates in a simulation. + * + * @param T The type of molecule concentration. + * @param P The type of position. + */ +@AlchemistDsl +interface TerminatorsContext> { + /** The parent simulation context. */ + val ctx: SimulationContext + + /** + * Adds a termination predicate to the simulation. + * + * @param this The termination predicate to add. + */ + operator fun TerminationPredicate<*, *>.unaryPlus() +} + +/** + * Implementation of [TerminatorsContext]. + * + * @param T The type of molecule concentration. + * @param P The type of position. + */ +class TerminatorsContextImpl>(override val ctx: SimulationContext) : + TerminatorsContext { + @Suppress("UNCHECKED_CAST") + override fun TerminationPredicate<*, *>.unaryPlus() { + ctx.environment.addTerminator(this as TerminationPredicate) + } +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/VariablesContext.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/VariablesContext.kt new file mode 100644 index 0000000000..f60078b0cc --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/model/VariablesContext.kt @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.model + +import it.unibo.alchemist.boundary.Variable +import it.unibo.alchemist.boundary.dsl.util.LoadingSystemLogger.logger +import java.io.Serializable +import kotlin.properties.ReadOnlyProperty +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +/** + * Context for managing variables in a simulation. + */ +class VariablesContext { + + /** Map of default variable values. */ + val defaults: MutableMap = mutableMapOf() + + /** Thread-local map of variable references. */ + val references = object : ThreadLocal>() { + override fun initialValue(): Map { + logger.debug("Initializing variable references with defaults: {}", defaults) + return defaults.toMap() + } + } + + /** + * Map of registered variables. + */ + val variables: MutableMap> = mutableMapOf() + + /** + * Map of dependent variables. + */ + val dependentVariables: MutableMap Any> = mutableMapOf() + + /** + * Adds new variable references to the current thread-local context. + * Since each simulation may run in its own thread, this ensures that each simulation + * has its own set of variable references, while still using the same variable definitions. + * + * @param newRefs Map of new variable references to add to the default/existing ones. + */ + fun addReferences(newRefs: Map) { + val currMap = references.get() + val updatedMap = currMap.toMutableMap().also { m -> + m.putAll(newRefs.mapValues { it.value as Any }) + } + references.set(updatedMap) + } + + /** + * Registers a variable provider. + * + * @param source The variable source. + * @return A variable provider. + */ + fun register(source: Variable): VariableProvider = VariableProvider(source) + + /** + * Registers a dependent variable provider. + * + * @param source The dependent variable source function. + * @return A dependent variable provider. + */ + fun dependent(source: () -> T): DependentVariableProvider = DependentVariableProvider(source) + + /** + * Provider for dependent variables that are computed from a source function. + * + * @param T The type of the variable value. + * @param source The function that provides the variable value. + */ + inner class DependentVariableProvider(private val source: () -> T) { + /** + * Provides a delegate for property delegation. + * + * @param thisRef The receiver object. + * @param prop The property metadata. + * @return A read-only property delegate. + */ + operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty { + check(!variables.containsKey(prop.name) && !dependentVariables.contains(prop.name)) { + "Variable ${prop.name} already exists" + } + dependentVariables[prop.name] = source + return DependentRef(source) + } + } + + /** + * Read-only property delegate for dependent variables. + * + * @param T The type of the variable value. + * @param source The function that provides the variable value. + */ + class DependentRef(private val source: () -> T) : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T = source() + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T): Unit = + error("Not allowed to assign a value to the variable ${property.name}") + } + + /** + * Provider for variables that are registered with a Variable source. + * + * @param T The type of the variable value. + * @param source The variable source. + */ + inner class VariableProvider(private val source: Variable<*>) { + /** + * Provides a delegate for property delegation. + * + * @param thisRef The receiver object. + * @param prop The property metadata. + * @return A read-only property delegate. + */ + operator fun provideDelegate(thisRef: Any?, prop: KProperty<*>): ReadOnlyProperty { + check(!variables.containsKey(prop.name) && !dependentVariables.contains(prop.name)) { + "Variable ${prop.name} already exists" + } + logger.debug("Registering variable: {}", prop.name) + variables[prop.name] = source + defaults[prop.name] = source.default + return Ref() + } + } + + /** + * Read-write property delegate for variables. + * + * @param T The type of the variable value. + */ + inner class Ref : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T { + check(references.get().contains(property.name)) { + "Variable ${property.name} has no defined value" + } + @Suppress("UNCHECKED_CAST") + return references.get()[property.name] as T + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T): Unit = + error("Not allowed to assign a value to the variable ${property.name}") + } +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/scripting/AlchemistScript.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/scripting/AlchemistScript.kt new file mode 100644 index 0000000000..d3bd9c4bf8 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/scripting/AlchemistScript.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.scripting + +import kotlin.script.experimental.annotations.KotlinScript +import kotlin.script.experimental.api.ScriptAcceptedLocation +import kotlin.script.experimental.api.ScriptCompilationConfiguration +import kotlin.script.experimental.api.acceptedLocations +import kotlin.script.experimental.api.compilerOptions +import kotlin.script.experimental.api.defaultImports +import kotlin.script.experimental.api.ide +import kotlin.script.experimental.jvm.dependenciesFromClassContext +import kotlin.script.experimental.jvm.jvm + +/** + * Base interface for Alchemist Kotlin DSL scripts. + */ +@KotlinScript( + displayName = "Alchemist Kotlin DSL", + fileExtension = "alchemist.kts", + compilationConfiguration = AlchemistCompilationConfiguration::class, +) +interface AlchemistScript + +/** + * Compilation configuration for Alchemist scripts. + */ +object AlchemistCompilationConfiguration : ScriptCompilationConfiguration({ + defaultImports( + "it.unibo.alchemist.boundary.dsl.Dsl.simulation", + "it.unibo.alchemist.boundary.dsl.Dsl.incarnation", + "it.unibo.alchemist.boundary.dsl.model.AvailableIncarnations.*", + "it.unibo.alchemist.boundary.dsl.model.Incarnation.*", + "it.unibo.alchemist.boundary.dsl.generated.*", + "it.unibo.alchemist.boundary.dsl.*", + "it.unibo.alchemist.boundary.dsl.Dsl.*", + "it.unibo.alchemist.model.maps.actions.*", + "it.unibo.alchemist.model.maps.deployments.*", + "it.unibo.alchemist.model.maps.environments.*", + "it.unibo.alchemist.model.*", + "it.unibo.alchemist.model.positions.*", + "it.unibo.alchemist.model.deployments.*", + "it.unibo.alchemist.model.positionfilters.And", + "it.unibo.alchemist.model.positionfilters.Or", + "it.unibo.alchemist.model.positionfilters.Not", + "it.unibo.alchemist.model.positionfilters.Xor", + "it.unibo.alchemist.model.actions.*", + "it.unibo.alchemist.model.conditions.*", + "it.unibo.alchemist.model.environments.*", + "it.unibo.alchemist.model.geometry.*", + "it.unibo.alchemist.model.layers.*", + "it.unibo.alchemist.model.linkingrules.*", + "it.unibo.alchemist.model.movestrategies.*", + "it.unibo.alchemist.model.neighborhoods.*", + "it.unibo.alchemist.model.nodes.*", + "it.unibo.alchemist.model.properties.*", + "it.unibo.alchemist.model.routes.*", + "it.unibo.alchemist.model.reactions.*", + "it.unibo.alchemist.model.terminators.*", + "it.unibo.alchemist.model.timedistributions.*", + "it.unibo.alchemist.boundary.properties.*", + "it.unibo.alchemist.boundary.dsl.aliases.*", + "it.unibo.alchemist.boundary.exporters.*", + "it.unibo.alchemist.boundary.extractors.*", + "it.unibo.alchemist.boundary.launchers.*", + "it.unibo.alchemist.boundary.statistic.*", + "it.unibo.alchemist.boundary.exportfilters.*", + "it.unibo.alchemist.boundary.variables.*", + "it.unibo.alchemist.boundary.dsl.util.LoadingSystemLogger.logger", + ) + ide { + acceptedLocations(ScriptAcceptedLocation.Everywhere) + } + jvm { + dependenciesFromClassContext(AlchemistScript::class, wholeClasspath = true) + compilerOptions.append("-Xcontext-parameters") + } +}) { + @Suppress("UnusedPrivateMember") + private fun readResolve(): Any = AlchemistCompilationConfiguration +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/util/LoadingSystemLogger.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/util/LoadingSystemLogger.kt new file mode 100644 index 0000000000..69caebe692 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/dsl/util/LoadingSystemLogger.kt @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.dsl.util + +import it.unibo.alchemist.boundary.dsl.DSLLoader +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +internal object LoadingSystemLogger { + val logger: Logger = LoggerFactory.getLogger(DSLLoader::class.java) +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/extractors/MoleculeReader.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/extractors/MoleculeReader.kt index 5325f61a0e..a9a5c757aa 100644 --- a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/extractors/MoleculeReader.kt +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/extractors/MoleculeReader.kt @@ -10,6 +10,7 @@ package it.unibo.alchemist.boundary.extractors import it.unibo.alchemist.boundary.ExportFilter +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL import it.unibo.alchemist.model.Actionable import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Incarnation @@ -34,6 +35,7 @@ import kotlin.math.min * aggregating data. If an empty list is passed, then the values * will be logged indipendently for each node. */ +@AlchemistKotlinDSL class MoleculeReader @JvmOverloads constructor( diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/modelproviders/KotlinDslProvider.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/modelproviders/KotlinDslProvider.kt new file mode 100644 index 0000000000..6260052b43 --- /dev/null +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/boundary/modelproviders/KotlinDslProvider.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.modelproviders + +import it.unibo.alchemist.boundary.AlchemistLoaderProvider +import it.unibo.alchemist.boundary.Loader +import it.unibo.alchemist.boundary.dsl.scripting.AlchemistScript +import java.io.InputStream +import java.io.Reader +import java.net.URL +import kotlin.script.experimental.api.ResultValue +import kotlin.script.experimental.api.ScriptDiagnostic +import kotlin.script.experimental.api.ScriptEvaluationConfiguration +import kotlin.script.experimental.api.valueOrNull +import kotlin.script.experimental.host.toScriptSource +import kotlin.script.experimental.jvm.baseClassLoader +import kotlin.script.experimental.jvm.jvm +import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost +import kotlin.script.experimental.jvmhost.createJvmCompilationConfigurationFromTemplate + +/** + * Provider for loading Alchemist simulations from Kotlin DSL scripts. + */ +object KotlinDslProvider : AlchemistLoaderProvider { + override val fileExtensions: Regex = "(?i)kts".toRegex() + + private val host = BasicJvmScriptingHost() + private val compilationConfiguration = createJvmCompilationConfigurationFromTemplate() + private val baseClassLoader = this::class.java.classLoader + + /** + * Evaluation configuration for script execution. + */ + private val evaluationConfiguration = ScriptEvaluationConfiguration { + jvm { + baseClassLoader(this@KotlinDslProvider.baseClassLoader) + } + } + override fun from(input: String): Loader { + val result = host.eval(input.toScriptSource(), compilationConfiguration, evaluationConfiguration) + val errors = result.reports.filter { it.severity == ScriptDiagnostic.Severity.ERROR } + require(errors.isEmpty()) { errors.joinToString("\n") { it.message } } + val value = (result.valueOrNull()?.returnValue as? ResultValue.Value)?.value + return value as? Loader + ?: error("Script must return a Loader; got ${value?.let { it::class.qualifiedName } ?: "null"}") + } + + override fun from(input: Reader): Loader = from(input.readText()) + + override fun from(input: InputStream): Loader = from(input.reader()) + + override fun from(input: URL): Loader = from(input.openStream()) +} diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/CircularArc.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/CircularArc.kt index 988d74fb83..77a7fcb998 100644 --- a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/CircularArc.kt +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/CircularArc.kt @@ -1,5 +1,6 @@ package it.unibo.alchemist.model.deployments +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL import it.unibo.alchemist.model.Deployment import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Position2D @@ -20,6 +21,7 @@ import org.apache.commons.math3.random.RandomGenerator * * Default values generate a uniform deployment on a circumference. */ +@AlchemistKotlinDSL data class CircularArc

> @JvmOverloads constructor( diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/CloseToAlreadyDeployed.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/CloseToAlreadyDeployed.kt index 3c7de791cc..8cd745e3f5 100644 --- a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/CloseToAlreadyDeployed.kt +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/CloseToAlreadyDeployed.kt @@ -9,6 +9,7 @@ package it.unibo.alchemist.model.deployments +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.GeoPosition import it.unibo.alchemist.model.Position @@ -19,6 +20,7 @@ import org.apache.commons.math3.random.RandomGenerator * in the proximity of those already included in the environment. * Behaviour if there are no nodes already inserted is undefined. */ +@AlchemistKotlinDSL class CloseToAlreadyDeployed>( randomGenerator: RandomGenerator, environment: Environment, diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/GraphStreamDeployment.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/GraphStreamDeployment.kt index 85f85a0afd..d8fa638b96 100644 --- a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/GraphStreamDeployment.kt +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/GraphStreamDeployment.kt @@ -9,6 +9,7 @@ package it.unibo.alchemist.model.deployments +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL import it.unibo.alchemist.model.Deployment import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.LinkingRule @@ -19,6 +20,7 @@ import org.apache.commons.math3.random.RandomGenerator /** * A deployment based on a [GraphStream](https://github.com/graphstream) graph. */ +@AlchemistKotlinDSL class GraphStreamDeployment

( private val createLinks: Boolean, private val graphStreamSupport: GraphStreamSupport<*, P>, diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/Grid.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/Grid.kt index 7808f7530f..11b5cb926a 100644 --- a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/Grid.kt +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/Grid.kt @@ -8,6 +8,7 @@ */ package it.unibo.alchemist.model.deployments +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL import it.unibo.alchemist.model.Deployment import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Position @@ -45,6 +46,7 @@ import org.apache.commons.math3.random.RandomGenerator * @param yShift * how shifted should be positions along columns */ +@AlchemistKotlinDSL open class Grid @JvmOverloads constructor( diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/Polygon.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/Polygon.kt index 1016f7c7a2..58e59c8435 100644 --- a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/Polygon.kt +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/Polygon.kt @@ -9,6 +9,7 @@ package it.unibo.alchemist.model.deployments +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.GeoPosition import it.unibo.alchemist.model.Position2D @@ -33,6 +34,7 @@ private typealias Point2D = Pair * undefined. There polygon is closed automatically (there is no need to pass the first point also as last element). * */ +@AlchemistKotlinDSL open class Polygon

>( environment: Environment<*, P>, randomGenerator: RandomGenerator, diff --git a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/SpecificPositions.kt b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/SpecificPositions.kt index 857c24a4be..c87a17f6ca 100644 --- a/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/SpecificPositions.kt +++ b/alchemist-loading/src/main/kotlin/it/unibo/alchemist/model/deployments/SpecificPositions.kt @@ -7,6 +7,7 @@ */ package it.unibo.alchemist.model.deployments +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL import it.unibo.alchemist.model.Deployment import it.unibo.alchemist.model.Environment import it.unibo.alchemist.model.Position @@ -14,6 +15,7 @@ import it.unibo.alchemist.model.Position /** * Given an environment and a list of list of numbers, it creates a list of the right position type for the environment. */ +@AlchemistKotlinDSL class SpecificPositions(environment: Environment<*, *>, vararg positions: Iterable) : Deployment> { private val positions: List> = positions.map { environment.makePosition(*it.toList().toTypedArray()) } diff --git a/alchemist-loading/src/test/java/it/unibo/alchemist/model/nodes/TestNode.java b/alchemist-loading/src/test/java/it/unibo/alchemist/model/nodes/TestNode.java index fa3520f9e0..8c3d09989e 100644 --- a/alchemist-loading/src/test/java/it/unibo/alchemist/model/nodes/TestNode.java +++ b/alchemist-loading/src/test/java/it/unibo/alchemist/model/nodes/TestNode.java @@ -9,6 +9,7 @@ package it.unibo.alchemist.model.nodes; +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL; import it.unibo.alchemist.model.Environment; import java.io.Serial; @@ -16,6 +17,7 @@ /** * Generic node for testing purposes. */ +@AlchemistKotlinDSL(scope = "DEPLOYMENT_CONTEXT") public final class TestNode extends GenericNode { @Serial diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/boundary/properties/TestNodeProperty.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/boundary/properties/TestNodeProperty.kt new file mode 100644 index 0000000000..1dd70d58c4 --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/boundary/properties/TestNodeProperty.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.boundary.properties + +import it.unibo.alchemist.boundary.dsl.AlchemistKotlinDSL +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Incarnation +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.NodeProperty +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.properties.AbstractNodeProperty +import org.apache.commons.math3.random.RandomGenerator + +@AlchemistKotlinDSL +class TestNodeProperty>( + node: Node, + val environment: Environment, + val incarnation: Incarnation, + val rng: RandomGenerator, + val s: String, +) : AbstractNodeProperty(node) { + override fun cloneOnNewNode(node: Node): NodeProperty = TestNodeProperty( + node, + environment, + incarnation, + rng, + s, + ) + + override fun toString(): String = super.toString() + "($s)" +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/DslLoaderFunctions.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/DslLoaderFunctions.kt new file mode 100644 index 0000000000..cdb7eea5c9 --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/DslLoaderFunctions.kt @@ -0,0 +1,519 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.dsl + +import another.location.SimpleMonitor +import it.unibo.alchemist.boundary.Loader +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.boundary.dsl.Dsl.simulation +import it.unibo.alchemist.boundary.dsl.model.AvailableIncarnations.PROTELIS +import it.unibo.alchemist.boundary.dsl.model.AvailableIncarnations.SAPERE +import it.unibo.alchemist.boundary.exporters.CSVExporter +import it.unibo.alchemist.boundary.exportfilters.CommonFilters +import it.unibo.alchemist.boundary.extractors.Time +import it.unibo.alchemist.boundary.variables.GeometricVariable +import it.unibo.alchemist.boundary.variables.LinearVariable +import it.unibo.alchemist.jakta.timedistributions.JaktaTimeDistribution +import it.unibo.alchemist.model.GeoPosition +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.actions.BrownianMove +import it.unibo.alchemist.model.deployments.Circle +import it.unibo.alchemist.model.deployments.Grid +import it.unibo.alchemist.model.deployments.Point +import it.unibo.alchemist.model.deployments.grid +import it.unibo.alchemist.model.deployments.point +import it.unibo.alchemist.model.environments.Continuous2DEnvironment +import it.unibo.alchemist.model.layers.StepLayer +import it.unibo.alchemist.model.linkingrules.ConnectWithinDistance +import it.unibo.alchemist.model.maps.actions.ReproduceGPSTrace +import it.unibo.alchemist.model.maps.deployments.FromGPSTrace +import it.unibo.alchemist.model.maps.environments.OSMEnvironment +import it.unibo.alchemist.model.positionfilters.Rectangle +import it.unibo.alchemist.model.positions.Euclidean2DPosition +import it.unibo.alchemist.model.reactions.Event +import it.unibo.alchemist.model.terminators.AfterTime +import it.unibo.alchemist.model.terminators.StableForSteps +import it.unibo.alchemist.model.timedistributions.DiracComb +import it.unibo.alchemist.model.timedistributions.ExponentialTime +import it.unibo.alchemist.model.timedistributions.WeibullTime +import it.unibo.alchemist.model.times.DoubleTime +import org.apache.commons.math3.random.MersenneTwister + +object DslLoaderFunctions { + fun > test01Nodes(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + networkModel = ConnectWithinDistance(5.0) + deployments { + deploy(point(0.0, 0.0)) + deploy(Point(ctx.environment, 0.0, 1.0)) + } + } + } + + fun > test02ManyNodes(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + simulationGenerator = MersenneTwister(10L) + scenarioGenerator = MersenneTwister(20L) + networkModel = ConnectWithinDistance(0.5) + deployments { + deploy( + Circle( + ctx.environment, + generator, + 10, + 0.0, + 0.0, + 10.0, + ), + ) + } + } + } + fun > test03Grid(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + networkModel = ConnectWithinDistance(0.5) + deployments { + val grid = grid( + -5.0, + -5.0, + 5.0, + 5.0, + 0.25, + 0.25, + 0.0, + 0.0, + ) + deploy(grid) + } + } + } + fun > test05Content(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + networkModel = ConnectWithinDistance(0.5) + deployments { + val hello = "hello" + deploy( + grid( + -5.0, + -5.0, + 5.0, + 5.0, + 0.25, + 0.25, + 0.1, + 0.1, + ), + ) { + all { + molecule = hello + } + } + } + } + } + fun > test06ContentFiltered(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + networkModel = ConnectWithinDistance(0.5) + deployments { + val hello = "hello" + deploy( + Grid( + ctx.environment, generator, + -5.0, + -5.0, + 5.0, + 5.0, + 0.25, 0.25, 0.1, 0.1, + ), + ) { + all { + molecule = hello + } + inside(Rectangle(-1.0, -1.0, 2.0, 2.0)) { + molecule = "token" + } + } + } + } + } + + fun > test07Programs(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + networkModel = ConnectWithinDistance(0.5) + deployments { + val token = "token" + deploy( + grid( + -5.0, + -5.0, + 5.0, + 5.0, + 0.25, + 0.25, + 0.1, + 0.1, + ), + ) { + inside(Rectangle(-0.5, -0.5, 1.0, 1.0)) { + molecule = token + } + programs { + all { + timeDistribution("1") + program = "{token} --> {firing}" + } + all { + program = "{firing} --> +{token}" + } + } + } + } + } + } + fun > test08ProtelisPrograms(): Loader { + val incarnation = PROTELIS.incarnation() + return simulation(incarnation) { + deployments { + deploy(point(1.5, 0.5)) { + programs { + all { + timeDistribution = +JaktaTimeDistribution( + sense = WeibullTime( + 1.0, + 1.0, + ctx.ctx.ctx.generator, + ), + deliberate = DiracComb(0.1), + act = ExponentialTime( + 1.0, + ctx.ctx.ctx.generator, + ), + ) + program = "1 + 1" + } + } + } + } + } + } + fun > test09TimeDistribution(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + networkModel = ConnectWithinDistance(0.5) + deployments { + deploy( + Grid( + ctx.environment, generator, + -5.0, -5.0, 5.0, 5.0, 0.25, 0.25, 0.1, 0.1, + ), + ) { + inside(Rectangle(-0.5, -0.5, 1.0, 1.0)) { + molecule = "token, 0, []" + } + programs { + all { + timeDistribution = DiracComb(0.5) + program = "{token, N, L} --> {token, N, L} *{token, N+#D, L add [#NODE;]}" + } + all { + program = "{token, N, L}{token, def: N2>=N, L2} --> {token, N, L}" + } + } + } + } + } + } + fun > test10Environment(): Loader { + val incarnation = SAPERE.incarnation() + val env = OSMEnvironment(incarnation, "vcm.pbf", false) + return simulation(incarnation, { env }) { + terminators { + +StableForSteps(5, 100) + } + deployments { + val gps = FromGPSTrace( + 7, + "gpsTrace", + true, + "AlignToSimulationTime", + ) + deploy(gps) { + programs { + all { + timeDistribution("15") + reaction = Event(node, timeDistribution) + addAction { + ReproduceGPSTrace( + env, + node, + reaction, + "gpsTrace", + true, + "AlignToSimulationTime", + ) + } + } + } + } + } + } + } + fun > test11monitors(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + monitors { + +SimpleMonitor() + } + } + } + fun > test12Layers(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + layer { + molecule = "A" + layer = StepLayer(2.0, 2.0, 100.0, 0.0) + } + layer { + molecule = "B" + layer = StepLayer(-2.0, -2.0, 0.0, 100.0) + } + deployments { + deploy( + grid( + -5.0, + -5.0, + 5.0, + 5.0, + 0.25, + 0.1, + 0.1, + ), + ) { + all { + molecule = "a" + } + } + } + } + } + fun > test13GlobalReaction(): Loader { + val incarnation = PROTELIS.incarnation() + return simulation(incarnation) { + programs { + +globalTestReaction(DiracComb(1.0)) + } + } + } + fun > test14Exporters(): Loader { + val incarnation = PROTELIS.incarnation() + return simulation(incarnation) { + exporter { + type = CSVExporter( + "test_export_interval", + 4.0, + ) + data( + Time(), + moleculeReader( + "default_module:default_program", + null, + CommonFilters.NOFILTER.filteringPolicy, + emptyList(), + ), + ) + } + } + } + fun > test15Variables(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + val rate: Double by variable(GeometricVariable(2.0, 0.1, 10.0, 9)) + val size: Double by variable(LinearVariable(5.0, 1.0, 10.0, 1.0)) + + val mSize by variable { -size } + val sourceStart by variable { mSize / 10.0 } + val sourceSize by variable { size / 5.0 } + terminators { +AfterTime(DoubleTime(1.0)) } + networkModel = ConnectWithinDistance(0.5) + deployments { + deploy( + grid( + mSize, + mSize, + size, + size, + 0.25, + 0.25, + 0.1, + 0.1, + ), + ) { + inside(Rectangle(sourceStart, sourceStart, sourceSize, sourceSize)) { + molecule = "token, 0, []" + } + programs { + all { + timeDistribution(rate.toString()) + program = "{token, N, L} --> {token, N, L} *{token, N+#D, L add [#NODE;]}" + } + all { + program = "{token, N, L}{token, def: N2>=N, L2} --> {token, N, L}" + } + } + } + } + } + } + + fun > test16ProgramsFilters(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + networkModel = ConnectWithinDistance(0.5) + deployments { + val token = "token" + deploy( + Grid( + ctx.environment, generator, + -5.0, + -5.0, + 5.0, + 5.0, + 0.25, 0.25, 0.1, 0.1, + ), + ) { + inside(Rectangle(-0.5, -0.5, 1.0, 1.0)) { + molecule = token + } + programs { + inside(Rectangle(-0.5, -0.5, 1.0, 1.0)) { + timeDistribution("1") + program = "{token} --> {firing}" + } + all { + program = "{firing} --> +{token}" + } + } + } + } + } + } + fun > test17CustomNodes(): Loader { + val incarnation = SAPERE.incarnation() + return simulation(incarnation) { + deployments { + deploy( + circle( + 10, + 0.0, + 0.0, + 5.0, + ), + ) { + nodes { + testNode() as Node + } + } + } + } + } + fun > test18NodeProperties(): Loader { + val incarnation = SAPERE.incarnation() + val environment = Continuous2DEnvironment(incarnation) + return simulation(incarnation, { environment }) { + deployments { + deploy( + circle( + 1000, + 0.0, + 0.0, + 15.0, + ), + ) { + properties { + val filter = RectangleFilter(-3.0, -3.0, 2.0, 2.0) + // same + val filter2 = Rectangle(3.0, 3.0, 2.0, 2.0) + inside(filter) { + +testNodeProperty("a") + } + inside(filter2) { + +testNodeProperty("b") + } + } + } + } + } + } + fun > test20Actions(): Loader { + val incarnation = SAPERE.incarnation() + val env = OSMEnvironment(incarnation) + return simulation(incarnation, { env }) { + networkModel = ConnectWithinDistance(1000.0) + deployments { + val lagoon = listOf( + Pair(45.2038121, 12.2504425), + Pair(45.2207426, 12.2641754), + Pair(45.2381516, 12.2806549), + Pair(45.2570053, 12.2895813), + Pair(45.276336, 12.2957611), + Pair(45.3029049, 12.2991943), + Pair(45.3212544, 12.3046875), + Pair(45.331875, 12.3040009), + Pair(45.3453893, 12.3040009), + Pair(45.3502151, 12.3156738), + Pair(45.3622776, 12.3232269), + Pair(45.3719259, 12.3300934), + Pair(45.3830193, 12.3348999), + Pair(45.395557, 12.3445129), + Pair(45.3998964, 12.3300934), + Pair(45.4018249, 12.3136139), + Pair(45.4105023, 12.3122406), + Pair(45.4167685, 12.311554), + Pair(45.4278531, 12.3012543), + Pair(45.4408627, 12.2902679), + Pair(45.4355628, 12.2772217), + Pair(45.4206242, 12.2703552), + Pair(45.3994143, 12.2744751), + Pair(45.3738553, 12.2676086), + Pair(45.3579354, 12.2614288), + Pair(45.3429763, 12.2497559), + Pair(45.3198059, 12.2408295), + Pair(45.2975921, 12.2346497), + Pair(45.2802014, 12.2408295), + Pair(45.257972, 12.233963), + Pair(45.2038121, 12.2504425), + ) + deploy(polygon(500, lagoon)) { + programs { + all { + timeDistribution("10") + reaction = Event(node, timeDistribution) + addAction { + BrownianMove( + env, + node, + ctx.ctx.ctx.ctx.simulationGenerator, + 0.0005, + ) + } + } + } + } + } + } + } +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/KotlinDslProviderTest.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/KotlinDslProviderTest.kt new file mode 100644 index 0000000000..2e4dd85a15 --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/KotlinDslProviderTest.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.dsl + +import it.unibo.alchemist.boundary.LoadAlchemist +import java.io.File +import java.nio.file.Files +import kotlin.io.path.writeText +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test + +class KotlinDslProviderTest { + @Test + fun loadSimpleScript() { + val script = """ + val inc = SAPERE.incarnation() + simulation(inc) { + networkModel = ConnectWithinDistance(5.0) + deployments { + deploy(point(0.0, 0.0)) + deploy(point(0.0, 1.0)) + } + } + """.trimIndent() + val path = Files.createTempFile("dsl-test-", ".alchemist.kts") + path.writeText(script) + val loader = LoadAlchemist.from(path.toFile()) + assertNotNull(loader) + } + + @Test + fun loadFromFile() { + val dslUrl = requireNotNull(this.javaClass.getResource("/dsl/kts/15-variables.alchemist.kts")) { + "Resource /dsl/kts/15-variables.alchemist.kts not found on test classpath" + } + val dslFile = File(dslUrl.toURI()) + val dslLoader = { LoadAlchemist.from(dslFile) } + dslLoader.shouldEqual("dsl/yml/15-variables.yml") + } + + @Test + fun loadFromFile2() { + val dslUrl = requireNotNull(this.javaClass.getResource("/dsl/kts/14-exporters.alchemist.kts")) { + "Resource /dsl/kts/14-exporters.alchemist.kts not found on test classpath" + } + val dslFile = File(dslUrl.toURI()) + val dslLoader = { LoadAlchemist.from(dslFile) } + dslLoader.shouldEqual("dsl/yml/14-exporters.yml") + } + + @Test + fun loadFromFile3() { + val dslUrl = requireNotNull(this.javaClass.getResource("/dsl/kts/18-properties.alchemist.kts")) { + "Resource /dsl/kts/18-properties.alchemist.kts not found on test classpath" + } + val dslFile = File(dslUrl.toURI()) + val dslLoader = { LoadAlchemist.from(dslFile) } + dslLoader.shouldEqual("dsl/yml/18-properties.yml") + } + + @Test + fun testUrlLoader() { + val dslUrl = requireNotNull(this.javaClass.getResource("/dsl/kts/12-layers.alchemist.kts")) { + "Resource /dsl/kts/12-layers.alchemist.kts not found on test classpath" + } + val dslLoader = { LoadAlchemist.from(dslUrl) } + dslLoader.shouldEqual("dsl/yml/12-layers.yml") + } +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/LayerComparisonUtils.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/LayerComparisonUtils.kt new file mode 100644 index 0000000000..b27f46c60d --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/LayerComparisonUtils.kt @@ -0,0 +1,40 @@ +package it.unibo.alchemist.dsl + +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Position +import org.junit.jupiter.api.Assertions.assertEquals + +object LayerComparisonUtils { + fun > compareLayerValues(dslEnv: Environment, yamlEnv: Environment) { + println("Comparing layer values...") + val samplePositions = mutableListOf

() + samplePositions.addAll(dslEnv.nodes.map { dslEnv.getPosition(it) }) + samplePositions.addAll(yamlEnv.nodes.map { yamlEnv.getPosition(it) }) + val uniquePositions = samplePositions.distinct() + if (uniquePositions.isNotEmpty()) { + for (position in uniquePositions) { + val dslLayerValues = dslEnv.layers.map { it.getValue(position) } + val yamlLayerValues = yamlEnv.layers.map { it.getValue(position) } + val dslDoubleValues = dslLayerValues.map { value -> + when (value) { + is Number -> value.toDouble() + else -> value.toString().toDoubleOrNull() ?: 0.0 + } + } + val yamlDoubleValues = yamlLayerValues.map { value -> + when (value) { + is Number -> value.toDouble() + else -> value.toString().toDoubleOrNull() ?: 0.0 + } + } + assertEquals( + dslDoubleValues, + yamlDoubleValues, + "Layer values at position $position should match", + ) + } + } else { + println("Skipping layer value comparison - no valid positions found") + } + } +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/LoaderFactory.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/LoaderFactory.kt new file mode 100644 index 0000000000..385fc34806 --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/LoaderFactory.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ +package it.unibo.alchemist.dsl + +import it.unibo.alchemist.boundary.LoadAlchemist +import it.unibo.alchemist.boundary.Loader +import org.kaikikm.threadresloader.ResourceLoader + +/** + * Factory for creating and loading DSL and YAML loaders for testing. + */ +object LoaderFactory { + + /** + * Loads a DSL loader from a resource path. + */ + fun loadDsl(dslCode: String): Loader = throw NotImplementedError("Not implemented yet $dslCode") + + /** + * Loads a YAML loader from a resource path. + */ + fun loadYaml(yamlResource: String): Loader = LoadAlchemist.from(ResourceLoader.getResource(yamlResource)!!) + + /** + * Loads both DSL and YAML loaders for comparison. + */ + fun loadBoth(dslCode: String, yamlResource: String): Pair = + Pair(loadDsl(dslCode), loadYaml(yamlResource)) +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/PerformanceComparisonTest.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/PerformanceComparisonTest.kt new file mode 100644 index 0000000000..cc323081bf --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/PerformanceComparisonTest.kt @@ -0,0 +1,222 @@ +package it.unibo.alchemist.dsl + +import it.unibo.alchemist.boundary.LoadAlchemist +import it.unibo.alchemist.boundary.Loader +import it.unibo.alchemist.model.Position +import java.io.File +import java.io.PrintStream +import java.util.Locale +import kotlin.time.measureTime +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.kaikikm.threadresloader.ResourceLoader + +class PerformanceComparisonTest { + + private data class PerformanceStats( + val yamlLoadingTime: Long, + val dslLoadingTime: Long, + val avgYamlActionTime: Double, + val avgDslActionTime: Double, + val minYamlActionTime: Long, + val minDslActionTime: Long, + val maxYamlActionTime: Long, + val maxDslActionTime: Long, + ) + + private fun calculateStats( + yamlLoadingTime: Long, + dslLoadingTime: Long, + yamlActionTimes: List, + dslActionTimes: List, + ): PerformanceStats = PerformanceStats( + yamlLoadingTime = yamlLoadingTime, + dslLoadingTime = dslLoadingTime, + avgYamlActionTime = yamlActionTimes.average(), + avgDslActionTime = dslActionTimes.average(), + minYamlActionTime = yamlActionTimes.minOrNull() ?: 0L, + minDslActionTime = dslActionTimes.minOrNull() ?: 0L, + maxYamlActionTime = yamlActionTimes.maxOrNull() ?: 0L, + maxDslActionTime = dslActionTimes.maxOrNull() ?: 0L, + ) + + private fun printResults(header: String, stats: PerformanceStats) { + println("\n=== $header ===") + println("YAML Loader:") + println(" Loading Phase:") + println(" Time: ${stats.yamlLoadingTime} ms") + println(" Action Phase:") + println(" Average: ${String.format(Locale.US, "%.2f", stats.avgYamlActionTime)} ms") + println(" Min: ${stats.minYamlActionTime} ms") + println(" Max: ${stats.maxYamlActionTime} ms") + println( + " Total Average: ${String.format( + Locale.US, + "%.2f", + stats.yamlLoadingTime + stats.avgYamlActionTime, + )} ms", + ) + println("\nDSL Loader:") + println(" Loading Phase:") + println(" Time: ${stats.dslLoadingTime} ms") + println(" Action Phase:") + println(" Average: ${String.format(Locale.US, "%.2f", stats.avgDslActionTime)} ms") + println(" Min: ${stats.minDslActionTime} ms") + println(" Max: ${stats.maxDslActionTime} ms") + println( + " Total Average: ${String.format(Locale.US, "%.2f", stats.dslLoadingTime + stats.avgDslActionTime)} ms", + ) + } + + private fun printSpeedup(stats: PerformanceStats, dslFasterMsg: String, yamlFasterMsg: String) { + val totalYamlTime = stats.yamlLoadingTime + stats.avgYamlActionTime + val totalDslTime = stats.dslLoadingTime + stats.avgDslActionTime + val speedup = totalYamlTime / totalDslTime + println("\nSpeedup (Total): ${String.format(Locale.US, "%.2f", speedup)}x") + if (speedup > 1.0) { + println("$dslFasterMsg ${String.format(Locale.US, "%.2f", speedup)}x faster than YAML") + } else { + println("$yamlFasterMsg ${String.format(Locale.US, "%.2f", 1.0 / speedup)}x faster than DSL") + } + val loadingSpeedup = stats.yamlLoadingTime.toDouble() / stats.dslLoadingTime.toDouble() + println("Loading Phase Speedup: ${String.format(Locale.US, "%.2f", loadingSpeedup)}x") + val actionSpeedup = stats.avgYamlActionTime / stats.avgDslActionTime + println("Action Phase Speedup: ${String.format(Locale.US, "%.2f", actionSpeedup)}x") + } + + private fun runPerformanceTest( + testHeader: String, + yamlResource: String, + dslResource: String, + iterations: Int, + resultsHeader: String, + dslFasterMsg: String, + yamlFasterMsg: String, + yamlLoaderAction: (Loader) -> Unit, + dslLoaderAction: (Loader) -> Unit, + ): PerformanceStats { + val originalOut = System.out + val originalErr = System.err + val nullStream = PrintStream(java.io.ByteArrayOutputStream()) + println("\n=== $testHeader ===") + println("Resource: $yamlResource") + println("Iterations: $iterations\n") + val yamlActionTimes = mutableListOf() + val dslActionTimes = mutableListOf() + val dslUrl = ResourceLoader.getResource(dslResource)!! + val ymlUrl = ResourceLoader.getResource(yamlResource)!! + System.setOut(nullStream) + System.setErr(nullStream) + var yamlLoader: Loader? = null + val yamlLoadingTime = measureTime { + yamlLoader = LoadAlchemist.from(ymlUrl) + } + assertNotNull(yamlLoader) + var dslLoader: Loader? = null + val dslLoadingTime = measureTime { + dslLoader = LoadAlchemist.from(dslUrl) + } + assertNotNull(dslLoader) + System.setOut(originalOut) + System.setErr(originalErr) + repeat(iterations) { + System.setOut(nullStream) + System.setErr(nullStream) + val yamlActionTime = measureTime { + yamlLoaderAction(yamlLoader!!) + } + val dslActionTime = measureTime { + dslLoaderAction(dslLoader!!) + } + System.setOut(originalOut) + System.setErr(originalErr) + yamlActionTimes.add(yamlActionTime.inWholeMilliseconds) + dslActionTimes.add(dslActionTime.inWholeMilliseconds) + } + val stats = calculateStats( + yamlLoadingTime.inWholeMilliseconds, + dslLoadingTime.inWholeMilliseconds, + yamlActionTimes, + dslActionTimes, + ) + printResults(resultsHeader, stats) + printSpeedup(stats, dslFasterMsg, yamlFasterMsg) + println("\n=== Test completed ===\n") + return stats + } + private fun runTestWith( + name: String, + action: (Loader) -> Unit, + testName: String, + iterations: Int = 10, + ): PerformanceStats { + val dslResource = "dsl/kts/$name.alchemist.kts" + val yamlResource = "dsl/yml/$name.yml" + return runPerformanceTest( + testHeader = testName, + yamlResource = yamlResource, + dslResource = dslResource, + iterations = iterations, + resultsHeader = "Results", + dslFasterMsg = "DSL loading is", + yamlFasterMsg = "YAML loading is", + yamlLoaderAction = action, + dslLoaderAction = action, + ) + } + + @Test + fun > `performance comparison between YAML and DSL loaders`() { + runTestWith( + "19-performance", + testName = "Performance Test: YAML vs DSL Loader", + action = { it.getDefault() }, + iterations = 20, + ) + } + + @Test + fun `performance comparison - loading phase only`() { + val stats = mutableListOf() + repeat(50) { + stats += runTestWith( + "19-performance", + action = {}, + testName = "Performance Test: Loading Phase Only ", + ) + } + val avgYamlLoadingTime = stats.map { it.yamlLoadingTime }.average() + val avgDslLoadingTime = stats.map { it.dslLoadingTime }.average() + println("\n=== Loading Phase Only Average Results ===") + println( + "YAML Loader Average Loading Time: ${String.format( + Locale.US, + "%.2f", + avgYamlLoadingTime, + )} ms", + ) + println( + "DSL Loader Average Loading Time: ${String.format( + Locale.US, + "%.2f", + avgDslLoadingTime, + )} ms", + ) + } + + @Test + fun `verify both loaders produce equivalent results`() { + val yamlResource = "dsl/yml/19-performance.yml" + val dslResource = "/dsl/kts/19-performance.alchemist.kts" + val dslUrl = requireNotNull(this.javaClass.getResource(dslResource)) { + "Resource $dslResource not found on test classpath" + } + val dslFile = File(dslUrl.toURI()) + val yamlLoader = LoadAlchemist.from(ResourceLoader.getResource(yamlResource)!!) + val dslLoader = LoadAlchemist.from(dslFile) + assertNotNull(yamlLoader) + assertNotNull(dslLoader) + val dslLoaderFunction = { dslLoader } + dslLoaderFunction.shouldEqual(yamlResource, includeRuntime = false) + } +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/RuntimeComparisonHelper.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/RuntimeComparisonHelper.kt new file mode 100644 index 0000000000..f99eacdd73 --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/RuntimeComparisonHelper.kt @@ -0,0 +1,630 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ +package it.unibo.alchemist.dsl + +import it.unibo.alchemist.boundary.Loader +import it.unibo.alchemist.core.Simulation +import it.unibo.alchemist.core.Status +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.TerminationPredicate +import it.unibo.alchemist.model.Time +import it.unibo.alchemist.model.terminators.AfterTime +import it.unibo.alchemist.model.terminators.StableForSteps +import it.unibo.alchemist.model.terminators.StepCount +import it.unibo.alchemist.model.times.DoubleTime +import kotlin.math.abs +import kotlin.math.max +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.fail + +/** + * Helper for comparing DSL and YAML loaders by running simulations and comparing final states + * + * This class focuses on runtime behavior comparison by executing both simulations + * for a specified duration and comparing their final states. + * + */ +object RuntimeComparisonHelper { + + /** + * Compares loaders by running both simulations and comparing their final states. + * + * @param dslLoader The DSL loader to compare + * @param yamlLoader The YAML loader to compare + * @param steps The number of steps to run before comparing + * (if null, uses targetTime or stableForSteps instead) + * @param targetTime Target time to run until (if null, uses steps or stableForSteps instead). + * Only one termination method should be provided. + * @param stableForSteps If provided, terminates when environment is stable (checkInterval, equalIntervals). + * Only one termination method should be provided. + * @param timeTolerance Tolerance for time comparison in seconds (default: 0.01s) + * @param positionTolerance Maximum distance between positions to consider them matching. + * If null, calculated as max(timeTolerance * 10, 1e-6). + * For random movement tests, consider using a larger value (e.g., 1.0 or more). + * + * @note For simulations to advance time, all reactions must have explicit time distributions. + * Reactions without time distributions default to "Infinity" rate, which schedules + * them at time 0.0, preventing time from advancing. + * + * @note Step-based terminators ensure both simulations execute the same number of steps, + * but final times may differ slightly due to randomness. Time-based terminators + * ensure both simulations reach approximately the same time, but step counts may differ. + * StableForSteps terminators ensure both simulations terminate at a stable state, which + * works well for deterministic simulations (e.g., ReproduceGPSTrace) but may not work + * for random simulations (e.g., BrownianMove) if reactions execute in different orders. + * Small timing differences are expected even with time-based terminators due to thread + * scheduling and the terminator being checked after each step completes. + */ + fun > compareLoaders( + dslLoader: Loader, + yamlLoader: Loader, + steps: Long? = null, + targetTime: Double? = null, + stableForSteps: Pair? = null, + timeTolerance: Double = 0.01, + positionTolerance: Double? = null, + ) { + val terminationMethods = listOfNotNull( + steps?.let { "steps" }, + targetTime?.let { "targetTime" }, + stableForSteps?.let { "stableForSteps" }, + ) + require(terminationMethods.size == 1) { + "Exactly one termination method must be provided: steps, targetTime, or stableForSteps. " + + "Provided: $terminationMethods" + } + val effectiveSteps = steps ?: 0L + println("Running simulations for comparison...") + val dslSimulation = dslLoader.getDefault() + val yamlSimulation = yamlLoader.getDefault() + println( + "DSL simulation initial step: ${dslSimulation.step}, " + + "initial time: ${dslSimulation.time}", + ) + println( + "YAML simulation initial step: ${yamlSimulation.step}, " + + "initial time: ${yamlSimulation.time}", + ) + addTerminators(dslSimulation, yamlSimulation, steps, targetTime, stableForSteps) + try { + runAndCompareSimulations( + dslSimulation, + yamlSimulation, + effectiveSteps, + targetTime, + stableForSteps, + steps, + timeTolerance, + positionTolerance, + ) + } catch (e: Exception) { + fail("Error during simulation execution: ${e.message}") + } finally { + // Ensure simulations are terminated (only if not already terminated) + if (dslSimulation.status != Status.TERMINATED) { + dslSimulation.terminate() + } + if (yamlSimulation.status != Status.TERMINATED) { + yamlSimulation.terminate() + } + } + } + + private fun hasTerminationCondition( + effectiveSteps: Long, + targetTime: Double?, + stableForSteps: Pair?, + ): Boolean = effectiveSteps > 0 || targetTime != null || stableForSteps != null + + private fun > addTerminators( + dslSimulation: Simulation, + yamlSimulation: Simulation, + steps: Long?, + targetTime: Double?, + stableForSteps: Pair?, + ) { + when { + steps != null -> { + addStepTerminator(dslSimulation, steps) + addStepTerminator(yamlSimulation, steps) + println("Added step-based terminators for $steps steps") + } + targetTime != null -> { + val time = DoubleTime(targetTime) + addTimeTerminator(dslSimulation, time) + addTimeTerminator(yamlSimulation, time) + println("Added time-based terminators for ${targetTime}s") + } + stableForSteps != null -> { + val (checkInterval, equalIntervals) = stableForSteps + addStableTerminator(dslSimulation, checkInterval, equalIntervals) + addStableTerminator(yamlSimulation, checkInterval, equalIntervals) + println( + "Added stable-for-steps terminators " + + "(checkInterval: $checkInterval, equalIntervals: $equalIntervals)", + ) + } + } + } + + private fun > runAndCompareSimulations( + dslSimulation: Simulation, + yamlSimulation: Simulation, + effectiveSteps: Long, + targetTime: Double?, + stableForSteps: Pair?, + steps: Long?, + timeTolerance: Double, + positionTolerance: Double?, + ) { + println("Running DSL simulation...") + runSimulationSynchronously(dslSimulation) + println( + "DSL simulation completed with status: ${dslSimulation.status}, " + + "step: ${dslSimulation.step}, time: ${dslSimulation.time}", + ) + println("Running YAML simulation...") + runSimulationSynchronously(yamlSimulation) + println( + "YAML simulation completed with status: ${yamlSimulation.status}, " + + "step: ${yamlSimulation.step}, time: ${yamlSimulation.time}", + ) + checkSimulationTimeAdvancement(dslSimulation, yamlSimulation, effectiveSteps, targetTime, stableForSteps) + val effectivePositionTolerance = positionTolerance ?: max(timeTolerance * 10, 1e-6) + compareRuntimeStates( + dslSimulation, + yamlSimulation, + timeTolerance, + compareSteps = steps != null, + positionTolerance = effectivePositionTolerance, + ) + } + + private fun > checkSimulationTimeAdvancement( + dslSimulation: Simulation, + yamlSimulation: Simulation, + effectiveSteps: Long, + targetTime: Double?, + stableForSteps: Pair?, + ) { + val shouldHaveAdvanced = hasTerminationCondition(effectiveSteps, targetTime, stableForSteps) + if (dslSimulation.time.toDouble() == 0.0 && shouldHaveAdvanced) { + println( + "WARNING: DSL simulation time is 0.0. " + + "Ensure all reactions have explicit time distributions.", + ) + } + if (yamlSimulation.time.toDouble() == 0.0 && shouldHaveAdvanced) { + println( + "WARNING: YAML simulation time is 0.0. " + + "Ensure all reactions have explicit time distributions.", + ) + } + } + + /** + * Adds step-based terminator to a simulation. + */ + private fun > addStepTerminator(simulation: Simulation, steps: Long) { + simulation.environment.addTerminator(StepCount(steps)) + } + + /** + * Adds time-based terminator to a simulation. + */ + private fun > addTimeTerminator(simulation: Simulation, targetTime: Time) { + simulation.environment.addTerminator(AfterTime(targetTime)) + } + + /** + * Adds stable-for-steps terminator to a simulation. + * Terminates when environment (positions + node contents) remains unchanged + * for checkInterval * equalIntervals steps. + */ + private fun > addStableTerminator( + simulation: Simulation, + checkInterval: Long, + equalIntervals: Long, + ) { + @Suppress("UNCHECKED_CAST") + val terminator = StableForSteps(checkInterval, equalIntervals) as + TerminationPredicate + simulation.environment.addTerminator(terminator) + } + + /** + * Runs a simulation synchronously (terminator will stop it). + */ + private fun > runSimulationSynchronously(simulation: Simulation) { + println(" Starting simulation thread, initial step: ${simulation.step}, initial time: ${simulation.time}") + val simulationThread = Thread(simulation, "Simulation-${System.currentTimeMillis()}") + simulationThread.start() + + while (simulation.status == Status.INIT) { + Thread.sleep(10) + } + println(" Simulation reached status: ${simulation.status}, step: ${simulation.step}, time: ${simulation.time}") + + while (simulation.status != Status.READY && simulation.status != Status.TERMINATED) { + Thread.sleep(10) + } + println( + " Simulation status after waiting: ${simulation.status}, " + + "step: ${simulation.step}, time: ${simulation.time}", + ) + + if (simulation.status == Status.TERMINATED) { + println(" Simulation already terminated before play()") + simulation.error.ifPresent { throw it } + return + } + + println(" Calling play(), step: ${simulation.step}, time: ${simulation.time}") + simulation.play().get() + println(" After play(), status: ${simulation.status}, step: ${simulation.step}, time: ${simulation.time}") + + simulationThread.join() + println(" Thread joined, final step: ${simulation.step}, final time: ${simulation.time}") + + simulation.error.ifPresent { throw it } + } + + /** + * Compares the final states of two simulations after runtime execution. + */ + private fun > compareRuntimeStates( + dslSimulation: Simulation, + yamlSimulation: Simulation, + timeTolerance: Double = 0.01, + compareSteps: Boolean = true, + positionTolerance: Double = 1e-6, + ) { + println("Comparing runtime simulation states...") + + val dslEnv = dslSimulation.environment + val yamlEnv = yamlSimulation.environment + + // Compare simulation execution state + compareSimulationExecutionState(dslSimulation, yamlSimulation, timeTolerance, compareSteps) + + // Compare environment states + compareRuntimeEnvironmentStates(dslEnv, yamlEnv, positionTolerance) + } + + /** + * Compares simulation execution state (time, step, status, errors). + */ + private fun > compareSimulationExecutionState( + dslSimulation: Simulation, + yamlSimulation: Simulation, + timeTolerance: Double = 0.01, + compareSteps: Boolean = true, + ) { + println("Comparing simulation execution state...") + + val dslTime = dslSimulation.time.toDouble() + val yamlTime = yamlSimulation.time.toDouble() + val timeDiff = abs(dslTime - yamlTime) + + println("DSL simulation time: ${dslSimulation.time}, step: ${dslSimulation.step}") + println("YAML simulation time: ${yamlSimulation.time}, step: ${yamlSimulation.step}") + println("Time difference: ${timeDiff}s (tolerance: ${timeTolerance}s)") + + if (timeDiff > timeTolerance) { + fail( + "Simulation times differ by ${timeDiff}s (tolerance: ${timeTolerance}s). " + + "DSL: ${dslTime}s, YAML: ${yamlTime}s", + ) + } + + // Compare step counts (only if using step-based terminator) + if (compareSteps) { + assertEquals( + yamlSimulation.step, + dslSimulation.step, + "Simulation step counts should match", + ) + } else { + val stepDiff = abs(yamlSimulation.step - dslSimulation.step) + println("Step difference: $stepDiff (not comparing - using time-based terminator)") + } + + // Compare status + assertEquals( + yamlSimulation.status, + dslSimulation.status, + "Simulation status should match", + ) + + // Compare error states + val dslError = dslSimulation.error + val yamlError = yamlSimulation.error + + if (dslError.isPresent != yamlError.isPresent) { + fail( + "Error states differ: DSL has error=${dslError.isPresent}, YAML has error=${yamlError.isPresent}", + ) + } + + if (dslError.isPresent && yamlError.isPresent) { + // Both have errors, compare error messages + val dslErrorMsg = dslError.get().message ?: "Unknown error" + val yamlErrorMsg = yamlError.get().message ?: "Unknown error" + if (dslErrorMsg != yamlErrorMsg) { + fail("Error messages differ: DSL='$dslErrorMsg', YAML='$yamlErrorMsg'") + } + } + } + + /** + * Compares environment states after runtime execution. + */ + private fun > compareRuntimeEnvironmentStates( + dslEnv: Environment, + yamlEnv: Environment, + positionTolerance: Double = 1e-6, + ) { + println("Comparing runtime environment states...") + + // Compare basic environment properties + assertEquals( + yamlEnv.nodeCount, + dslEnv.nodeCount, + "Node counts should match after runtime", + ) + + assertEquals( + yamlEnv.dimensions, + dslEnv.dimensions, + "Environment dimensions should match", + ) + + // Compare node positions and contents + compareRuntimeNodeStates(dslEnv, yamlEnv, positionTolerance) + + // Compare global reactions + compareRuntimeGlobalReactions(dslEnv, yamlEnv) + + // Compare layers + compareRuntimeLayers(dslEnv, yamlEnv) + } + + /** + * Compares node states after runtime execution using position-based matching with tolerance. + * + * @param positionTolerance Maximum distance between positions to consider them matching (default: 1e-6) + */ + private fun > compareRuntimeNodeStates( + dslEnv: Environment, + yamlEnv: Environment, + positionTolerance: Double = 1e-6, + ) { + println("Comparing runtime node states... (position tolerance: $positionTolerance)") + + val dslNodesWithPos = dslEnv.nodes.map { it to dslEnv.getPosition(it) } + val yamlNodesWithPos = yamlEnv.nodes.map { it to yamlEnv.getPosition(it) }.toMutableList() + + val (matchedPairs, unmatchedDslNodes, distances) = matchNodesByPosition( + dslNodesWithPos, + yamlNodesWithPos, + positionTolerance, + ) + + printMatchingStatistics(distances, matchedPairs, dslNodesWithPos.size) + checkUnmatchedNodes(unmatchedDslNodes, yamlNodesWithPos, distances, positionTolerance) + compareMatchedNodes(matchedPairs, dslEnv, yamlEnv, positionTolerance) + } + + private fun > matchNodesByPosition( + dslNodesWithPos: List, P>>, + yamlNodesWithPos: MutableList, P>>, + positionTolerance: Double, + ): Triple, Node>>, List, P>>, List> { + val matchedPairs = mutableListOf, Node>>() + val unmatchedDslNodes = mutableListOf, P>>() + val distances = mutableListOf() + + for ((dslNode, dslPos) in dslNodesWithPos) { + val closest = yamlNodesWithPos.minByOrNull { (_, yamlPos) -> + dslPos.distanceTo(yamlPos) + } + if (closest != null) { + val (yamlNode, yamlPos) = closest + val distance = dslPos.distanceTo(yamlPos) + distances.add(distance) + if (distance <= positionTolerance) { + matchedPairs.add(dslNode to yamlNode) + yamlNodesWithPos.remove(closest) + } else { + unmatchedDslNodes.add(dslNode to dslPos) + } + } else { + unmatchedDslNodes.add(dslNode to dslPos) + } + } + + return Triple(matchedPairs, unmatchedDslNodes, distances) + } + + private fun printMatchingStatistics( + distances: List, + matchedPairs: List, Node>>, + totalNodes: Int, + ) { + if (distances.isNotEmpty()) { + val minDistance = distances.minOrNull() ?: Double.MAX_VALUE + val maxDistance = distances.maxOrNull() ?: 0.0 + val avgDistance = distances.average() + println( + "Position matching statistics: min=$minDistance, max=$maxDistance, " + + "avg=$avgDistance, matched=${matchedPairs.size}/$totalNodes", + ) + } + } + + private fun > checkUnmatchedNodes( + unmatchedDslNodes: List, P>>, + yamlNodesWithPos: List, P>>, + distances: List, + positionTolerance: Double, + ) { + if (unmatchedDslNodes.isNotEmpty()) { + val minDistance = distances.minOrNull() ?: Double.MAX_VALUE + val maxDistance = distances.maxOrNull() ?: 0.0 + val avgDistance = distances.average() + val positions = unmatchedDslNodes.take(10).joinToString(", ") { (_, pos) -> pos.toString() } + val moreInfo = if (unmatchedDslNodes.size > 10) { + " ... and ${unmatchedDslNodes.size - 10} more" + } else { + "" + } + fail( + "DSL simulation has ${unmatchedDslNodes.size} unmatched nodes " + + "(tolerance: $positionTolerance). Distance stats: min=$minDistance, " + + "max=$maxDistance, avg=$avgDistance. First 10 positions: $positions$moreInfo", + ) + } + if (yamlNodesWithPos.isNotEmpty()) { + val positions = yamlNodesWithPos.take(10).joinToString(", ") { (_, pos) -> pos.toString() } + val moreInfo = if (yamlNodesWithPos.size > 10) { + " ... and ${yamlNodesWithPos.size - 10} more" + } else { + "" + } + fail( + "YAML simulation has ${yamlNodesWithPos.size} unmatched nodes " + + "at positions: $positions$moreInfo", + ) + } + } + + private fun > compareMatchedNodes( + matchedPairs: List, Node>>, + dslEnv: Environment, + yamlEnv: Environment, + positionTolerance: Double, + ) { + for ((dslNode, yamlNode) in matchedPairs) { + val dslPos = dslEnv.getPosition(dslNode) + val yamlPos = yamlEnv.getPosition(yamlNode) + val distance = dslPos.distanceTo(yamlPos) + if (distance > positionTolerance) { + println( + "WARNING: Matched nodes have distance $distance " + + "(tolerance: $positionTolerance)", + ) + } + compareNodeContentsAtPosition(dslNode, yamlNode, dslPos) + } + } + + /** + * Compares contents of two nodes at the same position. + */ + private fun compareNodeContentsAtPosition(dslNode: Node, yamlNode: Node, position: Any) { + // Compare molecule counts + assertEquals( + yamlNode.moleculeCount, + dslNode.moleculeCount, + "Molecule counts should match at position $position", + ) + + // Compare all molecule concentrations + val dslContents = dslNode.contents + val yamlContents = yamlNode.contents + + // Get all unique molecule names + val allMolecules = (dslContents.keys + yamlContents.keys).distinct() + + for (molecule in allMolecules) { + val dslConcentration = dslContents[molecule] + val yamlConcentration = yamlContents[molecule] + + when { + dslConcentration == null && yamlConcentration == null -> { + // Both null, continue + } + dslConcentration == null -> { + fail("DSL node missing molecule $molecule at position $position") + } + yamlConcentration == null -> { + fail("YAML node missing molecule $molecule at position $position") + } + else -> { + // Both concentrations exist, compare them exactly + assertEquals( + yamlConcentration, + dslConcentration, + "Concentration of molecule $molecule should match at position $position", + ) + } + } + } + + // Compare reaction counts + assertEquals( + yamlNode.reactions.size, + dslNode.reactions.size, + "Reaction counts should match at position $position", + ) + } + + /** + * Compares global reactions after runtime execution. + */ + private fun > compareRuntimeGlobalReactions( + dslEnv: Environment, + yamlEnv: Environment, + ) { + println("Comparing runtime global reactions...") + + assertEquals( + yamlEnv.globalReactions.size, + dslEnv.globalReactions.size, + "Global reaction counts should match after runtime", + ) + + // Compare global reaction types + val dslGlobalTypes = dslEnv.globalReactions.map { it::class }.sortedBy { it.simpleName } + val yamlGlobalTypes = yamlEnv.globalReactions.map { it::class }.sortedBy { it.simpleName } + + assertEquals( + yamlGlobalTypes, + dslGlobalTypes, + "Global reaction types should match after runtime", + ) + } + + /** + * Compares layers after runtime execution. + */ + private fun > compareRuntimeLayers(dslEnv: Environment, yamlEnv: Environment) { + println("Comparing runtime layers...") + + assertEquals( + yamlEnv.layers.size, + dslEnv.layers.size, + "Layer counts should match after runtime", + ) + + // Compare layer types + val dslLayerTypes = dslEnv.layers.map { it::class }.sortedBy { it.simpleName } + val yamlLayerTypes = yamlEnv.layers.map { it::class }.sortedBy { it.simpleName } + + assertEquals( + yamlLayerTypes, + dslLayerTypes, + "Layer types should match after runtime", + ) + + LayerComparisonUtils.compareLayerValues(dslEnv, yamlEnv) + } +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/SimulationsComparisons.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/SimulationsComparisons.kt new file mode 100644 index 0000000000..52b709b43b --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/SimulationsComparisons.kt @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ +package it.unibo.alchemist.dsl + +import it.unibo.alchemist.model.Position +import org.junit.jupiter.api.Test + +class SimulationsComparisons { + + @Test + fun > test01() { + { DslLoaderFunctions.test01Nodes() }.shouldEqual("dsl/yml/01-nodes.yml") + } + + @Test + fun > test02() { + { DslLoaderFunctions.test02ManyNodes() }.shouldEqual("dsl/yml/02-manynodes.yml") + } + + @Test + fun > test03() { + { DslLoaderFunctions.test03Grid() }.shouldEqual("dsl/yml/03-grid.yml") + } + + @Test + fun > test05() { + { DslLoaderFunctions.test05Content() }.shouldEqual("dsl/yml/05-content.yml") + } + + @Test + fun > test06() { + { DslLoaderFunctions.test06ContentFiltered() }.shouldEqual("dsl/yml/06-filters.yml") + } + + @Test + fun > test07() { + { DslLoaderFunctions.test07Programs() }.shouldEqual("dsl/yml/07-program.yml") + } + + @Test + fun > test08() { + DslLoaderFunctions.test08ProtelisPrograms().shouldEqual("dsl/yml/08-protelisprogram.yml") + } + + @Test + fun > test09() { + { DslLoaderFunctions.test09TimeDistribution() }.shouldEqual("dsl/yml/09-timedistribution.yml") + } + + @Test + fun > test10() { + { DslLoaderFunctions.test10Environment() }.shouldEqual("dsl/yml/10-environment.yml") + } + + @Test + fun > test11() { + { DslLoaderFunctions.test11monitors() }.shouldEqual("dsl/yml/11-monitors.yml") + } + + @Test + fun > test12() { + { DslLoaderFunctions.test12Layers() }.shouldEqual("dsl/yml/12-layers.yml") + } + + @Test + fun > test13() { + { DslLoaderFunctions.test13GlobalReaction() }.shouldEqual("dsl/yml/13-globalreaction.yml") + } + + @Test + fun > test14() { + { DslLoaderFunctions.test14Exporters() }.shouldEqual("dsl/yml/14-exporters.yml") + } + + @Test + fun > test15() { + { DslLoaderFunctions.test15Variables() }.shouldEqual("dsl/yml/15-variables.yml") + } + + @Test + fun > test16() { + { DslLoaderFunctions.test16ProgramsFilters() } + .shouldEqual( + "dsl/yml/16-programsfilters.yml", + targetTime = 10.0, + ) + } + + @Test + fun > test17() { + { DslLoaderFunctions.test17CustomNodes() }.shouldEqual("dsl/yml/17-customnodes.yml") + } + + @Test + fun > test18() { + { DslLoaderFunctions.test18NodeProperties() }.shouldEqual("dsl/yml/18-properties.yml") + } + + @Test + fun > test20() { + { DslLoaderFunctions.test20Actions() }.shouldEqual( + "dsl/yml/20-move.yml", + targetTime = 10.0, + ) + } +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/StaticComparisonHelper.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/StaticComparisonHelper.kt new file mode 100644 index 0000000000..23c3c8a44d --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/StaticComparisonHelper.kt @@ -0,0 +1,422 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ +package it.unibo.alchemist.dsl + +import it.unibo.alchemist.boundary.Exporter +import it.unibo.alchemist.boundary.Loader +import it.unibo.alchemist.boundary.exporters.GlobalExporter +import it.unibo.alchemist.core.Simulation +import it.unibo.alchemist.model.Environment +import it.unibo.alchemist.model.Node +import it.unibo.alchemist.model.Position +import org.junit.jupiter.api.Assertions.assertEquals + +/** + * Helper for comparing static properties of DSL and YAML loaders + * + * This class focuses on comparing the initial state and configuration + * of loaders without running the simulations. + */ +object StaticComparisonHelper { + + /** + * Compares basic loader properties. + */ + fun compareBasicProperties(dslLoader: Loader, yamlLoader: Loader) { + println("Comparing basic properties...") + + // Compare constants + assertEquals( + yamlLoader.constants, + dslLoader.constants, + "Constants should match", + ) + // Compare variables by name, default value, and full stream + val yamlVariables = yamlLoader.variables + val dslVariables = dslLoader.variables + assertEquals( + yamlVariables.keys, + dslVariables.keys, + "Variable names should match", + ) + yamlVariables.keys.forEach { name -> + val yamlVar = yamlVariables.getValue(name) + val dslVar = dslVariables.getValue(name) + assertEquals( + yamlVar.default, + dslVar.default, + "Default value of variable '$name' should match", + ) + val yamlValues = yamlVar.stream().toList() + val dslValues = dslVar.stream().toList() + assertEquals( + yamlValues, + dslValues, + "Values stream of variable '$name' should match", + ) + } + // Compare remote dependencies + assertEquals( + yamlLoader.remoteDependencies, + dslLoader.remoteDependencies, + "Remote dependencies should match", + ) + // Compare launcher types (not exact instances) + assertEquals( + yamlLoader.launcher::class, + dslLoader.launcher::class, + "Launcher types should match", + ) + } + + /** + * Compares the simulations generated by both loaders. + */ + fun > compareSimulations(dslLoader: Loader, yamlLoader: Loader) { + println("Comparing simulations...") + val yamlSimulation = yamlLoader.getDefault() + val dslSimulation = dslLoader.getDefault() + // Compare environments + compareEnvironments(dslSimulation.environment, yamlSimulation.environment) + // Compare simulation properties + compareSimulationProperties(dslSimulation, yamlSimulation) + } + + /** + * Compares two environments. + */ + private fun > compareEnvironments(dslEnv: Environment, yamlEnv: Environment) { + println("Comparing environments...") + // Compare node counts + assertEquals( + yamlEnv.nodes.size, + dslEnv.nodes.size, + "Node counts should match", + ) + // compare nodes class names + val dslNodeTypes = dslEnv.nodes.map { it::class }.sortedBy { it.simpleName } + val yamlNodeTypes = yamlEnv.nodes.map { it::class }.sortedBy { it.simpleName } + assertEquals( + yamlNodeTypes, + dslNodeTypes, + "Node types should match", + ) + // Compare node positions + val dslPositions = dslEnv.nodes.map { dslEnv.getPosition(it) }.toSet() + val yamlPositions = yamlEnv.nodes.map { yamlEnv.getPosition(it) }.toSet() + assertEquals( + yamlPositions, + dslPositions, + "Node positions should match", + ) + // compare node properties + val dslNodeProperties = dslEnv.nodes.flatMap { it.properties }.map { it.toString() }.sortedBy { it } + val yamlNodeProperties = yamlEnv.nodes.flatMap { it.properties }.map { it.toString() }.sortedBy { it } + println(dslNodeProperties) + println(yamlNodeProperties) + assertEquals( + yamlNodeProperties, + dslNodeProperties, + "Node properties should match", + ) + // Compare node contents (molecules and concentrations) + compareNodeContents(dslEnv, yamlEnv) + + // Compare linking rules + assertEquals( + yamlEnv.linkingRule::class, + dslEnv.linkingRule::class, + "Linking rule types should match", + ) + // Compare neighborhoods induced by the linking rule + compareNeighborhoods(dslEnv, yamlEnv) + // Compare programs (reactions) + comparePrograms(dslEnv, yamlEnv) + // Compare layers + compareLayers(dslEnv, yamlEnv) + } + + /** + * Compares neighborhoods for each node matched by position in both environments. + */ + private fun > compareNeighborhoods(dslEnv: Environment, yamlEnv: Environment) { + val dslByPos = dslEnv.nodes.associateBy { dslEnv.getPosition(it) } + val yamlByPos = yamlEnv.nodes.associateBy { yamlEnv.getPosition(it) } + // Positions should already match; guard to provide clearer error if not + assertEquals( + yamlByPos.keys, + dslByPos.keys, + "Node position sets should match before neighborhood comparison", + ) + for (position in dslByPos.keys) { + val dslNode = dslByPos.getValue(position) + val yamlNode = yamlByPos.getValue(position) + val dslNeighborPositions = dslEnv + .getNeighborhood(dslNode) + .neighbors + .map { dslEnv.getPosition(it) } + .toSet() + val yamlNeighborPositions = yamlEnv + .getNeighborhood(yamlNode) + .neighbors + .map { yamlEnv.getPosition(it) } + .toSet() + assertEquals( + yamlNeighborPositions, + dslNeighborPositions, + "Neighborhood for node at $position should match", + ) + } + } + + /** + * Compares node contents (molecules and concentrations). + */ + private fun > compareNodeContents(dslEnv: Environment, yamlEnv: Environment) { + println("Comparing node contents...") + // Since we can't match by position, we'll compare all nodes by their contents + val dslNodes = dslEnv.nodes.toList() + val yamlNodes = yamlEnv.nodes.toList() + // Compare total molecule counts + val dslTotalMolecules = dslNodes.sumOf { it.moleculeCount } + val yamlTotalMolecules = yamlNodes.sumOf { it.moleculeCount } + assertEquals( + yamlTotalMolecules, + dslTotalMolecules, + "Total molecule counts should match", + ) + // Compare all node contents (without position matching) + val dslContents = dslNodes.map { it.contents }.sortedBy { it.toString() } + val yamlContents = yamlNodes.map { it.contents }.sortedBy { it.toString() } + assertEquals( + yamlContents, + dslContents, + "All node contents (molecules and concentrations) should match", + ) + } + + /** + * Compares programs between environments. + */ + private fun > comparePrograms(dslEnv: Environment, yamlEnv: Environment) { + println("Comparing programs...") + // Compare global reactions + compareGlobalReactions(dslEnv, yamlEnv) + // Compare node reactions + compareNodeReactions(dslEnv, yamlEnv) + } + + /** + * Compares global reactions. + */ + private fun > compareGlobalReactions(dslEnv: Environment, yamlEnv: Environment) { + println("Comparing global reactions...") + val dslGlobalReactions = dslEnv.globalReactions.toList() + val yamlGlobalReactions = yamlEnv.globalReactions.toList() + assertEquals( + yamlGlobalReactions.size, + dslGlobalReactions.size, + "Global reactions count should match", + ) + // Compare global reaction types + val dslGlobalTypes = dslGlobalReactions.map { it::class }.sortedBy { it.simpleName } + val yamlGlobalTypes = yamlGlobalReactions.map { it::class }.sortedBy { it.simpleName } + assertEquals( + yamlGlobalTypes, + dslGlobalTypes, + "Global reaction types should match", + ) + } + + /** + * Compares node reactions (programs). + */ + private fun > compareNodeReactions(dslEnv: Environment, yamlEnv: Environment) { + println("Comparing node reactions...") + val dslNodes = dslEnv.nodes.toList() + val yamlNodes = yamlEnv.nodes.toList() + // Compare total reaction counts + val dslTotalReactions = dslNodes.sumOf { it.reactions.size } + val yamlTotalReactions = yamlNodes.sumOf { it.reactions.size } + assertEquals( + yamlTotalReactions, + dslTotalReactions, + "Total node reactions count should match", + ) + // Compare reaction types across all nodes + val dslReactionTypes = dslNodes.flatMap { it.reactions }.map { it::class }.sortedBy { it.simpleName } + val yamlReactionTypes = yamlNodes.flatMap { it.reactions }.map { it::class }.sortedBy { it.simpleName } + assertEquals( + yamlReactionTypes, + dslReactionTypes, + "Node reaction types should match", + ) + // Compare reaction programs (conditions and actions) + compareReactionPrograms(dslNodes, yamlNodes) + } + + /** + * Compares reaction programs by comparing their string representations and dependencies. + */ + private fun compareReactionPrograms(dslNodes: List>, yamlNodes: List>) { + println("Comparing reaction programs...") + // Compare total reactions count + val dslTotalReactions = dslNodes.sumOf { it.reactions.size } + val yamlTotalReactions = yamlNodes.sumOf { it.reactions.size } + assertEquals( + yamlTotalReactions, + dslTotalReactions, + "Total reactions count should match", + ) + val dslReactions = extractReactionInfo(dslNodes) + val yamlReactions = extractReactionInfo(yamlNodes) + assertEquals( + yamlReactions, + dslReactions, + "Reaction string representations and dependencies should match", + ) + } + + /** + * Data class to represent reaction information for comparison. + */ + private data class ReactionInfo( + val reactionString: String, + val inboundDependencies: List, + val outboundDependencies: List, + ) { + override fun toString(): String = + "Reaction(string='$reactionString', inbound=$inboundDependencies, outbound=$outboundDependencies)" + } + + private fun extractReactionInfo(nodes: List>): List = nodes.flatMap { + it.reactions + }.map { reaction -> + ReactionInfo( + reactionString = try { + reaction.toString() + } catch (e: Exception) { + "" + }, + inboundDependencies = reaction.inboundDependencies.map { it.toString() }.sorted(), + outboundDependencies = reaction.outboundDependencies.map { it.toString() }.sorted(), + ) + }.sortedBy { it.toString() } + + /** + * Compares simulation properties. + */ + private fun compareSimulationProperties(dslSimulation: Simulation<*, *>, yamlSimulation: Simulation<*, *>) { + println("Comparing simulation properties...") + // Compare output monitors count + assertEquals( + yamlSimulation.outputMonitors.size, + dslSimulation.outputMonitors.size, + "Output monitors count should match", + ) + + // Compare output monitor types by class since instances may differ + val yamlMonitorTypes = yamlSimulation.outputMonitors.map { it::class } + val dslMonitorTypes = dslSimulation.outputMonitors.map { it::class } + assertEquals( + yamlMonitorTypes.sortedBy { it.simpleName }, + dslMonitorTypes.sortedBy { it.simpleName }, + "Output monitor types should match", + ) + compareExporters(dslSimulation, yamlSimulation) + } + + private fun compareExporters(dslSimulation: Simulation<*, *>, yamlSimulation: Simulation<*, *>) { + val dslExporters = dslSimulation.outputMonitors + .filterIsInstance>() + .flatMap { it.exporters } + val yamlExporters = yamlSimulation.outputMonitors + .filterIsInstance>() + .flatMap { it.exporters } + assertEquals( + yamlExporters.size, + dslExporters.size, + "Exporter counts should match", + ) + + val dslTypes = dslExporters.map { it::class }.sortedBy { it.simpleName } + val yamlTypes = yamlExporters.map { it::class }.sortedBy { it.simpleName } + assertEquals( + yamlTypes, + dslTypes, + "Exporter types should match", + ) + compareDataExtractors(dslExporters, yamlExporters) + } + + private fun compareDataExtractors(dslExporters: List>, yamlExporters: List>) { + val dslTotal = dslExporters.sumOf { it.dataExtractors.size } + val yamlTotal = yamlExporters.sumOf { it.dataExtractors.size } + assertEquals( + yamlTotal, + dslTotal, + "Total data extractor counts should match", + ) + val dslTypes = dslExporters.flatMap { it.dataExtractors }.map { it::class }.sortedBy { it.simpleName } + val yamlTypes = yamlExporters.flatMap { it.dataExtractors }.map { it::class }.sortedBy { it.simpleName } + assertEquals( + yamlTypes, + dslTypes, + "Data extractor types should match", + ) + val dslInfo = dslExporters.flatMap { it.dataExtractors }.map { extractor -> + ExtractorInfo( + type = extractor::class.simpleName.orEmpty(), + columns = extractor.columnNames.sorted(), + ) + }.sortedBy { it.toString() } + val yamlInfo = yamlExporters.flatMap { it.dataExtractors }.map { extractor -> + ExtractorInfo( + type = extractor::class.simpleName.orEmpty(), + columns = extractor.columnNames.sorted(), + ) + }.sortedBy { it.toString() } + assertEquals( + yamlInfo, + dslInfo, + "Data extractor information (types and column names) should match", + ) + } + + private data class ExtractorInfo( + val type: String, + val columns: List, + ) { + override fun toString(): String = "Extractor(type=$type, columns=$columns)" + } + + /** + * Compares layers between environments. + */ + private fun > compareLayers(dslEnv: Environment, yamlEnv: Environment) { + println("Comparing layers...") + // Simplified check + // If two layers have different molecules this test does not detect it. + // Compare layer counts + assertEquals( + yamlEnv.layers.size, + dslEnv.layers.size, + "Layer counts should match", + ) + // Compare layer types + val dslLayerTypes = dslEnv.layers.map { it::class }.sortedBy { it.simpleName } + val yamlLayerTypes = yamlEnv.layers.map { it::class }.sortedBy { it.simpleName } + assertEquals( + yamlLayerTypes, + dslLayerTypes, + "Layer types should match", + ) + LayerComparisonUtils.compareLayerValues(dslEnv, yamlEnv) + } +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestComparators.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestComparators.kt new file mode 100644 index 0000000000..5fa9be9261 --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestComparators.kt @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ +package it.unibo.alchemist.dsl + +import it.unibo.alchemist.boundary.Loader +import it.unibo.alchemist.model.Position + +/** + * Main test comparison class that handles static and runtime comparisons + * + * This class provides an interface for comparing DSL and YAML loaders + * with the option to include runtime behavior testing. + */ +object TestComparators { + + /** + * Compares a DSL loader with a YAML loader. + * + * @param dslLoader The DSL loader to compare. + * @param yamlResource The YAML resource path to compare against. + * @param includeRuntime Whether to include runtime behavior comparison. + * @param steps The number of steps for runtime comparison (only used if includeRuntime is true). + * Exactly one of steps, targetTime, or stableForSteps must be provided. + * @param targetTime Target time to run until (only used if includeRuntime is true). + * Exactly one of steps, targetTime, or stableForSteps must be provided. + * @param stableForSteps If provided, terminates when environment is stable (checkInterval, equalIntervals). + * Exactly one of steps, targetTime, or stableForSteps must be provided. + * @param timeTolerance Tolerance for time comparison in seconds (default: 0.01s). + */ + fun > compare( + dslLoader: () -> Loader, + yamlResource: String, + includeRuntime: Boolean = false, + steps: Long? = null, + targetTime: Double? = null, + stableForSteps: Pair? = null, + timeTolerance: Double = 0.01, + ) { + val yamlLoader = LoaderFactory.loadYaml(yamlResource) + // Always perform static comparison + StaticComparisonHelper.compareBasicProperties(dslLoader(), yamlLoader) + StaticComparisonHelper.compareSimulations(dslLoader(), yamlLoader) + // Optionally perform runtime comparison + if (includeRuntime) { + RuntimeComparisonHelper.compareLoaders( + dslLoader(), + yamlLoader, + steps = steps, + targetTime = targetTime, + stableForSteps = stableForSteps, + timeTolerance = timeTolerance, + positionTolerance = null, + ) + } + } + + /** + * Compares DSL code with a YAML resource. + * + * @param dslCode The DSL code resource path. + * @param yamlResource The YAML resource path to compare against. + * @param includeRuntime Whether to include runtime behavior comparison. + * @param steps The number of steps for runtime comparison (only used if includeRuntime is true). + * Exactly one of steps, targetTime, or stableForSteps must be provided. + * @param targetTime Target time to run until (only used if includeRuntime is true). + * Exactly one of steps, targetTime, or stableForSteps must be provided. + * @param stableForSteps If provided, terminates when environment is stable (checkInterval, equalIntervals). + * Exactly one of steps, targetTime, or stableForSteps must be provided. + * @param timeTolerance Tolerance for time comparison in seconds (default: 0.01s). + */ + fun > compare( + dslCode: String, + yamlResource: String, + includeRuntime: Boolean = false, + steps: Long? = null, + targetTime: Double? = null, + stableForSteps: Pair? = null, + timeTolerance: Double = 0.01, + ) { + compare({ + LoaderFactory.loadDsl(dslCode) + }, yamlResource, includeRuntime, steps, targetTime, stableForSteps, timeTolerance) + } + + /** + * Compares two loaders directly. + * + * @param dslLoader The DSL loader to compare. + * @param yamlLoader The YAML loader to compare against. + * @param includeRuntime Whether to include runtime behavior comparison. + * @param steps The number of steps for runtime comparison (only used if includeRuntime is true). + * Exactly one of steps, targetTime, or stableForSteps must be provided. + * @param targetTime Target time to run until (only used if includeRuntime is true). + * Exactly one of steps, targetTime, or stableForSteps must be provided. + * @param stableForSteps If provided, terminates when environment is stable (checkInterval, equalIntervals). + * Exactly one of steps, targetTime, or stableForSteps must be provided. + * @param timeTolerance Tolerance for time comparison in seconds (default: 0.01s). + */ + fun > compare( + dslLoader: Loader, + yamlLoader: Loader, + includeRuntime: Boolean = false, + steps: Long? = null, + targetTime: Double? = null, + stableForSteps: Pair? = null, + timeTolerance: Double = 0.01, + ) { + // Always perform static comparison + StaticComparisonHelper.compareBasicProperties(dslLoader, yamlLoader) + StaticComparisonHelper.compareSimulations(dslLoader, yamlLoader) + // Optionally perform runtime comparison + if (includeRuntime) { + RuntimeComparisonHelper.compareLoaders( + dslLoader, + yamlLoader, + steps = steps, + targetTime = targetTime, + stableForSteps = stableForSteps, + timeTolerance = timeTolerance, + positionTolerance = null, + ) + } + } +} + +private const val DEFAULT_STEPS = 3000L + +private fun computeEffectiveSteps( + includeRuntime: Boolean, + steps: Long?, + targetTime: Double?, + stableForSteps: Pair?, +): Long? { + val shouldUseDefault = includeRuntime && steps == null && targetTime == null && stableForSteps == null + return if (shouldUseDefault) DEFAULT_STEPS else steps +} + +/** + * Extension function for easier test writing with static comparison only. + */ +fun Loader.shouldEqual(yamlResource: String) { + @Suppress("UNCHECKED_CAST") + TestComparators.compare({ this }, yamlResource, includeRuntime = false) +} + +/** + * Extension function for comparing two loaders. + * + * @param other The other loader to compare against + * @param includeRuntime Whether to include runtime behavior comparison + * @param steps The number of steps for runtime comparison. + * If includeRuntime is true and no termination method is provided, defaults to 3000L. + * @param targetTime Target time to run until. + * Exactly one of steps, targetTime, or stableForSteps must be provided when includeRuntime is true. + * @param stableForSteps If provided, terminates when environment is stable (checkInterval, equalIntervals). + * Exactly one of steps, targetTime, or stableForSteps must be provided when includeRuntime is true. + * @param timeTolerance Tolerance for time comparison in seconds (default: 0.01s) + */ +fun Loader.shouldEqual( + other: Loader, + includeRuntime: Boolean = true, + steps: Long? = null, + targetTime: Double? = null, + stableForSteps: Pair? = null, + timeTolerance: Double = 0.01, +) { + @Suppress("UNCHECKED_CAST") + TestComparators.compare( + this, + other, + includeRuntime, + computeEffectiveSteps(includeRuntime, steps, targetTime, stableForSteps), + targetTime, + stableForSteps, + timeTolerance, + ) +} + +/** + * Extension function for comparing DSL function with YAML resource. + * + * @param yamlResource The YAML resource path to compare against + * @param includeRuntime Whether to include runtime behavior comparison + * @param steps The number of steps for runtime comparison. + * If includeRuntime is true and no termination method is provided, defaults to 3000L. + * @param targetTime Target time to run until. + * Exactly one of steps, targetTime, or stableForSteps must be provided when includeRuntime is true. + * @param stableForSteps If provided, terminates when environment is stable (checkInterval, equalIntervals). + * Exactly one of steps, targetTime, or stableForSteps must be provided when includeRuntime is true. + * @param timeTolerance Tolerance for time comparison in seconds (default: 0.01s) + * + * @note For simulations to advance time, all reactions must have explicit time distributions. + * Reactions without time distributions default to "Infinity" rate, which schedules + * them at time 0.0, preventing time from advancing. + * + * @note Step-based terminators ensure both simulations execute the same number of steps, + * but final times may differ slightly due to randomness. Time-based terminators + * ensure both simulations reach approximately the same time, but step counts may differ. + * StableForSteps terminators ensure both simulations terminate at a stable state, which + * works well for deterministic simulations (e.g., ReproduceGPSTrace) but may not work + * for random simulations (e.g., BrownianMove) if reactions execute in different orders. + */ +fun (() -> Loader).shouldEqual( + yamlResource: String, + includeRuntime: Boolean = true, + steps: Long? = null, + targetTime: Double? = null, + stableForSteps: Pair? = null, + timeTolerance: Double = 0.01, +) { + @Suppress("UNCHECKED_CAST") + TestComparators.compare( + this, + yamlResource, + includeRuntime, + computeEffectiveSteps(includeRuntime, steps, targetTime, stableForSteps), + targetTime, + stableForSteps, + timeTolerance, + ) +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestContents.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestContents.kt new file mode 100644 index 0000000000..778d456138 --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestContents.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.dsl + +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.boundary.dsl.Dsl.simulation +import it.unibo.alchemist.boundary.dsl.model.AvailableIncarnations.SAPERE +import it.unibo.alchemist.model.deployments.Point +import it.unibo.alchemist.model.positions.Euclidean2DPosition +import org.junit.jupiter.api.Test + +class TestContents { + + @Test + fun testAll() { + val incarnation = SAPERE.incarnation() + val loader = simulation(incarnation) { + deployments { + deploy(Point(ctx.environment, 0.0, 0.0)) { + all { + molecule = "test" + concentration = 1.0 + } + } + } + } + + loader.launch(loader.launcher) + } +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestDeployments.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestDeployments.kt new file mode 100644 index 0000000000..98e932a20c --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestDeployments.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.dsl + +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.boundary.dsl.Dsl.simulation +import it.unibo.alchemist.boundary.dsl.model.AvailableIncarnations +import it.unibo.alchemist.model.deployments.Grid +import it.unibo.alchemist.model.deployments.Point +import it.unibo.alchemist.model.positions.Euclidean2DPosition +import org.junit.jupiter.api.Test + +class TestDeployments { + + @Test + fun testDeployments() { + val incarnation = AvailableIncarnations.SAPERE.incarnation() + val loader = simulation(incarnation) { + deployments { + val p = Point(ctx.environment, 0.0, 0.0) + deploy(p) + } + } + + loader.launch(loader.launcher) + } + + @Test + fun testMultipleDeployments() { + val incarnation = AvailableIncarnations.SAPERE.incarnation() + val loader = simulation(incarnation) { + deployments { + val point = Point(ctx.environment, 0.0, 0.0) + deploy(point) + deploy(Point(ctx.environment, 1.0, 1.0)) + } + } + + loader.launch(loader.launcher) + } + + @Test + fun testGridDeployment() { + val incarnation = AvailableIncarnations.SAPERE.incarnation() + val loader = simulation(incarnation) { + deployments { + val grid = Grid( + ctx.environment, + generator, + 1.0, + 1.0, + 5.0, + 5.0, + 1.0, + 1.0, + ) + deploy(grid) + } + } + loader.launch(loader.launcher) + } +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestSimulations.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestSimulations.kt new file mode 100644 index 0000000000..ed435d963c --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestSimulations.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.dsl + +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.boundary.dsl.Dsl.simulation +import it.unibo.alchemist.boundary.dsl.model.AvailableIncarnations.SAPERE +import it.unibo.alchemist.model.linkingrules.ConnectWithinDistance +import it.unibo.alchemist.model.positions.Euclidean2DPosition +import org.junit.jupiter.api.Test + +class TestSimulations { + + @Test + fun testIncarnation() { + val incarnation = SAPERE.incarnation() + val loader = simulation(incarnation) { + } + loader.launch(loader.launcher) + } + + @Test + fun testLinkingRule() { + val incarnation = SAPERE.incarnation() + val loader = simulation(incarnation) { + networkModel = ConnectWithinDistance(5.0) + } + loader.launch(loader.launcher) + } +} diff --git a/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestVariables.kt b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestVariables.kt new file mode 100644 index 0000000000..a7fc78f1f9 --- /dev/null +++ b/alchemist-loading/src/test/kotlin/it/unibo/alchemist/dsl/TestVariables.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +package it.unibo.alchemist.dsl + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.doubles.shouldBeExactly +import io.kotest.matchers.shouldBe +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.boundary.dsl.Dsl.simulation +import it.unibo.alchemist.boundary.dsl.model.AvailableIncarnations.SAPERE +import it.unibo.alchemist.boundary.dsl.model.SimulationContextImpl +import it.unibo.alchemist.boundary.variables.GeometricVariable +import it.unibo.alchemist.boundary.variables.LinearVariable +import it.unibo.alchemist.model.Position +import it.unibo.alchemist.model.positions.Euclidean2DPosition +import org.junit.jupiter.api.Test +@Suppress("UNCHECKED_CAST") +class TestVariables { + @Test + fun > testDefaultValue() { + val incarnation = SAPERE.incarnation() + simulation(incarnation) { + val rate: Double by variable(LinearVariable(5.0, 1.0, 10.0, 1.0)) + + runLater { + println("Checking variable") + rate.shouldBeExactly(5.0) + (this as SimulationContextImpl).variablesContext + .variables.containsKey("rate").shouldBeTrue() + } + }.getDefault() // needed to build the simulation + } + + @Test + fun > testOverrideValue() { + val incarnation = SAPERE.incarnation() + val loader = simulation(incarnation) { + val rate: Double by variable(LinearVariable(5.0, 1.0, 10.0, 1.0)) + deployments { + rate.shouldBeExactly(20.0) + (this@simulation as SimulationContextImpl).variablesContext + .variables.containsKey("rate").shouldBeTrue() + } + } + loader.getWith(mapOf("rate" to 20.0)) + } + + @Suppress("NoNameShadowing") + @Test + fun > testDoubleDeclaration() { + val incarnation = SAPERE.incarnation() + simulation(incarnation) { + val rate: Double by variable(LinearVariable(5.0, 1.0, 10.0, 1.0)) + println("First declaration of rate: $rate") + shouldThrow { + val rate: Double by variable(GeometricVariable(2.0, 1.0, 5.0, 1)) + println("This line should not be printed: $rate") + } + } + } + + @Test + fun > testDependendVariable() { + val incarnation = SAPERE.incarnation() + val loader = simulation(incarnation) { + val rate: Double by variable(GeometricVariable(2.0, 0.1, 10.0, 9)) + val size: Double by variable(LinearVariable(5.0, 1.0, 10.0, 1.0)) + + val mSize by variable { -size } + val sourceStart by variable { mSize / 10.0 } + val sourceSize by variable { size / 5.0 } + + runLater { + rate.shouldBe(2.0) + size.shouldBe(10.0) + mSize.shouldBe(-10.0) + sourceStart.shouldBe(-1.0) + sourceSize.shouldBe(2.0) + } + } + loader.getWith(mapOf("size" to 10.0)) + } +} diff --git a/alchemist-loading/src/test/resources/dsl/kts/11-monitors.alchemist.kts b/alchemist-loading/src/test/resources/dsl/kts/11-monitors.alchemist.kts new file mode 100644 index 0000000000..941bd2c9ed --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/kts/11-monitors.alchemist.kts @@ -0,0 +1,19 @@ +import another.location.SimpleMonitor +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.boundary.dsl.Dsl.simulation + +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ + +val incarnation = SAPERE.incarnation() +simulation(incarnation) { + monitors { + +SimpleMonitor() + } +} diff --git a/alchemist-loading/src/test/resources/dsl/kts/12-layers.alchemist.kts b/alchemist-loading/src/test/resources/dsl/kts/12-layers.alchemist.kts new file mode 100644 index 0000000000..7643de365a --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/kts/12-layers.alchemist.kts @@ -0,0 +1,22 @@ +val incarnation = SAPERE.incarnation() +simulation(incarnation) { + layer { + molecule = "A" + layer = StepLayer(2.0, 2.0, 100.0, 0.0) + } + layer { + molecule = "B" + layer = StepLayer(-2.0, -2.0, 0.0, 100.0) + } + deployments { + deploy( + grid(-5.0, -5.0, 5.0, 5.0, 0.25, + 0.1, 0.1, + ), + ) { + all { + molecule = "a" + } + } + } +} diff --git a/alchemist-loading/src/test/resources/dsl/kts/14-exporters.alchemist.kts b/alchemist-loading/src/test/resources/dsl/kts/14-exporters.alchemist.kts new file mode 100644 index 0000000000..82ebaf280d --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/kts/14-exporters.alchemist.kts @@ -0,0 +1,21 @@ +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.boundary.dsl.Dsl.simulation + +val incarnation = PROTELIS.incarnation() +simulation(incarnation) { + exporter { + type = CSVExporter( + "test_export_interval", + 4.0, + ) + data( + Time(), + moleculeReader( + "default_module:default_program", + null, + CommonFilters.NOFILTER.filteringPolicy, + emptyList(), + ), + ) + } +} diff --git a/alchemist-loading/src/test/resources/dsl/kts/15-variables.alchemist.kts b/alchemist-loading/src/test/resources/dsl/kts/15-variables.alchemist.kts new file mode 100644 index 0000000000..6bc0077881 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/kts/15-variables.alchemist.kts @@ -0,0 +1,38 @@ + +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.boundary.dsl.Dsl.simulation + +val incarnation = SAPERE.incarnation() +simulation(incarnation) { + val rate: Double by variable(GeometricVariable(2.0, 0.1, 10.0, 9)) + val size: Double by variable(LinearVariable(5.0, 1.0, 10.0, 1.0)) + + val mSize by variable { -size } + val sourceStart by variable { mSize / 10.0 } + val sourceSize by variable { size / 5.0 } + + networkModel = ConnectWithinDistance(0.5) + deployments { + deploy( + grid( + mSize, mSize, size, size, + 0.25, 0.25, 0.1, 0.1, + ), + ) { + inside(RectangleFilter(sourceStart, sourceStart, sourceSize, sourceSize)) { + molecule = "token, 0, []" + } + programs { + all { + timeDistribution(rate.toString()) + program = "{token, N, L} --> {token, N, L} *{token, N+#D, L add [#NODE;]}" + } + all { + program = "{token, N, L}{token, def: N2>=N, L2} --> {token, N, L}" + } + } + } + } +} + + diff --git a/alchemist-loading/src/test/resources/dsl/kts/18-properties.alchemist.kts b/alchemist-loading/src/test/resources/dsl/kts/18-properties.alchemist.kts new file mode 100644 index 0000000000..40adb4b227 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/kts/18-properties.alchemist.kts @@ -0,0 +1,29 @@ +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.boundary.dsl.Dsl.simulation + +val incarnation = SAPERE.incarnation() +simulation(incarnation) { + deployments { + deploy( + circle( + 1000, + 0.0, + 0.0, + 15.0, + ), + ) { + properties { + val filter = RectangleFilter(-3.0, -3.0, 2.0, 2.0) + val filter2 = RectangleFilter(3.0, 3.0, 2.0, 2.0) + inside(filter) { + +testNodeProperty("a") + } + // otherwise + inside(filter2) { + +testNodeProperty("b") + } + } + } + } +} + diff --git a/alchemist-loading/src/test/resources/dsl/kts/19-performance.alchemist.kts b/alchemist-loading/src/test/resources/dsl/kts/19-performance.alchemist.kts new file mode 100644 index 0000000000..420f9be133 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/kts/19-performance.alchemist.kts @@ -0,0 +1,110 @@ + +import another.location.SimpleMonitor +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.boundary.dsl.Dsl.simulation +import it.unibo.alchemist.boundary.extractors.Time +import org.apache.commons.math3.random.MersenneTwister + +val incarnation = SAPERE.incarnation() +val environment = {Continuous2DEnvironment(incarnation)} +simulation(incarnation, environment) { + simulationGenerator = MersenneTwister(24L) + scenarioGenerator = MersenneTwister(42L) + + networkModel = ConnectWithinDistance(0.5) + + val rate: Double by variable(GeometricVariable(2.0, 0.1, 10.0, 9)) + val size: Double by variable(LinearVariable(5.0, 1.0, 10.0, 1.0)) + + val mSize by variable { -size } + val sourceStart by variable { mSize / 10.0 } + val sourceSize by variable { size / 5.0 } + + layer { + molecule = "A" + layer = StepLayer(2.0, 2.0, 100.0, 0.0) + } + layer { + molecule = "B" + layer = StepLayer(-2.0, -2.0, 0.0, 100.0) + } + layer { + molecule = "C" + layer = StepLayer(0.0, 0.0, 50.0, 50.0) + } + + monitors { +SimpleMonitor()} + + exporter { + type = CSVExporter( + "performance_test", + 1.0, + ) + data( + Time(), + moleculeReader( + "token", + null, + CommonFilters.NOFILTER.filteringPolicy, + emptyList(), + ), + ) + } + + deployments { + deploy( + circle( + 200, + 0.0, + 0.0, + 20.0, + ), + ) { + all { + molecule = "basemolecule" + } + inside(RectangleFilter(-5.0, -5.0, 10.0, 10.0)) { + molecule = "centermolecule" + } + programs { + all { + timeDistribution("1") + program = "{basemolecule} --> {processed}" + } + all { + program = "{processed} --> +{basemolecule}" + } + } + } + deploy( + grid( + mSize, mSize, size, size, + 0.25, 0.25, 0.1, 0.1, + ), + ) { + all { + molecule = "gridmolecule" + } + inside(RectangleFilter(sourceStart, sourceStart, sourceSize, sourceSize)) { + molecule = "token, 0, []" + } + inside(RectangleFilter(-2.0, -2.0, 4.0, 4.0)) { + molecule = "filteredmolecule" + } + programs { + all { + timeDistribution(rate.toString()) + program = "{token, N, L} --> {token, N, L} *{token, N+#D, L add [#NODE;]}" + } + all { + program = "{token, N, L}{token, def: N2>=N, L2} --> {token, N, L}" + } + inside(RectangleFilter(-1.0, -1.0, 2.0, 2.0)) { + timeDistribution("0.5") + program = "{filteredmolecule} --> {active}" + } + } + } + } +} + diff --git a/alchemist-loading/src/test/resources/dsl/yml/01-nodes.yml b/alchemist-loading/src/test/resources/dsl/yml/01-nodes.yml new file mode 100644 index 0000000000..b64b375972 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/01-nodes.yml @@ -0,0 +1,11 @@ +incarnation: sapere + +network-model: + type: ConnectWithinDistance + parameters: [5] + +deployments: + - type: Point + parameters: [0, 0] + - type: Point + parameters: [0, 1] diff --git a/alchemist-loading/src/test/resources/dsl/yml/02-manynodes.yml b/alchemist-loading/src/test/resources/dsl/yml/02-manynodes.yml new file mode 100644 index 0000000000..e8e3a10ff2 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/02-manynodes.yml @@ -0,0 +1,13 @@ +incarnation: sapere + +seeds: + scenario: 20 + simulation: 10 + +network-model: + type: ConnectWithinDistance + parameters: [0.5] + +deployments: + type: Circle + parameters: [10, 0, 0, 10] diff --git a/alchemist-loading/src/test/resources/dsl/yml/03-grid.yml b/alchemist-loading/src/test/resources/dsl/yml/03-grid.yml new file mode 100644 index 0000000000..010565ebae --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/03-grid.yml @@ -0,0 +1,9 @@ +incarnation: sapere + +network-model: + type: ConnectWithinDistance + parameters: [0.5] + +deployments: + type: Grid + parameters: [-5, -5, 5, 5, 0.25, 0.25, 0, 0] \ No newline at end of file diff --git a/alchemist-loading/src/test/resources/dsl/yml/05-content.yml b/alchemist-loading/src/test/resources/dsl/yml/05-content.yml new file mode 100644 index 0000000000..d065057d75 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/05-content.yml @@ -0,0 +1,11 @@ +incarnation: sapere + +network-model: + type: ConnectWithinDistance + parameters: [0.5] + +deployments: + type: Grid + parameters: [-5, -5, 5, 5, 0.25, 0.25, 0.1, 0.1] + contents: + molecule: hello diff --git a/alchemist-loading/src/test/resources/dsl/yml/06-filters.yml b/alchemist-loading/src/test/resources/dsl/yml/06-filters.yml new file mode 100644 index 0000000000..dedebd5496 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/06-filters.yml @@ -0,0 +1,16 @@ +incarnation: sapere + +network-model: + type: ConnectWithinDistance + parameters: [0.5] + +deployments: + type: Grid + parameters: [-5, -5, 5, 5, 0.25, 0.25, 0.1, 0.1] + contents: + - molecule: hello + - in: + type: Rectangle + parameters: [-1, -1, 2, 2] + + molecule: token diff --git a/alchemist-loading/src/test/resources/dsl/yml/07-program.yml b/alchemist-loading/src/test/resources/dsl/yml/07-program.yml new file mode 100644 index 0000000000..c8f7425be8 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/07-program.yml @@ -0,0 +1,19 @@ +incarnation: sapere + +network-model: + type: ConnectWithinDistance + parameters: [0.5] + +deployments: + type: Grid + parameters: [-5, -5, 5, 5, 0.25, 0.25, 0.1, 0.1] + contents: + in: + type: Rectangle + parameters: [-0.5, -0.5, 1, 1] + molecule: token + programs: + - time-distribution: 1 + program: > + {token} --> {firing} + - program: "{firing} --> +{token}" diff --git a/alchemist-loading/src/test/resources/dsl/yml/08-protelisprogram.yml b/alchemist-loading/src/test/resources/dsl/yml/08-protelisprogram.yml new file mode 100644 index 0000000000..58186119d1 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/08-protelisprogram.yml @@ -0,0 +1,25 @@ +incarnation: protelis + +deployments: + - type: Point + parameters: [ 1.5, 0.5 ] + programs: + - time-distribution: + type: JaktaTimeDistribution + # Recursive construction of other types + parameters: + sense: + type: WeibullTime + parameters: [1.0, 1.0] + deliberate: + type: DiracComb + parameters: 0.1 + act: + type: ExponentialTime + parameters: 1.0 + program: | + 1 + 1 + +#terminate: +# - type: AfterTime +# parameters: [ 0 ] diff --git a/alchemist-loading/src/test/resources/dsl/yml/09-timedistribution.yml b/alchemist-loading/src/test/resources/dsl/yml/09-timedistribution.yml new file mode 100644 index 0000000000..e7a83147e8 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/09-timedistribution.yml @@ -0,0 +1,23 @@ +incarnation: sapere + +network-model: + type: ConnectWithinDistance + parameters: [0.5] + +_send: &grad + - time-distribution: + type: DiracComb + parameters: [0.5] + program: "{token, N, L} --> {token, N, L} *{token, N+#D, L add [#NODE;]}" + - program: > + {token, N, L}{token, def: N2>=N, L2} --> {token, N, L} + +deployments: + type: Grid + parameters: [-5, -5, 5, 5, 0.25, 0.25, 0.1, 0.1] + contents: + in: + type: Rectangle + parameters: [-0.5, -0.5, 1, 1] + molecule: token, 0, [] + programs: *grad \ No newline at end of file diff --git a/alchemist-loading/src/test/resources/dsl/yml/10-environment.yml b/alchemist-loading/src/test/resources/dsl/yml/10-environment.yml new file mode 100755 index 0000000000..120ac55610 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/10-environment.yml @@ -0,0 +1,25 @@ +environment: + type: OSMEnvironment + parameters: ["vcm.pbf", false] # Requires the file vcm.pbf in the classpath! + +incarnation: sapere + +_pools: + - pool: &move + - time-distribution: 15 + type: Event + actions: + - type: ReproduceGPSTrace + parameters: ["gpsTrace", true, "AlignToSimulationTime"] + +deployments: + - type: FromGPSTrace + parameters: [7, "gpsTrace", true, "AlignToSimulationTime"] + programs: + - *move + +terminate: + type: StableForSteps + parameters: + equalIntervals: 5 + checkInterval: 100 diff --git a/alchemist-loading/src/test/resources/dsl/yml/11-monitors.yml b/alchemist-loading/src/test/resources/dsl/yml/11-monitors.yml new file mode 100644 index 0000000000..0d148b8d91 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/11-monitors.yml @@ -0,0 +1,4 @@ +incarnation: sapere + +monitors: + - type: another.location.SimpleMonitor \ No newline at end of file diff --git a/alchemist-loading/src/test/resources/dsl/yml/12-layers.yml b/alchemist-loading/src/test/resources/dsl/yml/12-layers.yml new file mode 100644 index 0000000000..a54eb56c7c --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/12-layers.yml @@ -0,0 +1,19 @@ +incarnation: sapere + +environment: + type: Continuous2DEnvironment + parameters: [] + +layers: + - type: StepLayer + parameters: [2, 2, 100, 0] + molecule: A + - type: StepLayer + parameters: [-2, -2, 0, 100] + molecule: B + +deployments: + - type: Grid + parameters: [-5, -5, 5, 5, 0.25, 0.1, 0.1] + contents: + molecule: a \ No newline at end of file diff --git a/alchemist-loading/src/test/resources/dsl/yml/13-globalreaction.yml b/alchemist-loading/src/test/resources/dsl/yml/13-globalreaction.yml new file mode 100644 index 0000000000..c1e030c6c2 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/13-globalreaction.yml @@ -0,0 +1,9 @@ +incarnation: protelis + +environment: + type: Continuous2DEnvironment + global-programs: + - time-distribution: + type: DiracComb + parameters: [1.0] + type: GlobalTestReaction diff --git a/alchemist-loading/src/test/resources/dsl/yml/14-exporters.yml b/alchemist-loading/src/test/resources/dsl/yml/14-exporters.yml new file mode 100644 index 0000000000..768b46c188 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/14-exporters.yml @@ -0,0 +1,9 @@ +incarnation: protelis +export: + - type: CSVExporter + parameters: + fileNameRoot: "test_export_interval" + interval: 3.0 + data: + - time + - molecule: "default_module:default_program" diff --git a/alchemist-loading/src/test/resources/dsl/yml/15-variables.yml b/alchemist-loading/src/test/resources/dsl/yml/15-variables.yml new file mode 100644 index 0000000000..35eb15c1ac --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/15-variables.yml @@ -0,0 +1,35 @@ +incarnation: sapere + +variables: + rate: &rate + type: GeometricVariable + parameters: [2, 0.1, 10, 9] + size: &size + type: LinearVariable + parameters: [5, 1, 10, 1] + mSize: &mSize + formula: -size + sourceStart: &sourceStart + formula: mSize / 10 + sourceSize: &sourceSize + formula: size / 5 + +network-model: + type: ConnectWithinDistance + parameters: [0.5] + +_send: &grad + - time-distribution: *rate + program: "{token, N, L} --> {token, N, L} *{token, N+#D, L add [#NODE;]}" + - program: > + {token, N, L}{token, def: N2>=N, L2} --> {token, N, L} + +deployments: + type: Grid + parameters: [*mSize, *mSize, *size, *size, 0.25, 0.25, 0.1, 0.1] + contents: + - in: + type: Rectangle + parameters: [*sourceStart, *sourceStart, *sourceSize, *sourceSize] + molecule: token, 0, [] + programs: *grad diff --git a/alchemist-loading/src/test/resources/dsl/yml/16-programsfilters.yml b/alchemist-loading/src/test/resources/dsl/yml/16-programsfilters.yml new file mode 100644 index 0000000000..86e1159b7d --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/16-programsfilters.yml @@ -0,0 +1,22 @@ +incarnation: sapere + +network-model: + type: ConnectWithinDistance + parameters: [0.5] + +deployments: + type: Grid + parameters: [-5, -5, 5, 5, 0.25, 0.25, 0.1, 0.1] + contents: + in: + type: Rectangle + parameters: [-0.5, -0.5, 1, 1] + molecule: token + programs: + - time-distribution: 1 + in: + type: Rectangle + parameters: [ -0.5, -0.5, 1, 1 ] + program: > + {token} --> {firing} + - program: "{firing} --> +{token}" diff --git a/alchemist-loading/src/test/resources/dsl/yml/17-customnodes.yml b/alchemist-loading/src/test/resources/dsl/yml/17-customnodes.yml new file mode 100644 index 0000000000..1c5a5a3706 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/17-customnodes.yml @@ -0,0 +1,7 @@ +incarnation: sapere + +deployments: + type: Circle + parameters: [10, 0, 0, 5] + nodes: + type: it.unibo.alchemist.model.nodes.TestNode diff --git a/alchemist-loading/src/test/resources/dsl/yml/18-properties.yml b/alchemist-loading/src/test/resources/dsl/yml/18-properties.yml new file mode 100644 index 0000000000..5bcb8f217f --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/18-properties.yml @@ -0,0 +1,18 @@ +incarnation: sapere + +deployments: + - type: Circle + parameters: [1000, 0, 0, 15] + properties: + - type: TestNodeProperty + parameters: ["a"] + in: + type: Rectangle + parameters: [-3, -3, 2, 2] + - type: TestNodeProperty + parameters: [ "b" ] + in: + type: Rectangle + parameters: [3, 3, 2, 2] + + diff --git a/alchemist-loading/src/test/resources/dsl/yml/19-performance.yml b/alchemist-loading/src/test/resources/dsl/yml/19-performance.yml new file mode 100644 index 0000000000..74068e27e3 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/19-performance.yml @@ -0,0 +1,87 @@ +incarnation: sapere + +seeds: + scenario: 42 + simulation: 24 + +network-model: + type: ConnectWithinDistance + parameters: [0.5] + +environment: + type: Continuous2DEnvironment + parameters: [] + +variables: + rate: &rate + type: GeometricVariable + parameters: [2, 0.1, 10, 9] + size: &size + type: LinearVariable + parameters: [5, 1, 10, 1] + mSize: &mSize + formula: -size + sourceStart: &sourceStart + formula: mSize / 10 + sourceSize: &sourceSize + formula: size / 5 + +layers: + - type: StepLayer + parameters: [2, 2, 100, 0] + molecule: A + - type: StepLayer + parameters: [-2, -2, 0, 100] + molecule: B + - type: StepLayer + parameters: [0, 0, 50, 50] + molecule: C + +monitors: + - type: another.location.SimpleMonitor + +export: + - type: CSVExporter + parameters: + fileNameRoot: "performance_test" + interval: 1.0 + data: + - time + - molecule: "token" + +deployments: + - type: Circle + parameters: [200, 0, 0, 20] + contents: + - molecule: basemolecule + - in: + type: Rectangle + parameters: [-5, -5, 10, 10] + molecule: centermolecule + programs: + - time-distribution: 1 + program: "{basemolecule} --> {processed}" + - program: "{processed} --> +{basemolecule}" + - type: Grid + parameters: [*mSize, *mSize, *size, *size, 0.25, 0.25, 0.1, 0.1] + contents: + - molecule: gridmolecule + - in: + type: Rectangle + parameters: [*sourceStart, *sourceStart, *sourceSize, *sourceSize] + molecule: token, 0, [] + - in: + type: Rectangle + parameters: [-2, -2, 4, 4] + molecule: filteredmolecule + programs: + - time-distribution: *rate + program: "{token, N, L} --> {token, N, L} *{token, N+#D, L add [#NODE;]}" + - program: > + {token, N, L}{token, def: N2>=N, L2} --> {token, N, L} + - time-distribution: 0.5 + in: + type: Rectangle + parameters: [-1, -1, 2, 2] + program: "{filteredmolecule} --> {active}" + diff --git a/alchemist-loading/src/test/resources/dsl/yml/20-move.yml b/alchemist-loading/src/test/resources/dsl/yml/20-move.yml new file mode 100644 index 0000000000..fa8cb62689 --- /dev/null +++ b/alchemist-loading/src/test/resources/dsl/yml/20-move.yml @@ -0,0 +1,22 @@ +incarnation: sapere +environment: { type: OSMEnvironment } +network-model: { type: ConnectWithinDistance, parameters: [1000] } +_venice_lagoon: &lagoon + [[45.2038121, 12.2504425], [45.2207426, 12.2641754], [45.2381516, 12.2806549], + [45.2570053, 12.2895813], [45.276336, 12.2957611], [45.3029049, 12.2991943], + [45.3212544, 12.3046875], [45.331875, 12.3040009], [45.3453893, 12.3040009], + [45.3502151, 12.3156738], [45.3622776, 12.3232269], [45.3719259, 12.3300934], + [45.3830193, 12.3348999], [45.395557, 12.3445129], [45.3998964, 12.3300934], + [45.4018249, 12.3136139], [45.4105023, 12.3122406], [45.4167685, 12.311554], + [45.4278531, 12.3012543], [45.4408627, 12.2902679], [45.4355628, 12.2772217], + [45.4206242, 12.2703552], [45.3994143, 12.2744751], [45.3738553, 12.2676086], + [45.3579354, 12.2614288], [45.3429763, 12.2497559], [45.3198059, 12.2408295], + [45.2975921, 12.2346497], [45.2802014, 12.2408295], [45.257972, 12.233963], + [45.2038121, 12.2504425]] +deployments: + type: Polygon + parameters: [500, *lagoon] + programs: + - time-distribution: 10 + type: Event + actions: { type: BrownianMove, parameters: [0.0005]} \ No newline at end of file diff --git a/alchemist-test/build.gradle.kts b/alchemist-test/build.gradle.kts index 1fb653fa15..e3d168e50f 100644 --- a/alchemist-test/build.gradle.kts +++ b/alchemist-test/build.gradle.kts @@ -20,6 +20,7 @@ import Libs.alchemist plugins { id("kotlin-jvm-convention") + id("com.google.devtools.ksp") } dependencies { @@ -31,6 +32,8 @@ dependencies { implementation(alchemist("implementationbase")) implementation(alchemist("physics")) runtimeOnly(libs.bundles.testing.runtimeOnly) + implementation(libs.ksp) + ksp(project(":alchemist-dsl-processor")) } publishing.publications { diff --git a/buildSrc/src/main/kotlin/kotlin-jvm-convention.gradle.kts b/buildSrc/src/main/kotlin/kotlin-jvm-convention.gradle.kts index 0344280917..7f6a04b4f3 100644 --- a/buildSrc/src/main/kotlin/kotlin-jvm-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/kotlin-jvm-convention.gradle.kts @@ -50,6 +50,7 @@ java { kotlin { compilerOptions { freeCompilerArgs.add("-Xjvm-default=all") // Enable default methods in Kt interfaces + freeCompilerArgs.add("-Xcontext-parameters") // Enable context receivers } } diff --git a/buildSrc/src/main/kotlin/kotlin-multiplatform-convention.gradle.kts b/buildSrc/src/main/kotlin/kotlin-multiplatform-convention.gradle.kts index 2dbfd674d7..19695e8c43 100644 --- a/buildSrc/src/main/kotlin/kotlin-multiplatform-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/kotlin-multiplatform-convention.gradle.kts @@ -17,6 +17,7 @@ kotlin { jvm { compilerOptions { freeCompilerArgs.add("-Xjvm-default=all") // Enable default methods in Kt interfaces + freeCompilerArgs.add("-Xcontext-parameters") // Enable context receivers } } diff --git a/buildSrc/src/main/kotlin/kotlin-static-analysis-convention.gradle.kts b/buildSrc/src/main/kotlin/kotlin-static-analysis-convention.gradle.kts index 71e9819af5..fbe0d7bbb3 100644 --- a/buildSrc/src/main/kotlin/kotlin-static-analysis-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/kotlin-static-analysis-convention.gradle.kts @@ -24,8 +24,15 @@ private val kmpGenerationTasks get(): TaskCollection = tasks.matching { ta } } +private val kspTasks get(): TaskCollection = tasks.matching { task -> + task.name.startsWith("ksp") +} tasks.withType().configureEach { dependsOn(kmpGenerationTasks) + dependsOn(kspTasks) + excludeGenerated() + exclude("**/build/generated/**") + exclude("**/generated/**") } tasks.allVerificationTasks.configureEach { excludeGenerated() } diff --git a/dokka-cache/com.google.devtools.ksp/symbol-processing-api/2.3.2.list b/dokka-cache/com.google.devtools.ksp/symbol-processing-api/2.3.2.list new file mode 100644 index 0000000000..a3c54d253d --- /dev/null +++ b/dokka-cache/com.google.devtools.ksp/symbol-processing-api/2.3.2.list @@ -0,0 +1,8 @@ +$dokka.format:javadoc-v1 +$dokka.linkExtension:html +$dokka.location:com.google.devtools.ksp.processing/Dependencies.Companion///PointingToDeclaration/com/google/devtools/ksp/processing/Dependencies.Companion.html +com.google.devtools.ksp +com.google.devtools.ksp.processing +com.google.devtools.ksp.symbol +com.google.devtools.ksp.visitor + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e24a5e3c92..396abc67e2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ junit = "6.0.1" konf = "1.1.2" kotest = "6.0.7" kotlin = "2.2.21" +ksp = "2.3.2" kotlinx-coroutines = "1.10.2" ktor = "3.3.3" mockito = "5.21.0" @@ -84,6 +85,8 @@ kotlin-quality-assurance-plugin = "org.danilopianini.gradle-kotlin-qa:org.danilo kotlin-react = { module = "org.jetbrains.kotlin-wrappers:kotlin-react", version.ref = "react" } kotlin-react-dom = { module = "org.jetbrains.kotlin-wrappers:kotlin-react-dom", version.ref = "react" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +ksp-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } +ksp = { module = "com.google.devtools.ksp:symbol-processing", version.ref = "ksp" } kotlinx-atomicfu-runtime = { module = "org.jetbrains.kotlin:kotlinx-atomicfu-runtime", version.ref = "kotlin" } kotlinx-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } @@ -171,6 +174,7 @@ graphql-client = { id = "com.apollographql.apollo3", version.ref = "apollo" } graphql-server = { id = "com.expediagroup.graphql", version.ref = "graphql" } hugo = "io.github.fstaudt.hugo:0.12.0" kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktor = { id = "io.ktor.plugin", version.ref = "ktor" } multiJvmTesting = "org.danilopianini.multi-jvm-test-plugin:4.3.2" publishOnCentral = "org.danilopianini.publish-on-central:9.1.8" diff --git a/kotlinscripting.xml b/kotlinscripting.xml new file mode 100644 index 0000000000..5f88afaa61 --- /dev/null +++ b/kotlinscripting.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 0666afc031..8beb22e618 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ include( "alchemist-api", "alchemist-composeui", "alchemist-cognitive-agents", + "alchemist-dsl-processor", "alchemist-engine", "alchemist-euclidean-geometry", "alchemist-full", diff --git a/site/themes/hugo-theme-relearn b/site/themes/hugo-theme-relearn index 528984250a..9803d5122e 160000 --- a/site/themes/hugo-theme-relearn +++ b/site/themes/hugo-theme-relearn @@ -1 +1 @@ -Subproject commit 528984250a6e83e1852c900f454d5b4078ae4c76 +Subproject commit 9803d5122ebb3276acea823f476e9eb44f607862 diff --git a/src/test/resources/dodgeball.alchemist.kts b/src/test/resources/dodgeball.alchemist.kts new file mode 100644 index 0000000000..bfac78b697 --- /dev/null +++ b/src/test/resources/dodgeball.alchemist.kts @@ -0,0 +1,33 @@ +import it.unibo.alchemist.boundary.dsl.Dsl.incarnation +import it.unibo.alchemist.boundary.dsl.Dsl.simulation + +/* + * Copyright (C) 2010-2025, Danilo Pianini and contributors + * listed, for each module, in the respective subproject's build.gradle.kts file. + * + * This file is part of Alchemist, and is distributed under the terms of the + * GNU General Public License, with a linking exception, + * as described in the file LICENSE in the Alchemist distribution's top directory. + */ +import it.unibo.alchemist.boundary.swingui.monitor.impl.SwingGUI +val incarnation = SAPERE.incarnation() +simulation(incarnation) { + networkModel = ConnectWithinDistance(0.5) + monitors { +SwingGUI(environment)} + deployments{ + deploy (grid(-5.0, -5.0, 5.0, 5.0, + 0.25, 0.25, 0.1, 0.1, 0.0, 0.0)){ + inside(RectangleFilter(-0.5, -0.5, 1.0, 1.0)) { + molecule = "ball" + } + all{ molecule = "{hit, 0}"} + programs { + all{ + timeDistribution("1") + program = "{ball} {hit, N} --> {hit, N + 1} {launching}" + } + all { program = "{launching} --> +{ball}"} + } + } + } +} diff --git a/src/test/resources/dodgeball.yml b/src/test/resources/dodgeball.yml new file mode 100644 index 0000000000..503a1237c0 --- /dev/null +++ b/src/test/resources/dodgeball.yml @@ -0,0 +1,14 @@ +incarnation: sapere +network-model: { type: ConnectWithinDistance, parameters: [0.5] } +deployments: + type: Grid + parameters: [-5.0, -5.0, 5.0, 5.0, 0.25, 0.25, 0.1, 0.1, 0.0, 0.0] # A perturbed grid of devices + contents: + - molecule: "{hit, 0}" # Everywhere, no one has been hit + - in: { type: Rectangle, parameters: [-0.5, -0.5, 1, 1] } # Inside this shape... + molecule: ball # ...every node has a ball + programs: + - time-distribution: 1 # This is a frequency, time distribution type is left to the incarnation + # 'program' specs are passed down to the incarnation for being interpreted as reactions + program: "{ball} {hit, N} --> {hit, N + 1} {launching}" # If hit, count the hit + - program: "{launching} --> +{ball}" # As soon as possible, throw the ball to a neighbor \ No newline at end of file