Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
474c30f
chore(deps): add rrmxmx-kt dependency
angelacorte Apr 11, 2025
28257e7
feat: add nelder mead method logic
angelacorte Apr 11, 2025
4ef2e98
feat: add nelder mead launcher
angelacorte Apr 11, 2025
e7a90aa
style: fix according to ktlint
angelacorte Apr 11, 2025
fa08ed1
Merge branch 'master' into feat/nelder-mead-launcher
angelacorte Apr 14, 2025
def2ed3
docs: add missing documentation
angelacorte Apr 14, 2025
79f7336
chore(deps): add kotlinx serialization
angelacorte Apr 15, 2025
4cc1909
fix: put result into json file and take as parameter the path and file
angelacorte Apr 15, 2025
c9a5e82
test: add test class for nelder mead launcher
angelacorte Apr 15, 2025
3ebddb6
feat: add nelder mead launcher yaml
angelacorte Apr 15, 2025
1bfd7c2
perf: improve the network diameter evaluation performance (#4288)
angelacorte Apr 14, 2025
17bdc3d
chore(release): 42.0.8 [skip ci]
semantic-release-bot Apr 14, 2025
f207cc6
ci: do not install winget (preinstalled on windows-2025) (#4387)
DanySK Apr 14, 2025
485bab8
chore(deps): update dependency com.google.guava:guava to v33.4.8-jre …
renovate[bot] Apr 14, 2025
806535a
chore(build): update the javadoc.io cache
DanySK Apr 14, 2025
d0ff1be
chore(deps): update plugin gitsemver to v5 (#4390)
renovate[bot] Apr 14, 2025
8c4fed1
chore(deps): update graphql to v8.6.0 (minor) (#4389)
renovate[bot] Apr 15, 2025
8fb34e5
chore(build): update the javadoc.io cache
DanySK Apr 15, 2025
1f9bf95
ci(deps): update danysk/build-check-deploy-gradle-action action to v3…
renovate[bot] Apr 15, 2025
8ea9192
chore(core-deps): update protelis to v18.0.4 (patch) (#4393)
renovate[bot] Apr 15, 2025
f3a7e25
chore(build): update the javadoc.io cache
DanySK Apr 15, 2025
f1ca95c
chore(deps): update react to v2025.4.11-19.1.0 (patch) (#4394)
renovate[bot] Apr 15, 2025
15e44f6
chore(build): update the javadoc.io cache
DanySK Apr 15, 2025
f4697ef
chore(deps): update plugin multijvmtesting to v3.4.0 (#4396)
renovate[bot] Apr 17, 2025
e123b5c
chore(deps): update dependency org.danilopianini:jirf to v0.4.32 (#4391)
renovate[bot] Apr 17, 2025
38e6546
chore(build): update the javadoc.io cache
DanySK Apr 17, 2025
656ff62
chore(deps): update danysk/makepkg docker tag to v1.1.44 (#4398)
renovate[bot] Apr 18, 2025
e91db85
chore(build): update the javadoc.io cache
DanySK Apr 18, 2025
0271e59
chore(deps): update react to v2025.4.12-19.1.0 (patch) (#4397)
renovate[bot] Apr 18, 2025
60e0ca7
chore(build): update the javadoc.io cache
DanySK Apr 18, 2025
5e3fa2c
chore(deps): update react to v2025.4.13-19.1.0 (patch) (#4399)
renovate[bot] Apr 18, 2025
f827953
chore(build): update the javadoc.io cache
DanySK Apr 18, 2025
15ed357
chore(deps): update plugin hugo to v0.10.0 (#4400)
renovate[bot] Apr 19, 2025
8a845de
revert(deps): revert update graphql to v8.6.0 (#4389), as it is leadi…
DanySK Apr 19, 2025
dcc7511
chore(build): update the javadoc.io cache
DanySK Apr 19, 2025
ac05446
chore(deps): update react to v2025.4.14-19.1.0 (patch) (#4402)
renovate[bot] Apr 19, 2025
8fb5f59
chore(build): update the javadoc.io cache
DanySK Apr 19, 2025
4230738
test(graphql): convert `NodeSurrogateTest` to kotlin.test and set a t…
DanySK Apr 19, 2025
98c117f
test(graphql): convert `EnvironmentSurrogateTest` to kotlin.test and …
DanySK Apr 19, 2025
4246ed5
test(graphql): convert `PositionSurrogateTest` to kotlin.test and set…
DanySK Apr 19, 2025
80dd86b
test(graphql): convert `ConcentrationSurrogateTest` to kotlin.test an…
DanySK Apr 20, 2025
c28fd26
chore(release): 42.0.9 [skip ci]
semantic-release-bot Apr 20, 2025
9255b77
chore(deps): update react to v2025.4.15-19.1.0 (patch) (#4408)
renovate[bot] Apr 20, 2025
cd27bae
chore(build): update the javadoc.io cache
DanySK Apr 20, 2025
34a3fb1
chore(deps): update react to v2025.4.16-19.1.0 (patch) (#4409)
renovate[bot] Apr 21, 2025
5d432f7
chore(build): update the javadoc.io cache
DanySK Apr 21, 2025
8c1bf49
chore(deps): update dependency io.arrow-kt:arrow-core to v2.1.0 (#4410)
renovate[bot] Apr 21, 2025
2d67753
chore(build): update the javadoc.io cache
DanySK Apr 21, 2025
6584781
chore(deps): update dependency org.apache.commons:commons-collections…
renovate[bot] Apr 22, 2025
bbde9f7
chore(build): update the javadoc.io cache
DanySK Apr 22, 2025
4c9d00a
chore(deps): update dependency org.danilopianini.gradle-java-qa:org.d…
renovate[bot] Apr 22, 2025
f9e54d0
feat: add SystemEnvVariable to load environment variable (#4401)
cric96 Apr 23, 2025
75ac362
chore(release): 42.1.0 [skip ci]
semantic-release-bot Apr 23, 2025
48698bc
chore(deps): update dependency com.google.code.gson:gson to v2.13.1 (…
renovate[bot] Apr 24, 2025
8307c05
chore(build): update the javadoc.io cache
DanySK Apr 24, 2025
806abe4
chore(deps): update dependency org.danilopianini:gson-extras to v3.3.…
renovate[bot] Apr 24, 2025
2b9c864
chore(build): update the javadoc.io cache
DanySK Apr 24, 2025
f1041d3
chore(deps): update node.js to 22.15 (#4416)
renovate[bot] Apr 25, 2025
e5107ca
ci(deps): update actions/download-artifact action to v4.3.0 (#4415)
renovate[bot] Apr 25, 2025
4dfb2d8
chore(deps): update plugin com.gradle.develocity to v4.0.1 (#4418)
renovate[bot] Apr 25, 2025
f878cdc
chore(deps): update danysk/makepkg docker tag to v1.1.45 (#4417)
renovate[bot] Apr 25, 2025
fc2b46d
chore(deps): update plugin multijvmtesting to v3.4.1 (#4420)
renovate[bot] Apr 25, 2025
ccc73fd
chore(deps): update plugin gitsemver to v5.1.2 (#4419)
renovate[bot] Apr 25, 2025
78c0ca4
chore(deps): update dependency org.danilopianini.gradle-kotlin-qa:org…
renovate[bot] Apr 25, 2025
be322ee
chore(build): update the javadoc.io cache
DanySK Apr 25, 2025
3704d02
chore(deps): update dependency org.danilopianini.gradle-java-qa:org.d…
renovate[bot] Apr 25, 2025
7f8f66f
chore(deps): update dependency gradle to v8.14 (#4421)
renovate[bot] Apr 25, 2025
5b2d7df
chore(deps): update plugin org.danilopianini.gradle-pre-commit-git-ho…
renovate[bot] Apr 26, 2025
065d6ab
chore(deps): update dependency org.danilopianini.gradle-java-qa:org.d…
renovate[bot] Apr 28, 2025
05a82d9
chore(deps): update dependency io.mockk:mockk to v1.14.2 (#4430)
renovate[bot] Apr 29, 2025
69fc16b
chore(build): update the javadoc.io cache
DanySK Apr 29, 2025
923824d
chore(deps): update dependency semantic-release-preconfigured-convent…
renovate[bot] Apr 29, 2025
98bc10f
chore(deps): update dependency io.arrow-kt:arrow-core to v2.1.1 (#4429)
renovate[bot] Apr 29, 2025
0582375
chore(build): update the javadoc.io cache
DanySK Apr 29, 2025
4b4f373
chore(deps): update react to v2025.4.17-19.1.0 (patch) (#4432)
renovate[bot] Apr 29, 2025
92705e2
chore(build): update the javadoc.io cache
DanySK Apr 29, 2025
3671c60
chore(deps): update dependency scalafmt to v3.9.5 (#4431)
renovate[bot] Apr 29, 2025
a618e38
chore(deps): update react to v2025.4.18-19.1.0 (patch) (#4434)
renovate[bot] Apr 30, 2025
d9f4fb0
chore(build): update the javadoc.io cache
DanySK Apr 30, 2025
75d91d6
chore(deps): update react to v2025.5.0-19.1.0 (minor) (#4435)
renovate[bot] May 1, 2025
06fee99
chore(build): update the javadoc.io cache
DanySK May 1, 2025
427a036
chore(deps): update react to v2025.5.1-19.1.0 (patch) (#4437)
renovate[bot] May 1, 2025
f820666
chore(build): update the javadoc.io cache
DanySK May 1, 2025
3a1dfcb
chore(deps): update dependency semantic-release-preconfigured-convent…
renovate[bot] May 2, 2025
22b2abb
chore(deps): update danysk/makepkg docker tag to v1.1.46 (#4438)
renovate[bot] May 2, 2025
13cfcf4
chore(deps): update react to v2025.5.2-19.1.0 (patch) (#4439)
renovate[bot] May 2, 2025
ef59168
chore(build): update the javadoc.io cache
DanySK May 2, 2025
cd3500f
chore(deps): update plugin publishoncentral to v8.0.7 (#4440)
renovate[bot] May 3, 2025
9001a47
ci(deps): update danysk/build-check-deploy-gradle-action action to v3…
renovate[bot] May 3, 2025
ddaedb7
chore(deps): update dependency scalafmt to v3.9.6 (#4443)
renovate[bot] May 5, 2025
0441020
chore(deps): update ktor monorepo to v3.1.3 (patch) (#4442)
renovate[bot] May 5, 2025
890de44
chore(build): update the javadoc.io cache
DanySK May 5, 2025
bb08504
chore(deps): update dependency org.jetbrains.compose to v1.8.0 (#4445)
renovate[bot] May 6, 2025
05c6fba
chore(build): update the javadoc.io cache
DanySK May 6, 2025
0af61e6
chore(deps): update react to v2025.5.3-19.1.0 (patch) (#4446)
renovate[bot] May 7, 2025
6a8fd2b
chore(build): update the javadoc.io cache
DanySK May 7, 2025
32503bf
Merge branch 'master' into feat/nelder-mead-launcher
angelacorte May 7, 2025
8a18eff
refactor: change target function into zoom
angelacorte May 7, 2025
e4b87f1
Merge branch 'master' into feat/nelder-mead-launcher
angelacorte May 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions alchemist-loading/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ dependencies {
implementation(libs.kasechange)
implementation(libs.kotlin.reflect)
implementation(libs.kotlin.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.rrmxmx)
implementation(libs.mongodb)
implementation(libs.snakeyaml)
implementation(libs.caffeine)

runtimeOnly(libs.groovy.jsr223)
runtimeOnly(kotlin("scripting-jsr223"))
Expand All @@ -50,6 +53,7 @@ dependencies {
testImplementation(libs.appdirs)
testImplementation(libs.caffeine)
testImplementation(libs.embedmongo)
testImplementation(libs.kotlin.test)
testRuntimeOnly(incarnation("sapere"))
testRuntimeOnly(incarnation("protelis"))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package it.unibo.alchemist.boundary

import com.github.benmanes.caffeine.cache.Caffeine
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future

/**
* Nelder-Mead optimization method.
* Given an initial [simplex], this method iteratively refines the simplex to minimize a given [objective] function.
* The method is suitable for optimizing functions that are continuous but not differentiable.
* Other parameters are:
* - [alpha]: Reflection coefficient (standard value is 1.0);
* - [gamma]: Expansion coefficient (standard value is 2.0);
* - [rho]: Contraction coefficient (standard value is 0.5);
* - [sigma]: Shrink coefficient (standard value is 0.5);
* - [maxIterations]: Maximum number of iterations;
* - [tolerance]: Termination condition (small variation in function values).
*/
class NelderMeadMethod(
val simplex: List<Vertex>,
private val maxIterations: Int,
private val tolerance: Double,
private val alpha: Double, // Reflection coefficient
private val gamma: Double, // Expansion coefficient
private val rho: Double, // Contraction coefficient
private val sigma: Double, // Shrink coefficient
executorService: ExecutorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()),
private val objective: (List<Double>) -> Future<Double>,
) {

private val cache = Caffeine.newBuilder().executor(executorService)
.build<List<Double>, Future<Double>> { coordinates ->
objective(coordinates)
}

/**
* Apply the Nelder-Mead optimization method to the given [simplex] and [objective] function.
*/
fun optimize(): Vertex {
require(simplex.isNotEmpty()) { "The initial simplex must not be empty" }
val dimensions = simplex.first().size
require(dimensions > 0) { "The number of dimensions must be greater than 0" }
require(simplex.size == dimensions + 1) {
"The vertices of the initial simplex must be one more than the number of dimensions"
}
require(simplex.all { it.size == dimensions }) {
"All vertices of the initial simplex must have the same number of dimensions"
}
var symplexUpdated = simplex
repeat(maxIterations) {
// Sort simplex by function values
val sortedSimplex = symplexUpdated
.map { it to cache[it.valuesToList()] }
.sortedBy { it.second.get() }
.map { it.first }
val bestVertex: Vertex = sortedSimplex.first()
val worstVertex: Vertex = sortedSimplex.last()
val secondWorstVertex: Vertex = sortedSimplex[simplex.size - 2]
val bestValue = cache[bestVertex.valuesToList()]
val worstValues = worstVertex.valuesToList()
// Compute centroid (excluding worst point)
val centroid =
DoubleArray(dimensions) { index ->
sortedSimplex.dropLast(1).sumOf { it[index] } / (sortedSimplex.size - 1)
}.toList()
// Reflections
val reflected: List<Double> = centroid.mapCentroid(alpha, worstValues)
val reflectedValue = cache[reflected]
require(reflectedValue.get().isFinite() && !reflectedValue.get().isNaN()) {
"Invalid objective function return value for reflection with $reflected = $reflectedValue.\n" +
"Check the objective function implementation, the result should be a finite number."
}
// check (!reflectedValue.get().isFinite()) {
// error("Invalid objective function return value for reflection")
// }
val newSimplex = when {
reflectedValue < bestValue -> { // expansion
val expanded: List<Double> = centroid.mapCentroid(gamma, reflected)
when {
cache[expanded] < reflectedValue -> sortedSimplex.updateLastVertex(expanded)
else -> sortedSimplex.updateLastVertex(reflected)
}
}
reflectedValue < cache[secondWorstVertex.valuesToList()] -> { // accept reflection
sortedSimplex.updateLastVertex(reflected)
}
else -> { // contraction
val contracted = when {
reflectedValue < cache[worstValues] -> centroid.mapCentroid(rho, reflected)
else -> centroid.mapCentroid(rho, worstValues)
}
when {
cache[contracted] < cache[worstValues] -> sortedSimplex.updateLastVertex(contracted)
else -> { // shrink simplex
sortedSimplex.map { vertex ->
Vertex(
vertex.keys().associateWith { key ->
// Find the index corresponding to the key
val index = bestVertex.keys().indexOf(key)
val oldValue = bestVertex[index]
oldValue + sigma * (vertex[index] - oldValue) // Apply shrink transformation
},
)
}
}
}
}
}
// Check termination condition (small variation in function values)
val functionValues = newSimplex.map { cache[it.valuesToList()].get() }
symplexUpdated = newSimplex
val maxDiff = functionValues.maxOrNull()!! - functionValues.minOrNull()!!
if (maxDiff < tolerance) return symplexUpdated.first()
}
return symplexUpdated.first()
}

private fun List<Double>.mapCentroid(coefficient: Double, values: List<Double>): List<Double> =
mapIndexed { index, value -> value + coefficient * (values[index] - value) }

private fun List<Vertex>.updateLastVertex(newVertex: List<Double>): List<Vertex> = mapIndexed { index, vertex ->
if (index == size - 1) {
Vertex(vertex.keys().associateWith { newVertex[vertex.keys().indexOf(it)] })
} else {
vertex
}
}

/**
* This companion object contains the comparison operators for [Future] of [Double] objects and [Double]s.
*/
companion object {
/**
* Compares two [Future] of [Double] objects.
*/
operator fun Future<Double>.compareTo(other: Future<Double>): Int = get().compareTo(other.get())

/**
* Compares a [Future] of [Double] object with a [Double].
*/
operator fun Future<Double>.compareTo(other: Double): Int = get().compareTo(other)

/**
* Compares a [Double] with a [Future] of [Double] object.
*/
operator fun Double.compareTo(other: Future<Double>): Int = compareTo(other.get())
}
}

/**
* A vertex of the simplex in the Nelder-Mead method.
*/
@JvmInline
value class Vertex(private val vertex: Map<String, Double>) {
/**
* Returns amount of dimensions of the vertex.
*/
val size: Int
get() = vertex.size

/**
* Returns the values of the vertex as a list.
*/
fun valuesToList(): List<Double> = vertex.values.toList()

/**
* Returns the keys of the vertex as a set.
*/
fun keys(): Set<String> = vertex.keys

/**
* Returns the value of the vertex at the given index.
*/
operator fun get(index: Int) = valuesToList()[index]

/**
* Returns the key of the vertex at the given index.
*/
fun keyAt(index: Int) = vertex.keys.elementAt(index)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package it.unibo.alchemist.boundary.launchers

import it.unibo.alchemist.boundary.Launcher
import it.unibo.alchemist.boundary.Loader
import it.unibo.alchemist.boundary.NelderMeadMethod
import it.unibo.alchemist.boundary.Variable
import it.unibo.alchemist.boundary.Vertex
import it.unibo.alchemist.model.Environment
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import org.danilopianini.rrmxmx.RrmxmxRandom
import org.danilopianini.rrmxmx.RrmxmxRandom.Companion.DEFAULT_SEED
import java.io.File
import java.time.LocalDateTime
import java.util.concurrent.Callable
import java.util.concurrent.ConcurrentLinkedDeque
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.Int.Companion.MAX_VALUE

/**
* Alchemist launcher for the Nelder-Mead method optimization.
* This launcher optimize the [variables] of the simulation by a given [objectiveFunction].
* The optimization is done by the Nelder-Mead method, which is a derivative-free optimization algorithm.
* The optimization is done in parallel for a given number of [repetitions] of the [seedName] and [maxIterations].
* It will stop when the [tolerance] is reached.
* The [alpha], [gamma], [rho] and [sigma] parameters are the standard values for the Nelder-Mead method.
* The result of the optimization is written into `[outputPath]` folder in [outputFileName] as a JSON file.
*/
class NelderMeadLauncher
@JvmOverloads
constructor(
private val outputPath: String = "data",
private val outputFileName: String = "nelderMead",
private val objectiveFunction: Environment<*, *>.() -> Double,
private val variables: List<String> = emptyList(),
private val seedName: String,
private val repetitions: Int = 1,
private val maxIterations: Int = MAX_VALUE,
private val seed: ULong = DEFAULT_SEED,
private val tolerance: Double = 1e-6,
private val alpha: Double = 1.0, // standard value for the reflection in Nelder-Mead method
private val gamma: Double = 2.0, // standard value for the expansion in Nelder-Mead method
private val rho: Double = 0.5, // standard value for the contraction in Nelder-Mead method
private val sigma: Double = 0.5, // standard value for the shrink in Nelder-Mead method
) : Launcher {
@OptIn(ExperimentalSerializationApi::class)
@Synchronized
override fun launch(loader: Loader) {
require(loader.variables.isNotEmpty() || variables.isNotEmpty()) {
"No variables found, can not optimize anything."
}
val simplexVertices: List<Map<String, Double>> = generateSymplexVertices(loader.variables)
val seeds: List<Int> =
loader.variables[seedName]
?.stream()
?.map {
check(it is Number) { "Seed must be a number. $it is not." }
it.toInt()
}?.toList()
?.take(repetitions) ?: listOf(repetitions)
val executorID = AtomicInteger(0)
val executor =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()) {
Thread(it, "Alchemist Nelder Mead worker #${executorID.getAndIncrement()}")
}
val errorQueue = ConcurrentLinkedDeque<Throwable>()
loader.executeWithNelderMead(simplexVertices, executor) { vertex ->
val futureValues = seeds.map<Int, Future<Double>> { currentSeed ->
// associate keys to vertex values
val simulationParameters = variables
.associateWith { vertex[variables.indexOf(it)] } + (seedName to currentSeed)
check(loader.variables.keys == simulationParameters.keys) {
"Variables do not match: ${loader.variables.keys} != ${simulationParameters.keys}"
}
executor.submit<Double>(
Callable {
val simulation = loader.getWith<Any?, Nothing>(simulationParameters)
simulation.play()
simulation.run()
if (simulation.error.isPresent) {
errorQueue.add(simulation.error.get())
}
objectiveFunction(simulation.environment)
},
)
}
ForkJoinPool.commonPool().submit(
Callable {
futureValues.map { it.get() }.average()
},
)
}.also { result ->
// if not exists create the directory
File(outputPath).mkdirs()
val outputFile = File(
"$outputPath${File.separator}${outputFileName}" +
"_${LocalDateTime.now().toString().replace(":", "-")}.json",
)
val outputContent = buildJsonObject {
put(seedName, JsonPrimitive(repetitions))
put("variables", buildJsonObject {
variables.forEach { variable ->
put(variable, JsonPrimitive(result[variable]))
}
})
}.toString()
outputFile.writeText(outputContent)
}
executor.shutdown()
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.HOURS)
if (errorQueue.isNotEmpty()) {
throw errorQueue.reduce { previous, other ->
previous.addSuppressed(other)
previous
}
}
}

private fun Loader.executeWithNelderMead(
simplexVertices: List<Map<String, Double>>,
executorService: ExecutorService,
executeFunction: (List<Double>) -> Future<Double>,
): Map<String, Double> = NelderMeadMethod(
simplex = simplexVertices.map { Vertex(it) },
maxIterations = maxIterations,
tolerance = tolerance,
alpha = alpha,
gamma = gamma,
rho = rho,
sigma = sigma,
executorService = executorService,
objective = executeFunction,
).optimize()
.let { result ->
this@NelderMeadLauncher.variables.associateWith {
result[this@NelderMeadLauncher.variables.indexOf(it)]
}
}

private fun generateSymplexVertices(loaderVariables: Map<String, Variable<*>>): List<Map<String, Double>> {
val randomGenerator = RrmxmxRandom(seed)
val instances: Map<String, ClosedRange<Double>> =
variables.associateWith { varName ->
val variable = loaderVariables.getValue(varName)
val allValues =
variable
.stream()
.map {
check(it is Number) {
"All variables to optimize must be Numbers. $varName has value $it."
}
it.toDouble()
}.toList()
allValues.min()..allValues.max()
}
return (0..variables.size).map {
instances.mapValues { (_, range) -> randomGenerator.nextDouble(range.start, range.endInclusive) }
}
}
}
Loading
Loading