Nel progetto mi sono occupato della costruzione del modello del World
e tutte le componenti interne:
MovementStrategy
, Edge
e Node
, puntando su immutabilità, funzioni pure e testabilità.
Per quanto riguarda la parte grafica mi sono occupato di tutta la parte di rendering del World
, che comprende il rendering dei Node
e del colore di questi ultimi in base agli infetti e ai morti, il rendering degli Edge
e del colore in base
alla tipologia.
Ho curato l’integrazione tra modello e interfaccia, assicurandomi che ogni aggiornamento dello stato di World
si
riflettesse con precisione e fluidità sul rendering della view.
Per ottimizzare il sistema di rendering, ho limitato il ridisegno dell’interfaccia ai soli elementi modificati, evitando ridisegni completi e migliorando le performance.
In aggiunta a tutto quanto già descritto, ho progettato e implementato l’intero sistema di movimento delle persone all’interno del world.
i file di cui mi sono occupato sono:
Edge
, EdgeExtensions
, MovementComputation
, MovementStrategy
, Node
, Types
, World
, WorldFactory
, WorldValidator
, WorldConnectivity
,
EdgeConfigurationFactory
, EdgeMovementConfig
, GlobalLogic
, LocalPercentageLogic
, MovementEvent
, ChangeNodesInWorldEvent
, MovementLogic
, MovementLogicWithEdgeCapacityAndPercentage
,
MovementStrategyDispatcher
, MovementStrategyLogic
, StaticLogic
, CircularLayout
, DefaultNodeViewFactory
, EdgeLayer
, EdgeUpdater
, GraphLayout
, LivePosition
,
NodeLayer
, NodeView
, NodeViewFactory
, UpdatableWorldView
, WorldRenderer
, WorldView
, ConsoleSimulationView
Le parti più importanti del mio lavoro sono:
World
MovementStrategy
, MovementLogic
Ho definito le entità fondamentali:
Node
: con builder e validazioni (popolazione, infetti, morti)Edge
: con ordinamento lessicografico (per consentire l’uguaglianza di edge unidirezionali) e tipologie (Air
, Land
, Sea
)MovementStrategy
: un trait per definire le strategie di movimento.
Edge
e Node
sono corredate di extension methods (infectedPercentage, increasePopulation, edgeId, getMapEdges, ecc.) per semplificare ogni operazione nel Mondo.Il World
è l’insieme di queste tre Entità:
case class World private (
nodes: Map[NodeId, Node],
edges: Map[EdgeId, Edge],
movements: Map[MovementStrategy, Percentage]
):
Ho modellato il World
come una private case class con costruttore privato e un metodo apply nel companion object per due ragioni principali:
Quindi per garantire un modello coerente ho introdotto WorldValidator
che si occupa di validare la creazione del World
nel metodo apply
del companion object.
Questo approccio consente di mantenere il codice pulito e facilmente testabile, poiché tutte le regole di validazione sono concentrate in un’unica classe.
object World:
def apply(
nodes: Map[NodeId, Node],
edges: Map[EdgeId, Edge],
movements: Map[MovementStrategy, Percentage]
): World =
WorldValidator.validateEdges(nodes, edges)
WorldValidator.validateMovements(movements)
new World(nodes, edges, movements)
Gestire nodes, edges e movements nella World usando mappe (con ID come chiavi) offre diversi vantaggi, soprattutto in un’ottica di programmazione funzionale e gestione di stati immutabili:
Per quanto riguarda movements i vantaggi di gestirlo come mappa Strategy
-> Percentage
sono:
extension (edge: Edge)
def edgeId: EdgeId =
if edge.nodeA < edge.nodeB then s"${edge.nodeA}-${edge.nodeB}-${edge.typology}" else s"${edge.nodeB}-${edge.nodeA}-${edge.typology}"
extension (edges: Iterable[Edge])
def getMapEdges: Map[EdgeId, Edge] =
edges.map(edge => edge.edgeId -> edge).toMap
Sono stati implementati questi due extension methods per aiutare l’utilizzatore di World
a creare la mappa di Edge
passando al costruttore di World
la Lista di Edge
delegando la computazione degli ID e la creazione della mappa a questi metodi.
Mi sono occupato poi del sistema di movimento, progettato per gestire lo spostamento delle persone tra i Node in maniera modulare, testabile e perfettamente aderente ai principi della programmazione funzionale. Il sistema è responsabile di determinare, a ogni tick di simulazione, quali individui si spostano, in che quantità, e verso quali destinazioni, aggiornando immutabilmente lo stato del World.
Il cuore architetturale di questo sottosistema è costituito da tre componenti principali:
È un sealed trait che rappresenta le diverse strategie di movimento disponibili, ovvero i comportamenti astratti che la popolazione può adottare.
sealed trait MovementStrategy
case object Static extends MovementStrategy
case object LocalPercentageMovement extends MovementStrategy
case object GlobalLogicMovement extends MovementStrategy
Il World conosce esclusivamente queste strategie come intenzioni astratte di comportamento: non ha alcuna visibilità sull’implementazione delle logiche che le realizzano.
movements: Map[MovementStrategy, Percentage]
Questa rappresenta la dichiarazione delle intenzioni del sistema: il World sa quali comportamenti usare e in che proporzione, ma non conosce le implementazioni operative di questi comportamenti.
compute
.
Le classi concrete che lo implementano (StaticLogic
, LocalPercentageLogic
, GlobalLogic
)
definiscono come una strategia genera effettivamente movimenti tra nodi.Sono queste classi che:
PeopleMovement
Importante: il World non conosce queste logiche. Solo gli eventi e i moduli operativi (es. MovementComputation) ne sono a conoscenza e le invocano quando serve.
Per ogni strategia di movemento viene passato come parametro al metodo compute il generatore casuale Random. Questo approccio, basato sull’iniezione delle dipendenze, consente di controllare esattamente il comportamento nei test, ad esempio usando un generatore Random inizializzato con un seed noto (new Random(42)), oppure mockando nextDouble() per ottenere valori deterministici. In questo modo, ogni MovementLogic può essere testata in maniera riproducibile e priva di effetti collaterali.
trait MovementLogic:
def compute(
world: World,
percent: Percentage,
rng: scala.util.Random
): Iterable[PeopleMovement]
Esempio di test in cui ho mockato il generatore casuale per ottenere un comportamento prevedibile:
val fixedRandom: Random = new Random:
override def nextDouble(): Double = 0.1
val result: Seq[PeopleMovement] = GlobalLogic.compute(world, 1.0, fixedRandom).toList
object MovementStrategyDispatcher:
def logicFor(strategy: MovementStrategy): MovementLogic = strategy match
case LocalPercentageMovement => LocalPercentageLogic
case GlobalLogicMovement => GlobalLogic
case Static => StaticLogic
A questo si affianca un modulo MovementStrategyLogic
:
object MovementStrategyLogic:
def compute(
world: World,
strategy: MovementStrategy,
percentage: Percentage,
rng: scala.util.Random
): Iterable[PeopleMovement] =
MovementStrategyDispatcher.logicFor(strategy).compute(world, percentage, rng)
Questo permette al sistema di passare da una dichiarazione astratta di strategia a una logica concreta da eseguire.
L’intero processo è orchestrato dal metodo MovementComputation.computeAllMovements
, che:
def computeAllMovements(world: World, rng: scala.util.Random): MovementResult =
world.movements.foldLeft(MovementResult(world.nodes, List.empty)) {
case (MovementResult(currentNodes, accMoves), (strategy, percent)) =>
val newMoves = MovementStrategyLogic.compute(world, strategy, percent, rng)
val updatedNodes = applyMovements(world.modifyNodes(currentNodes), newMoves).nodes
MovementResult(updatedNodes, accMoves ++ newMoves)
}
Il World
dichiara che movimento deve accadere (strategie + percentuali), e solo gli eventi e i moduli operativi determinano come avviene il movimento.
Questa scelta progettuale consente:
Per aggiungere una nuova strategia è sufficiente dichiarare la nuova MovementStrategy
, fornire un’implementazione di MovementLogic
, e registrarla nel dispatcher
. Il World resta completamente isolato da questo processo.
Durante l’applicazione dei movimenti (applyMovements), viene utilizzata una distribuzione ipergeometrica per stimare, in modo realistico, quanti degli individui in movimento siano infetti. Questo modello simula un’estrazione casuale senza rimpiazzo da una popolazione composta da individui sani e infetti, mantenendo la proporzione di partenza.
private def sampleInfected(node: Node, amount: Int): Int =
val hgd = new HypergeometricDistribution(
node.population,
node.infected,
amount
)
hgd.sample()
Nota: questa gestione basata sulla distribuzione ipergeometrica è stata realizzata in collaborazione con il collega Matteo Susca.
Come detto in precedenza, sono state implementate due differenti strategie di movimento:
GlobalLogic
e LocalPercentageLogic
.
Qui mi concentrerò sulla prima, che è quella più complessa e interessante. Questa logica considera l’intera struttura del World e la configurazione degli edge per determinare in modo intelligente e probabilistico dove e quanta popolazione spostare.
Per ogni nodo con popolazione maggiore di zero, GlobalLogic
esamina tutti gli edge aperti che lo connettono ad altri nodi.
La quantità di persone da spostare viene calcolata come percentuale della popolazione del nodo, in base al parametro ricevuto.
Tuttavia, non tutti i movimenti vengono generati indiscriminatamente: entra in gioco una logica di filtro basata su capacità e probabilità.
Ogni edge può avere una capacità massima di transito e una probabilità base di movimento definite per tipologia (Air, Land, Sea). La decisione finale se spostare o meno le persone viene presa confrontando un valore casuale con una probabilità calcolata dinamicamente. Questa probabilità finale è ottenuta moltiplicando la probabilità base dell’edge per il rapporto tra il numero di persone da spostare (che è in proporzione alla popolazione del nodo) e la popolazione media dei nodi nel mondo. In questo modo, i nodi con una popolazione sopra la media sono più propensi a generare movimento, mentre quelli più piccoli lo fanno meno frequentemente.
private def getFinalProbability(
edgeTypology: EdgeType,
toMove: Int,
avgPopulation: Int
): Double =
edgeMovementConfig.probability.getOrElse(
edgeTypology,
0.0
) * (toMove.toDouble / avgPopulation)
private def shouldMove(
edge: Edge,
nodeId: NodeId,
rng: scala.util.Random,
avgPopulation: Int,
toMove: Int
): Boolean =
val finalProbability = getFinalProbability(
edge.typology,
toMove,
avgPopulation
)
edge.other(nodeId).isDefined && !edge.isClose && rng.nextDouble() < finalProbability
Questo comportamento rispecchia fenomeni reali: ad esempio, città densamente popolate generano più traffico di persone, mentre le aree isolate o scarsamente abitate rimangono più statiche.
Nota: Le logiche della GlobalLogic sono state realizzate in collaborazione con il collega Matteo Susca.