Spaces:
Sleeping
Sleeping
Commit ·
9e62f55
1
Parent(s): 52d378a
first commit
Browse files- .gitignore +5 -0
- Dockerfile +25 -0
- Readme.MD +104 -0
- app.py +764 -0
- requirements.txt +6 -0
- src/__init__.py +0 -0
- src/config.py +143 -0
- src/config/engine_config.json +18 -0
- src/engine/__init__.py +0 -0
- src/engine/crossover.py +29 -0
- src/engine/evolution.py +344 -0
- src/engine/mutation.py +66 -0
- src/engine/selection.py +272 -0
- src/models/__init__.py +0 -0
- src/models/individual.py +76 -0
- src/problems/__init__.py +0 -0
- src/problems/my_problem.py +93 -0
- src/utils/__init__.py +0 -0
- src/utils/demand_processing.py +60 -0
- src/utils/generator.py +153 -0
- src/utils/health.py +104 -0
- src/utils/helpers.py +92 -0
- src/utils/hf_storage.py +138 -0
- src/utils/visualization.py +33 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
documenti/
|
| 3 |
+
.venv
|
| 4 |
+
benchmarks
|
| 5 |
+
data/
|
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN useradd -m -u 1000 user
|
| 6 |
+
|
| 7 |
+
COPY requirements.txt .
|
| 8 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 9 |
+
|
| 10 |
+
COPY . .
|
| 11 |
+
|
| 12 |
+
RUN chown -R user:user /app
|
| 13 |
+
|
| 14 |
+
USER user
|
| 15 |
+
|
| 16 |
+
ENV HOME=/home/user \
|
| 17 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 18 |
+
STREAMLIT_SERVER_PORT=7860 \
|
| 19 |
+
STREAMLIT_SERVER_ADDRESS=0.0.0.0 \
|
| 20 |
+
STREAMLIT_SERVER_ENABLE_CORS=false \
|
| 21 |
+
STREAMLIT_SERVER_HEADLESS=true
|
| 22 |
+
|
| 23 |
+
EXPOSE 7860
|
| 24 |
+
|
| 25 |
+
CMD ["streamlit", "run", "app.py"]
|
Readme.MD
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: GeneticWFM
|
| 3 |
+
emoji: 🧬
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: Evolutionary AI solver for workforce management
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# 🧬 AI Workforce Scheduler
|
| 14 |
+
|
| 15 |
+
Questo progetto è un prototipo avanzato per l'ottimizzazione della pianificazione dei turni di lavoro (**Workforce Management**) basato su **Algoritmi Genetici** e accelerato tramite **Numba**. È progettato specificamente per gestire scenari complessi nel settore **BPO (Business Process Outsourcing)**, garantendo la copertura del fabbisogno operativo e il rispetto dei vincoli di legge e contrattuali.
|
| 16 |
+
|
| 17 |
+
## 🚀 Caratteristiche Tecniche
|
| 18 |
+
|
| 19 |
+
* **Motore Evolutivo ad Alte Prestazioni**: Utilizzo di **Numba (`njit`)** per la compilazione Just-In-Time e il calcolo parallelo della fitness, permettendo di gestire popolazioni numerose su centinaia di dipendenti in pochi secondi.
|
| 20 |
+
* **Architettura Modulare**: Suddivisione netta tra motore genetico, modelli di dominio e logica di business per una facile scalabilità.
|
| 21 |
+
* **Gestione Vincoli Avanzata**:
|
| 22 |
+
* Pause **VDT (Video Terminalista)** gestite dinamicamente tramite maschere di turno.
|
| 23 |
+
* Rispetto del **Mix Settimanale** (Giorni Lavorati vs Riposi).
|
| 24 |
+
* Gestione di vincoli **Hard** (Assenze, turni fissi) e **Soft** (Preferenze orarie).
|
| 25 |
+
* **Vettorializzazione NumPy**: Tutta la logica di calcolo è ottimizzata per operare su matrici, riducendo al minimo i cicli Python nel core dell'algoritmo.
|
| 26 |
+
|
| 27 |
+
## 📂 Struttura del Progetto
|
| 28 |
+
|
| 29 |
+
Il progetto è organizzato secondo i principi della programmazione modulare:
|
| 30 |
+
|
| 31 |
+
```
|
| 32 |
+
.
|
| 33 |
+
├── app.py # UI & Sandbox Evolutiva (Streamlit)
|
| 34 |
+
├── src/
|
| 35 |
+
│ ├── config.py # Manager degli Iperparametri (Cascade L0/L1/L2)
|
| 36 |
+
│ ├── config/
|
| 37 |
+
│ │ └── engine_config.json # Parametri dell'Ambiente Evolutivo (L1)
|
| 38 |
+
│ ├── engine/ # Motore Evolutivo e Operatori Genetici
|
| 39 |
+
│ │ ├── crossover.py # Ricombinazione Genica (Uniform Crossover vettorializzato)
|
| 40 |
+
│ │ ├── evolution.py # Ciclo Generazionale (Main JIT Solver)
|
| 41 |
+
│ │ ├── mutation.py # Perturbazione Stocastica (Mutazione Ibrida Day-Swap/Time-Shift)
|
| 42 |
+
│ │ └── selection.py # Pressione Selettiva (Tournament) e Funzione di Fitness (Loss)
|
| 43 |
+
│ ├── models/
|
| 44 |
+
│ │ └── individual.py # Rappresentazione Cromosomica e mapping Genotipo/Fenotipo (VDT)
|
| 45 |
+
│ ├── problems/
|
| 46 |
+
│ │ └── my_problem.py # Definizione dello Spazio di Ricerca e dei Vincoli Ambientali
|
| 47 |
+
│ └── utils/
|
| 48 |
+
│ ├── demand_processing.py # Allineamento dei Target di Fitness (Time-series sanitization)
|
| 49 |
+
│ ├── generator.py # Generatore di Ambienti di Simulazione (Mock Scenarios)
|
| 50 |
+
│ ├── health.py # Metriche di Dinamica Popolazionale (IBE, Distanza di Hamming)
|
| 51 |
+
│ ├── helpers.py # Utility di decodifica dei tratti e structural analysis
|
| 52 |
+
│ ├── hf_storage.py # Conservazione del Pool Genetico e I/O su HF Datasets
|
| 53 |
+
│ └── visualization.py # Proiezione Fenotipica e rendering vettoriale dei risultati
|
| 54 |
+
├── Dockerfile # Containerizzazione per HF Spaces
|
| 55 |
+
├── requirements.txt # Dipendenze del progetto
|
| 56 |
+
└── README.md # Documentazione
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## 🛠️ Installazione e Setup
|
| 60 |
+
|
| 61 |
+
### Prerequisiti
|
| 62 |
+
* Python 3.10 o superiore.
|
| 63 |
+
* Si consiglia l'uso di un ambiente virtuale (`venv`).
|
| 64 |
+
|
| 65 |
+
### Passaggi
|
| 66 |
+
1. **Clona il repository**:
|
| 67 |
+
```bash
|
| 68 |
+
git clone <repository-url>
|
| 69 |
+
cd workforce-scheduler
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
2. **Configura l'ambiente**:
|
| 73 |
+
```bash
|
| 74 |
+
python -m venv .venv
|
| 75 |
+
source .venv/bin/activate # Su Windows: .venv\\Scripts\\activate
|
| 76 |
+
pip install -r requirements.txt
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
## 💻 Modalità d'Uso
|
| 80 |
+
|
| 81 |
+
### Avvio Dashboard
|
| 82 |
+
Per gestire le attività e avviare l'ottimizzazione tramite l'interfaccia web:
|
| 83 |
+
```bash
|
| 84 |
+
streamlit run app.py
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
## ⚙️ Sistema di Configurazione
|
| 88 |
+
L'algoritmo adotta una gerarchia di parametri a tre livelli:
|
| 89 |
+
1. **System Defaults**: Valori di sicurezza definiti nel codice.
|
| 90 |
+
2. **Engine Config** (`src/config/engine_config.json`): Parametri standard per il comportamento dell'algoritmo.
|
| 91 |
+
3. **Activity Config** (`data/activities/{nome}/activity_config.json`): Parametri specifici per la commessa, inclusi i **pesi della fitness**.
|
| 92 |
+
|
| 93 |
+
## 📊 Visualizzazione Risultati
|
| 94 |
+
L'applicazione genera automaticamente:
|
| 95 |
+
* **Matrici di Copertura**: Grafici comparativi tra domanda e staff.
|
| 96 |
+
* **Roster Visuale**: Tabellone dei turni settimanale.
|
| 97 |
+
* **Report Qualità**: Analisi degli slot scoperti.
|
| 98 |
+
|
| 99 |
+
## 🧪 Roadmap e Sviluppi Futuri
|
| 100 |
+
* [ ] Integrazione di modelli di **Deep Learning** per la predizione della domanda.
|
| 101 |
+
* [ ] Implementazione di strategie di mutazione basate su **Reinforcement Learning**.
|
| 102 |
+
* [ ] Export dei roster in formato Excel/CSV.
|
| 103 |
+
|
| 104 |
+
**Sviluppato come prototipo per soluzioni WFM avanzate.**
|
app.py
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import numpy as np
|
| 4 |
+
import matplotlib.pyplot as plt
|
| 5 |
+
import datetime
|
| 6 |
+
import traceback
|
| 7 |
+
from src.config import cfg
|
| 8 |
+
from src.utils.helpers import load_employees_from_json, check_hours_balance, minutes_to_time
|
| 9 |
+
from src.utils.visualization import get_final_coverage_matrix
|
| 10 |
+
from src.utils.health import calculate_ibe, interpret_ibe, analyze_convergence_quality
|
| 11 |
+
from src.utils.demand_processing import sanitize_weekly_demand
|
| 12 |
+
from src.utils.generator import generate_scenario_files
|
| 13 |
+
from src.utils.hf_storage import load_json, save_json, list_activities
|
| 14 |
+
from src.problems.my_problem import process_demand
|
| 15 |
+
from src.engine.evolution import run_genetic_algorithm
|
| 16 |
+
|
| 17 |
+
# --- SETUP INTERFACCIA ---
|
| 18 |
+
st.set_page_config(page_title="AI Workforce Scheduler", layout="wide", page_icon="🧬")
|
| 19 |
+
|
| 20 |
+
# ==============================================================================
|
| 21 |
+
# 1. ROUTING E GESTIONE WORKSPACE (SIDEBAR)
|
| 22 |
+
# ==============================================================================
|
| 23 |
+
st.sidebar.title("🏢 Seleziona Attività")
|
| 24 |
+
|
| 25 |
+
# Recupero dinamicamente la lista dei workspace dal repository remoto (HF Datasets)
|
| 26 |
+
available_activities = list_activities()
|
| 27 |
+
|
| 28 |
+
if not available_activities:
|
| 29 |
+
st.warning("⚠️ Nessuna attività trovata nel Dataset remoto.")
|
| 30 |
+
st.sidebar.info("Utilizza il Generatore per istanziare un nuovo workspace.")
|
| 31 |
+
st.stop()
|
| 32 |
+
|
| 33 |
+
selected_activity = st.sidebar.selectbox("Attività da gestire:", available_activities)
|
| 34 |
+
|
| 35 |
+
st.title(f"Gestione Turni: {selected_activity}")
|
| 36 |
+
st.markdown("Pianificazione intelligente dei turni di lavoro tramite intelligenza artificiale evolutiva.")
|
| 37 |
+
|
| 38 |
+
# Gestione dello stato di sessione: se l'utente cambia attività, forzo il ricaricamento
|
| 39 |
+
# del Singleton di configurazione e pulisco la cache dei risultati precedenti.
|
| 40 |
+
if 'current_activity' not in st.session_state or st.session_state['current_activity'] != selected_activity:
|
| 41 |
+
try:
|
| 42 |
+
cfg.load_configurations(selected_activity)
|
| 43 |
+
st.session_state['current_activity'] = selected_activity
|
| 44 |
+
if 'ga_results' in st.session_state:
|
| 45 |
+
del st.session_state['ga_results']
|
| 46 |
+
except Exception as e:
|
| 47 |
+
st.error(f"Errore caricamento config per {selected_activity}: {e}")
|
| 48 |
+
|
| 49 |
+
if st.sidebar.button("♻️ Ricarica App"):
|
| 50 |
+
st.cache_data.clear()
|
| 51 |
+
st.rerun()
|
| 52 |
+
|
| 53 |
+
# ==============================================================================
|
| 54 |
+
# 2. VIEWPORT & TAB ROUTING
|
| 55 |
+
# ==============================================================================
|
| 56 |
+
tab1, tab2, tab3, tab4, tab5 = st.tabs(["⚙️ Configurazione Attività", "👥 Dipendenti", "📈 Domanda (Fabbisogno)", "🚀 Esecuzione & Risultati", "⚡ Generatore"])
|
| 57 |
+
|
| 58 |
+
# ------------------------------------------------------------------------------
|
| 59 |
+
# TAB 1: PARAMETRI DI SISTEMA E BUSINESS (CONFIG L2)
|
| 60 |
+
# ------------------------------------------------------------------------------
|
| 61 |
+
with tab1:
|
| 62 |
+
st.header("⚙️ Parametri Generali")
|
| 63 |
+
|
| 64 |
+
config_filename = "activity_config.json"
|
| 65 |
+
config_data = load_json(selected_activity, config_filename)
|
| 66 |
+
|
| 67 |
+
if config_data:
|
| 68 |
+
client_settings = config_data.get('client_settings', {})
|
| 69 |
+
curr_slot = client_settings.get('planning_slot_minutes', 30)
|
| 70 |
+
|
| 71 |
+
# --- 1. SETUP GRIGLIA TEMPORALE ---
|
| 72 |
+
st.subheader("🗓️ Calendario Operativo")
|
| 73 |
+
st.caption("Definisci la granularità della pianificazione e gli orari di apertura del servizio.")
|
| 74 |
+
|
| 75 |
+
new_slot = st.selectbox(
|
| 76 |
+
"Granularità Pianificazione (minuti)",
|
| 77 |
+
options=[15, 30, 60],
|
| 78 |
+
index=[15, 30, 60].index(curr_slot) if curr_slot in [15, 30, 60] else 1,
|
| 79 |
+
key=f"{selected_activity}_slot_select"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
with st.expander("Modifica Orari di Apertura/Chiusura Settimanali"):
|
| 83 |
+
st.info("I turni generati dall'algoritmo non supereranno mai i limiti impostati qui. Seleziona 'Chiuso' per impedire la pianificazione in un giorno specifico.")
|
| 84 |
+
|
| 85 |
+
# Costruisco i form per le regole orarie. Uso un dict temporaneo per raccogliere gli input.
|
| 86 |
+
day_names = ["Lunedì", "Martedì", "Mercoledì", "Giovedì", "Venerdì", "Sabato", "Domenica"]
|
| 87 |
+
user_schedule = {}
|
| 88 |
+
current_hours = config_data.get('operating_hours', {})
|
| 89 |
+
default_rule = current_hours.get('default', "09:00-18:00")
|
| 90 |
+
exceptions = current_hours.get('exceptions', {})
|
| 91 |
+
|
| 92 |
+
for i, day_name in enumerate(day_names):
|
| 93 |
+
day_idx_str = str(i)
|
| 94 |
+
rule = exceptions.get(day_idx_str, default_rule)
|
| 95 |
+
is_closed_init = (rule == "CLOSED")
|
| 96 |
+
start_init, end_init = datetime.time(8, 0), datetime.time(20, 0)
|
| 97 |
+
|
| 98 |
+
if not is_closed_init:
|
| 99 |
+
try:
|
| 100 |
+
s_str, e_str = rule.split('-')
|
| 101 |
+
sh, sm = map(int, s_str.split(':'))
|
| 102 |
+
eh, em = map(int, e_str.split(':'))
|
| 103 |
+
start_init, end_init = datetime.time(sh, sm), datetime.time(eh, em)
|
| 104 |
+
except: pass
|
| 105 |
+
|
| 106 |
+
c1, c2, c3, c4 = st.columns([1, 1, 1, 1])
|
| 107 |
+
c1.markdown(f"**{day_name}**")
|
| 108 |
+
unique_key = f"{selected_activity}_day_{i}"
|
| 109 |
+
is_closed = c2.checkbox("Chiuso", value=is_closed_init, key=f"{unique_key}_closed")
|
| 110 |
+
start_t = c3.time_input("Apertura", value=start_init, key=f"{unique_key}_start", disabled=is_closed, step=900)
|
| 111 |
+
end_t = c4.time_input("Chiusura", value=end_init, key=f"{unique_key}_end", disabled=is_closed, step=900)
|
| 112 |
+
user_schedule[i] = {"closed": is_closed, "start": start_t, "end": end_t}
|
| 113 |
+
st.divider()
|
| 114 |
+
|
| 115 |
+
# --- 2. PESI DELLA LOSS FUNCTION ---
|
| 116 |
+
st.subheader("⚖️ Obiettivi di Business (Pesi)")
|
| 117 |
+
st.caption("Istruisci l'algoritmo su cosa è più importante. Valori più alti indicano una priorità maggiore.")
|
| 118 |
+
weights = config_data.get('weights', {})
|
| 119 |
+
wk = f"{selected_activity}_weights"
|
| 120 |
+
|
| 121 |
+
# Espongo i pesi per permettere il fine-tuning degli obiettivi di business direttamente da UI
|
| 122 |
+
col_w1, col_w2 = st.columns(2)
|
| 123 |
+
with col_w1:
|
| 124 |
+
new_under = st.number_input("Peso Understaffing (Evita Buchi)", value=weights.get('understaffing', 1000.0), step=100.0, key=f"{wk}_under")
|
| 125 |
+
new_over = st.number_input("Peso Overstaffing (Evita Eccessi)", value=weights.get('overstaffing', 10.0), step=5.0, key=f"{wk}_over")
|
| 126 |
+
with col_w2:
|
| 127 |
+
new_homo = st.number_input("Peso Equità (Bilancia i Carichi)", value=weights.get('homogeneity', 20.0), step=5.0, key=f"{wk}_homo")
|
| 128 |
+
new_soft = st.number_input("Peso Preferenze (Accontenta gli Operatori)", value=weights.get('soft_preference', 50.0), step=5.0, key=f"{wk}_soft")
|
| 129 |
+
|
| 130 |
+
# --- 3. HYPER-PARAMETRI DEL MOTORE GENETICO ---
|
| 131 |
+
st.subheader("🧬 Motore Genetico")
|
| 132 |
+
st.caption("Configura le prestazioni dell'intelligenza artificiale.")
|
| 133 |
+
gen_params = config_data.get('genetic_params', {})
|
| 134 |
+
|
| 135 |
+
c_gen1, c_gen2 = st.columns(2)
|
| 136 |
+
new_pop = c_gen1.number_input("Popolazione (Soluzioni per generazione)", value=gen_params.get('population_size', 500), step=100)
|
| 137 |
+
new_gen = c_gen2.number_input("Generazioni (Cicli di apprendimento)", value=gen_params.get('generations', 200), step=50)
|
| 138 |
+
|
| 139 |
+
# Nascondo i parametri avanzati dell'engine JIT in un expander per mantenere la UI pulita
|
| 140 |
+
with st.expander("🔧 Parametri Avanzati Algoritmo (Fine Tuning)"):
|
| 141 |
+
st.warning("⚠️ Modifica questi valori solo se sai cosa stai facendo. I default sono ottimizzati per scenari BPO standard. Valori errati possono bloccare l'algoritmo.")
|
| 142 |
+
ac1, ac2, ac3 = st.columns(3)
|
| 143 |
+
|
| 144 |
+
p_mut = ac1.slider("Mutation Rate", 0.0, 1.0, gen_params.get('mutation_rate', 0.4))
|
| 145 |
+
p_cross = ac2.slider("Crossover Rate", 0.0, 1.0, gen_params.get('crossover_rate', 0.85))
|
| 146 |
+
p_elite = ac3.number_input("Elitism Rate (Protezione Migliori)", 0.0, 0.5, gen_params.get('elitism_rate', 0.02), step=0.01, format="%.2f")
|
| 147 |
+
|
| 148 |
+
st.markdown("---")
|
| 149 |
+
bc1, bc2, bc3 = st.columns(3)
|
| 150 |
+
p_tourn = bc1.number_input("Tournament Size", 2, 20, gen_params.get('tournament_size', 5))
|
| 151 |
+
p_heur = bc2.slider("Heuristic Init Rate (Partenza Intelligente)", 0.0, 1.0, gen_params.get('heuristic_rate', 0.8))
|
| 152 |
+
p_noise = bc3.number_input("Heuristic Noise (Rumore iniziale)", 0.0, 1.0, gen_params.get('heuristic_noise', 0.2), step=0.1)
|
| 153 |
+
|
| 154 |
+
st.markdown("---")
|
| 155 |
+
p_split = st.slider("Guided Mutation Split (Swap Giorno vs Cambio Orario)", 0.0, 1.0, gen_params.get('guided_mutation_split', 0.4))
|
| 156 |
+
|
| 157 |
+
st.markdown("---")
|
| 158 |
+
st.subheader("🧪 Analisi Pressione Evolutiva (IBE)")
|
| 159 |
+
|
| 160 |
+
# Calcolo live dell'IBE (il mio indicatore custom) per dare un feedback
|
| 161 |
+
# immediato sulla bontà dei parametri genetici scelti dall'utente.
|
| 162 |
+
curr_ibe = calculate_ibe(
|
| 163 |
+
pop_size=int(new_pop), generations=int(new_gen),
|
| 164 |
+
p_cross=p_cross, p_mut=p_mut, p_heur=p_heur, p_elite=p_elite
|
| 165 |
+
)
|
| 166 |
+
status_msg, delta_color = interpret_ibe(curr_ibe)
|
| 167 |
+
|
| 168 |
+
hc1, hc2, hc3 = st.columns([1, 1.5, 1])
|
| 169 |
+
hc1.metric(label="IBE Score", value=f"{curr_ibe:,.0f}".replace(",", "."), delta="Target: 1k-3k", delta_color=delta_color)
|
| 170 |
+
|
| 171 |
+
if "OTTIMALE" in status_msg: hc2.success(f"**Stato:** {status_msg}")
|
| 172 |
+
elif "STALLO" in status_msg: hc2.warning(f"**Stato:** {status_msg}")
|
| 173 |
+
else: hc2.error(f"**Stato:** {status_msg}")
|
| 174 |
+
|
| 175 |
+
with hc3.expander("Cos'è l'IBE?"):
|
| 176 |
+
st.caption("""
|
| 177 |
+
**Indice di Bilanciamento Evolutivo (IBE)**
|
| 178 |
+
È un indicatore di salute delle impostazioni che hai inserito.
|
| 179 |
+
Misura l'equilibrio tra la capacità dell'AI di esplorare nuove soluzioni (Mutazioni)
|
| 180 |
+
e la tendenza a sfruttare quelle già trovate (Euristiche/Elitismo).
|
| 181 |
+
- **Troppo basso:** L'AI si "accontenta" subito della prima soluzione mediocre trovata.
|
| 182 |
+
- **Troppo alto:** L'AI continua a cercare a caso senza mai focalizzarsi su un piano stabile.
|
| 183 |
+
""")
|
| 184 |
+
|
| 185 |
+
# --- 4. PERSISTENZA I/O ---
|
| 186 |
+
st.divider()
|
| 187 |
+
if st.button("💾 Calcola e Salva Configurazione", type="primary"):
|
| 188 |
+
|
| 189 |
+
# Costruisco la mappa delle regole
|
| 190 |
+
daily_rules_map = {}
|
| 191 |
+
min_h, max_h = 24, 0
|
| 192 |
+
for i in range(7):
|
| 193 |
+
d = user_schedule[i]
|
| 194 |
+
if d['closed']: daily_rules_map[str(i)] = "CLOSED"
|
| 195 |
+
else:
|
| 196 |
+
s_str, e_str = d['start'].strftime("%H:%M"), d['end'].strftime("%H:%M")
|
| 197 |
+
daily_rules_map[str(i)] = f"{s_str}-{e_str}"
|
| 198 |
+
if d['start'].hour < min_h: min_h = d['start'].hour
|
| 199 |
+
if d['end'].hour > max_h: max_h = d['end'].hour + (1 if d['end'].minute > 0 else 0)
|
| 200 |
+
|
| 201 |
+
# Trick per ottimizzare il payload JSON: calcolo l'orario più frequente
|
| 202 |
+
# e lo imposto come 'default', salvando gli altri giorni come 'exceptions'.
|
| 203 |
+
from collections import Counter
|
| 204 |
+
vals = list(daily_rules_map.values())
|
| 205 |
+
most_common = Counter(vals).most_common(1)[0][0]
|
| 206 |
+
final_hours = {"default": most_common, "exceptions": {}}
|
| 207 |
+
for k, v in daily_rules_map.items():
|
| 208 |
+
if v != most_common: final_hours["exceptions"][k] = v
|
| 209 |
+
|
| 210 |
+
# Aggiornamento dell'oggetto configurazione
|
| 211 |
+
if 'client_settings' not in config_data: config_data['client_settings'] = {}
|
| 212 |
+
config_data['client_settings']['planning_slot_minutes'] = new_slot
|
| 213 |
+
config_data['client_settings']['day_start_hour'] = int(min_h) if min_h < 24 else 8
|
| 214 |
+
config_data['client_settings']['day_end_hour'] = int(max_h) if max_h > 0 else 20
|
| 215 |
+
|
| 216 |
+
config_data['operating_hours'] = final_hours
|
| 217 |
+
config_data['weights'] = {"understaffing": new_under, "overstaffing": new_over, "homogeneity": new_homo, "soft_preference": new_soft}
|
| 218 |
+
|
| 219 |
+
config_data['genetic_params'] = {
|
| 220 |
+
"population_size": int(new_pop), "generations": int(new_gen),
|
| 221 |
+
"mutation_rate": p_mut, "crossover_rate": p_cross, "elitism_rate": p_elite,
|
| 222 |
+
"tournament_size": int(p_tourn), "heuristic_rate": p_heur,
|
| 223 |
+
"heuristic_noise": p_noise, "guided_mutation_split": p_split
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
# Push sul cloud
|
| 227 |
+
save_json(selected_activity, config_filename, config_data)
|
| 228 |
+
cfg.load_configurations(selected_activity)
|
| 229 |
+
st.success("✅ Configurazione salvata e sincronizzata con successo.")
|
| 230 |
+
|
| 231 |
+
else:
|
| 232 |
+
st.error("Payload di configurazione mancante dal Dataset.")
|
| 233 |
+
|
| 234 |
+
# ------------------------------------------------------------------------------
|
| 235 |
+
# TAB 2: ANAGRAFICA E VINCOLI (HR MASTER DATA)
|
| 236 |
+
# ------------------------------------------------------------------------------
|
| 237 |
+
with tab2:
|
| 238 |
+
|
| 239 |
+
emp_filename = "employees.json"
|
| 240 |
+
emp_data = load_json(selected_activity, emp_filename)
|
| 241 |
+
|
| 242 |
+
col_header, col_pie = st.columns([3, 1])
|
| 243 |
+
|
| 244 |
+
with col_header:
|
| 245 |
+
st.header("👥 Gestione Personale")
|
| 246 |
+
st.caption("Gestisci i profili contrattuali, le regole di fairness settimanale e inserisci ferie, permessi o vincoli di orario.")
|
| 247 |
+
|
| 248 |
+
if emp_data is not None:
|
| 249 |
+
with col_pie:
|
| 250 |
+
# Rendering del mix contrattuale. Uso matplotlib con patch alpha=0.0
|
| 251 |
+
# per avere uno sfondo trasparente che si adatti al tema di Streamlit.
|
| 252 |
+
df_stats = pd.DataFrame(emp_data)
|
| 253 |
+
if not df_stats.empty and 'contract' in df_stats.columns:
|
| 254 |
+
counts = df_stats['contract'].value_counts()
|
| 255 |
+
fig_pie, ax_pie = plt.subplots(figsize=(1.5, 1.5))
|
| 256 |
+
colors = ['#3498db', '#e74c3c', '#f1c40f', '#9b59b6']
|
| 257 |
+
wedges, texts, autotexts = ax_pie.pie(
|
| 258 |
+
counts, autopct='%1.0f%%', startangle=90, colors=colors[:len(counts)],
|
| 259 |
+
textprops={'fontsize': 5, 'weight': 'bold', 'color': 'white'}, pctdistance=0.7
|
| 260 |
+
)
|
| 261 |
+
ax_pie.axis('equal')
|
| 262 |
+
fig_pie.patch.set_alpha(0.0)
|
| 263 |
+
leg = ax_pie.legend(
|
| 264 |
+
wedges, counts.index, title="Contratti", loc="center left",
|
| 265 |
+
bbox_to_anchor=(1, 0, 0.5, 1), fontsize=5, title_fontsize=6, frameon=False, labelcolor='white'
|
| 266 |
+
)
|
| 267 |
+
leg.get_title().set_color("white")
|
| 268 |
+
leg.get_title().set_fontweight("bold")
|
| 269 |
+
st.pyplot(fig_pie, width='content')
|
| 270 |
+
|
| 271 |
+
st.divider()
|
| 272 |
+
|
| 273 |
+
col_list, col_editor = st.columns([1, 2])
|
| 274 |
+
|
| 275 |
+
with col_list:
|
| 276 |
+
st.subheader("Anagrafica Risorse")
|
| 277 |
+
emp_ids = [e['id'] for e in emp_data]
|
| 278 |
+
selected_id = st.selectbox("Seleziona il Dipendente da ispezionare:", emp_ids, index=0 if emp_ids else None)
|
| 279 |
+
|
| 280 |
+
st.divider()
|
| 281 |
+
if st.button("💾 Salva Modifiche Anagrafica", type="primary"):
|
| 282 |
+
save_json(selected_activity, emp_filename, emp_data)
|
| 283 |
+
st.success("Anagrafica aggiornata in Cloud.")
|
| 284 |
+
|
| 285 |
+
# Costruisco la preview tabellare
|
| 286 |
+
summary = []
|
| 287 |
+
for e in emp_data:
|
| 288 |
+
mix = e.get('shift_mix', {"WORK": 5, "OFF": 2})
|
| 289 |
+
summary.append({"ID": e['id'], "Contratto": e['contract'], "Mix": f"{mix.get('WORK',5)}W/{mix.get('OFF',2)}O"})
|
| 290 |
+
st.dataframe(summary, hide_index=True, width='stretch')
|
| 291 |
+
|
| 292 |
+
with col_editor:
|
| 293 |
+
# Editor del singolo dipendente: permette di iniettare override
|
| 294 |
+
# hard/soft/absence direttamente sull'oggetto prima del salvataggio.
|
| 295 |
+
if selected_id:
|
| 296 |
+
emp_record = next((e for e in emp_data if e['id'] == selected_id), None)
|
| 297 |
+
if emp_record:
|
| 298 |
+
st.subheader(f"✏️ Proprietà: {emp_record['id']}")
|
| 299 |
+
|
| 300 |
+
c1, c2 = st.columns(2)
|
| 301 |
+
emp_record['id'] = c1.text_input("ID Dipendente", value=emp_record['id'])
|
| 302 |
+
ct = emp_record.get('contract', 'FT40')
|
| 303 |
+
ct_idx = ["FT40", "PT30", "PT20"].index(ct) if ct in ["FT40", "PT30", "PT20"] else 0
|
| 304 |
+
emp_record['contract'] = c2.selectbox("Tipologia Contratto", ["FT40", "PT30", "PT20"], index=ct_idx)
|
| 305 |
+
|
| 306 |
+
cc1, cc2 = st.columns(2)
|
| 307 |
+
emp_record['work_hours'] = cc1.number_input("Ore Lavorative Giornaliere", value=float(emp_record.get('work_hours', 8.0)))
|
| 308 |
+
emp_record['break_duration'] = cc2.number_input("Minuti di Pausa/Pranzo", value=int(emp_record.get('break_duration', 0)))
|
| 309 |
+
|
| 310 |
+
st.markdown("---")
|
| 311 |
+
|
| 312 |
+
st.subheader("📅 Regole di Fairness Settimanale")
|
| 313 |
+
st.caption("Imposta quanti giorni questa risorsa deve lavorare rispetto a quanti giorni deve riposare nella settimana.")
|
| 314 |
+
curr_mix = emp_record.get('shift_mix', {"WORK": 5, "OFF": 2})
|
| 315 |
+
|
| 316 |
+
cm1, cm2 = st.columns(2)
|
| 317 |
+
w_days = cm1.number_input("Target Giorni Lavorativi", min_value=1, max_value=7, value=int(curr_mix.get("WORK", 5)))
|
| 318 |
+
o_days = cm2.number_input("Target Giorni di Riposo", min_value=0, max_value=6, value=int(curr_mix.get("OFF", 2)))
|
| 319 |
+
|
| 320 |
+
emp_record['shift_mix'] = {"WORK": w_days, "OFF": o_days}
|
| 321 |
+
st.info(f"L'algoritmo cercherà in tutti i modi di programmare esattamente {w_days} giorni di lavoro e {o_days} di riposo. Le violazioni verranno penalizzate nel calcolo finale.")
|
| 322 |
+
|
| 323 |
+
st.markdown("---")
|
| 324 |
+
st.subheader("🔒 Vincoli Operativi (Assenze e Permessi)")
|
| 325 |
+
st.caption("Aggiungi ferie, malattie o turni fissi inamovibili.")
|
| 326 |
+
|
| 327 |
+
constraints = emp_record.get('constraints', {})
|
| 328 |
+
day_map = {0: "Lunedì", 1: "Martedì", 2: "Mercoledì", 3: "Giovedì", 4: "Venerdì", 5: "Sabato", 6: "Domenica"}
|
| 329 |
+
|
| 330 |
+
if constraints:
|
| 331 |
+
cons_view = []
|
| 332 |
+
for d, r in constraints.items():
|
| 333 |
+
cons_view.append({"Giorno": day_map.get(int(d), d), "Tipo": r['type'].upper(), "Valore/Motivo": r.get('start_time', r.get('reason',''))})
|
| 334 |
+
st.table(pd.DataFrame(cons_view))
|
| 335 |
+
|
| 336 |
+
to_del = st.selectbox("Seleziona Giorno da sbloccare", options=list(constraints.keys()), format_func=lambda x: day_map.get(int(x), x))
|
| 337 |
+
if st.button("🗑️ Rimuovi Vincolo"):
|
| 338 |
+
del emp_record['constraints'][to_del]
|
| 339 |
+
st.rerun()
|
| 340 |
+
|
| 341 |
+
with st.expander("➕ Aggiungi una nuova regola per questo dipendente"):
|
| 342 |
+
ac1, ac2 = st.columns(2)
|
| 343 |
+
add_d = ac1.selectbox("Giorno della settimana", range(7), format_func=lambda x: day_map[x])
|
| 344 |
+
add_t = ac2.selectbox("Tipologia di regola", ["absence", "hard", "soft"], format_func=lambda x: "Assenza" if x=="absence" else ("Turno Obbligato (Hard)" if x=="hard" else "Preferenza Oraria (Soft)"))
|
| 345 |
+
|
| 346 |
+
new_rule = {"type": add_t}
|
| 347 |
+
if add_t == "absence":
|
| 348 |
+
new_rule["reason"] = st.selectbox("Motivo Assenza", ["FERIE", "MALATTIA", "PERMESSO"])
|
| 349 |
+
else:
|
| 350 |
+
t_val = st.time_input("Orario di inzio desiderato").strftime("%H:%M")
|
| 351 |
+
new_rule["start_time"] = t_val
|
| 352 |
+
|
| 353 |
+
if st.button("Conferma Inserimento"):
|
| 354 |
+
if 'constraints' not in emp_record: emp_record['constraints'] = {}
|
| 355 |
+
emp_record['constraints'][str(add_d)] = new_rule
|
| 356 |
+
st.rerun()
|
| 357 |
+
else:
|
| 358 |
+
st.error("Payload anagrafico mancante o corrotto.")
|
| 359 |
+
|
| 360 |
+
# ------------------------------------------------------------------------------
|
| 361 |
+
# TAB 3: DEMAND TIME-SERIES (FABBISOGNO)
|
| 362 |
+
# ------------------------------------------------------------------------------
|
| 363 |
+
with tab3:
|
| 364 |
+
st.header("📈 Time-Series Fabbisogno Operativo")
|
| 365 |
+
st.caption("Visualizza e modifica la curva di traffico o il numero di operatori richiesti per ogni frazione oraria.")
|
| 366 |
+
|
| 367 |
+
demand_filename = "demand.json"
|
| 368 |
+
conf = load_json(selected_activity, "activity_config.json")
|
| 369 |
+
raw_demand = load_json(selected_activity, demand_filename)
|
| 370 |
+
|
| 371 |
+
if conf:
|
| 372 |
+
sett = conf.get('client_settings', {})
|
| 373 |
+
start_h = sett.get('day_start_hour', 8)
|
| 374 |
+
end_h = sett.get('day_end_hour', 20)
|
| 375 |
+
slot_min = sett.get('planning_slot_minutes', 30)
|
| 376 |
+
|
| 377 |
+
current_conf_for_alignment = {'client_settings': {'day_start_hour': start_h, 'day_end_hour': end_h, 'planning_slot_minutes': slot_min}}
|
| 378 |
+
|
| 379 |
+
total_min = (end_h - start_h) * 60
|
| 380 |
+
num_slots = int(total_min / slot_min)
|
| 381 |
+
|
| 382 |
+
# Allineamento dinamico della time-series: gestisco i cambi di granularità oraria
|
| 383 |
+
# troncando o paddando la matrice tramite l'helper apposito.
|
| 384 |
+
if raw_demand:
|
| 385 |
+
sanitized_list = sanitize_weekly_demand(raw_demand, current_conf_for_alignment)
|
| 386 |
+
target_demand = np.array(sanitized_list)
|
| 387 |
+
else:
|
| 388 |
+
target_demand = np.ones((7, num_slots), dtype=int) * 5
|
| 389 |
+
|
| 390 |
+
days = ["Lun", "Mar", "Mer", "Gio", "Ven", "Sab", "Dom"]
|
| 391 |
+
|
| 392 |
+
time_labels = []
|
| 393 |
+
curr_m = start_h * 60
|
| 394 |
+
end_m = end_h * 60
|
| 395 |
+
while curr_m < end_m:
|
| 396 |
+
h = int(curr_m // 60)
|
| 397 |
+
m = int(curr_m % 60)
|
| 398 |
+
time_labels.append(f"{h:02d}:{m:02d}")
|
| 399 |
+
curr_m += slot_min
|
| 400 |
+
|
| 401 |
+
x = np.arange(len(time_labels))
|
| 402 |
+
|
| 403 |
+
day_idx = st.selectbox("Ispeziona Giorno:", range(7), format_func=lambda x: days[x])
|
| 404 |
+
|
| 405 |
+
# Rendering vettoriale del profilo di carico
|
| 406 |
+
fig, ax = plt.subplots(figsize=(10, 3))
|
| 407 |
+
daily_curve = target_demand[day_idx]
|
| 408 |
+
|
| 409 |
+
ax.plot(x, daily_curve, color='#e74c3c', linestyle='--', marker='o', markersize=3, label="Target Staff (Richiesto)")
|
| 410 |
+
ax.fill_between(x, 0, daily_curve, color='#e74c3c', alpha=0.1)
|
| 411 |
+
|
| 412 |
+
step_x = max(1, len(x) // 15)
|
| 413 |
+
ax.set_xticks(x[::step_x])
|
| 414 |
+
ax.set_xticklabels(time_labels[::step_x], rotation=45, fontsize=8)
|
| 415 |
+
ax.set_title(f"Profilo di Carico: {days[day_idx]}")
|
| 416 |
+
ax.grid(True, linestyle='--', alpha=0.3)
|
| 417 |
+
ax.legend()
|
| 418 |
+
|
| 419 |
+
st.pyplot(fig)
|
| 420 |
+
|
| 421 |
+
st.divider()
|
| 422 |
+
|
| 423 |
+
# Uso il data_editor nativo di Streamlit per permettere l'override manuale
|
| 424 |
+
# della demand curva direttamente in UI, molto comodo per i planner.
|
| 425 |
+
st.subheader("✏️ Override Manuale (Modifica Volumi)")
|
| 426 |
+
st.caption("Fai doppio clic su una cella della tabella per alterare manualmente il numero di operatori richiesti.")
|
| 427 |
+
df_demand = pd.DataFrame(target_demand, index=days, columns=time_labels)
|
| 428 |
+
edited_df = st.data_editor(df_demand, width='stretch', height=300)
|
| 429 |
+
|
| 430 |
+
if st.button("💾 Salva Modifiche Curva"):
|
| 431 |
+
final_json_structure = []
|
| 432 |
+
for i, row in enumerate(edited_df.values):
|
| 433 |
+
row_list = [f"Giorno_{i}"] + row.tolist()
|
| 434 |
+
final_json_structure.append(row_list)
|
| 435 |
+
|
| 436 |
+
save_json(selected_activity, "demand.json", final_json_structure)
|
| 437 |
+
st.success("✅ Fabbisogno aggiornato e sincronizzato.")
|
| 438 |
+
st.rerun()
|
| 439 |
+
else:
|
| 440 |
+
st.warning("Impossibile effettuare il render: payload mancante.")
|
| 441 |
+
|
| 442 |
+
# ------------------------------------------------------------------------------
|
| 443 |
+
# TAB 4: MOTORE DI OTTIMIZZAZIONE
|
| 444 |
+
# ------------------------------------------------------------------------------
|
| 445 |
+
with tab4:
|
| 446 |
+
st.header("🚀 Motore di Ottimizzazione")
|
| 447 |
+
st.caption("Avvia l'AI per calcolare l'incastro dei turni migliore in base ai parametri che hai inserito.")
|
| 448 |
+
|
| 449 |
+
col_run, col_stat = st.columns([1, 2])
|
| 450 |
+
with col_run:
|
| 451 |
+
run_btn = st.button("✨ AVVIA IL CALCOLO DEI TURNI", type="primary")
|
| 452 |
+
|
| 453 |
+
if run_btn:
|
| 454 |
+
prog_bar = st.progress(0)
|
| 455 |
+
status_text = st.empty()
|
| 456 |
+
|
| 457 |
+
# Callback passata all'engine genetico per aggiornare l'interfaccia
|
| 458 |
+
# asincronamente durante i pesanti cicli for loop su Numba.
|
| 459 |
+
def ui_callback(gen, tot, score, div):
|
| 460 |
+
prog_bar.progress(gen / tot)
|
| 461 |
+
status_text.markdown(f"🧬 Elaborazione in corso... Generazione: **{gen}/{tot}** | Punteggio Penalità: **{score:.0f}** | Esplorazione: **{div:.2f}%**" )
|
| 462 |
+
|
| 463 |
+
with st.spinner("Compilazione codice macchina (JIT) e calcolo in corso. Potrebbe volerci qualche minuto..."):
|
| 464 |
+
try:
|
| 465 |
+
current_act = st.session_state.get('current_activity')
|
| 466 |
+
if not current_act: raise ValueError("Nessun contesto operativo attivo.")
|
| 467 |
+
|
| 468 |
+
employees = load_employees_from_json(current_act)
|
| 469 |
+
raw_d = load_json(selected_activity, "demand.json")
|
| 470 |
+
target = process_demand(raw_d)
|
| 471 |
+
|
| 472 |
+
# Applichiamo le regole di business (le chiusure impostate in L2 config)
|
| 473 |
+
# azzerando forzatamente la domanda oraria per non far schedulare turni.
|
| 474 |
+
for d in range(7):
|
| 475 |
+
closing_slot = cfg.get_closing_slot(d)
|
| 476 |
+
if cfg.is_day_closed(d) or closing_slot == 0:
|
| 477 |
+
target[d, :] = 0
|
| 478 |
+
else:
|
| 479 |
+
if closing_slot < target.shape[1]:
|
| 480 |
+
target[d, closing_slot:] = 0
|
| 481 |
+
|
| 482 |
+
hours_diff = check_hours_balance(employees, target)
|
| 483 |
+
|
| 484 |
+
if hours_diff >= 0:
|
| 485 |
+
st.success(f"✅ Controllo Preliminare Superato: Lo staff disponibile copre matematicamente le ore richieste (Surplus: {hours_diff:.1f}h).")
|
| 486 |
+
else:
|
| 487 |
+
st.error(f"⚠️ Attenzione - Sotto-dimensionamento Strutturale: Hai chiesto più ore di quelle contrattualizzate. Verranno generati dei buchi inevitabili (Deficit: {abs(hours_diff):.1f}h).")
|
| 488 |
+
|
| 489 |
+
# Esecuzione del kernel genetico core
|
| 490 |
+
top_solutions, div_history = run_genetic_algorithm(employees, target, progress_callback=ui_callback)
|
| 491 |
+
|
| 492 |
+
final_pop_sample = np.array([sol['schedule'] for sol in top_solutions])
|
| 493 |
+
final_diversity = div_history[-1] if div_history else 0.0
|
| 494 |
+
|
| 495 |
+
# Caching dell'output generato nell'oggetto di sessione.
|
| 496 |
+
# Evita di perdere i risultati (o triggerare ricalcoli) se cambio tab.
|
| 497 |
+
st.session_state['ga_results'] = {
|
| 498 |
+
'top_solutions': top_solutions,
|
| 499 |
+
'diversity_score': final_diversity,
|
| 500 |
+
'diversity_history': div_history,
|
| 501 |
+
'selected_idx': 0,
|
| 502 |
+
'employees': employees,
|
| 503 |
+
'target': target
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
best_s = top_solutions[0]['total_score']
|
| 507 |
+
status_text.success(f"Ottimizzazione conclusa con successo! Miglior punteggio di penalità raggiunto: {best_s:.0f}")
|
| 508 |
+
|
| 509 |
+
except Exception as e:
|
| 510 |
+
st.error(f"Errore di sistema durante il calcolo: {e}")
|
| 511 |
+
traceback.print_exc()
|
| 512 |
+
|
| 513 |
+
# Blocco di visualizzazione post-run
|
| 514 |
+
if 'ga_results' in st.session_state:
|
| 515 |
+
res = st.session_state['ga_results']
|
| 516 |
+
solutions = res['top_solutions']
|
| 517 |
+
div_hist = res.get('diversity_history', [res.get('diversity_score', 0.0)])
|
| 518 |
+
final_div = div_hist[-1]
|
| 519 |
+
|
| 520 |
+
# Diagnostica algoritmica automatizzata (Controlla il drop-rate della diversità)
|
| 521 |
+
msg, color_code = analyze_convergence_quality(div_hist)
|
| 522 |
+
|
| 523 |
+
st.markdown("### 🩺 Diagnostica e Validazione Scientifica")
|
| 524 |
+
kpi1, kpi2 = st.columns([1, 3])
|
| 525 |
+
|
| 526 |
+
kpi1.metric("Diversità Genetica Finale", f"{final_div:.2f}%")
|
| 527 |
+
|
| 528 |
+
if color_code == "success": kpi2.success(msg)
|
| 529 |
+
elif color_code == "normal": kpi2.info(msg)
|
| 530 |
+
else: kpi2.error(msg)
|
| 531 |
+
|
| 532 |
+
with st.expander("Cos'è la Diversità e come interpretarla?"):
|
| 533 |
+
st.caption("""
|
| 534 |
+
La **Diversità** indica quante soluzioni "diverse" l'algoritmo stava ancora testando alla fine del processo.
|
| 535 |
+
- **Se è >40%:** L'AI non è riuscita a trovare un pattern vincente e ha continuato a sparare a caso (aumenta le Generazioni o diminuisci la Mutazione).
|
| 536 |
+
- **Se crolla subito a <5% (Convergenza Prematura):** L'AI si è "incastrata" su una soluzione mediocre e ha smesso di cercare (aumenta la Mutazione).
|
| 537 |
+
- **Se scende gradualmente (Matura):** È lo stato ideale. L'AI ha esplorato bene e poi ha "stretto" verso la soluzione perfetta.
|
| 538 |
+
""")
|
| 539 |
+
|
| 540 |
+
if div_hist:
|
| 541 |
+
st.subheader("📉 Profilo Dinamico dell'Apprendimento")
|
| 542 |
+
|
| 543 |
+
fig_div, ax_div = plt.subplots(figsize=(10, 3))
|
| 544 |
+
x_axis = [i * 5 for i in range(len(div_hist))]
|
| 545 |
+
|
| 546 |
+
ax_div.plot(x_axis, div_hist, color='#2980b9', linewidth=2, label='Varianza di Popolazione (%)')
|
| 547 |
+
ax_div.axhspan(0, 5, color='#e74c3c', alpha=0.1, label='Rischio Collasso (<5%)')
|
| 548 |
+
ax_div.axhspan(40, 100, color='#e67e22', alpha=0.1, label='Rischio Divergenza (>40%)')
|
| 549 |
+
ax_div.axhspan(5, 40, color='#2ecc71', alpha=0.1, label='Fascia Ottimale')
|
| 550 |
+
|
| 551 |
+
ax_div.set_ylabel("Hamming Dist (%)")
|
| 552 |
+
ax_div.set_xlabel("Epoche di Addestramento")
|
| 553 |
+
ax_div.set_ylim(0, max(50, max(div_hist) + 5))
|
| 554 |
+
ax_div.grid(True, linestyle='--', alpha=0.5)
|
| 555 |
+
ax_div.legend(loc='upper right', fontsize='small')
|
| 556 |
+
|
| 557 |
+
st.pyplot(fig_div)
|
| 558 |
+
plt.close(fig_div)
|
| 559 |
+
|
| 560 |
+
with st.expander("Come leggere questo grafico?"):
|
| 561 |
+
st.caption("""
|
| 562 |
+
Questo grafico racconta visivamente il lavoro dell'algoritmo:
|
| 563 |
+
1. **Fase Iniziale (Esplorazione):** Il grafico deve partire alto (fuori dal rosso basso). L'AI sta provando incastri creativi.
|
| 564 |
+
2. **Discesa (Sfruttamento):** La curva deve scendere dolcemente verso il basso.
|
| 565 |
+
3. **Atterraggio:** La curva dovrebbe stabilizzarsi nella **Fascia Verde Ottimale**. Se vedi crolli verticali improvvisi all'inizio, c'è un problema di configurazione nei parametri genetici.
|
| 566 |
+
""")
|
| 567 |
+
|
| 568 |
+
st.divider()
|
| 569 |
+
st.subheader("🏆 Esplorazione delle Migliori Soluzioni Trovate")
|
| 570 |
+
st.caption("L'algoritmo ti propone le varianti più performanti. Lo SCORE TOTALE è la somma delle penalità (più è basso, meglio è).")
|
| 571 |
+
|
| 572 |
+
comp_data = []
|
| 573 |
+
for i, s in enumerate(solutions):
|
| 574 |
+
comp_data.append({
|
| 575 |
+
"Candidato": f"Soluzione #{i+1}",
|
| 576 |
+
"SCORE TOTALE (Penalità)": int(s['total_score']),
|
| 577 |
+
"❌ Understaffing (Buchi)": int(s['understaffing']),
|
| 578 |
+
"⚖️ Inequità Weekend": int(s['equity']),
|
| 579 |
+
"⚡ Overstaffing (Eccessi)": int(s['overstaffing']),
|
| 580 |
+
"🎨 Pref. Ignorate": int(s['soft_preferences']),
|
| 581 |
+
"📝 Mix Contratti Violato": int(s['contract'])
|
| 582 |
+
})
|
| 583 |
+
|
| 584 |
+
df_comp = pd.DataFrame(comp_data)
|
| 585 |
+
st.dataframe(
|
| 586 |
+
df_comp.style.background_gradient(cmap="RdYlGn_r", subset=["SCORE TOTALE (Penalità)", "❌ Understaffing (Buchi)"]),
|
| 587 |
+
width='stretch',
|
| 588 |
+
hide_index=True
|
| 589 |
+
)
|
| 590 |
+
|
| 591 |
+
sel_opt = st.radio("Seleziona quale piano turni visualizzare in dettaglio:",
|
| 592 |
+
options=range(len(solutions)),
|
| 593 |
+
format_func=lambda x: f"Apri Dettaglio Soluzione #{x+1}",
|
| 594 |
+
horizontal=True,
|
| 595 |
+
index=st.session_state.get('selected_idx', 0))
|
| 596 |
+
|
| 597 |
+
st.session_state['ga_results']['selected_idx'] = sel_opt
|
| 598 |
+
|
| 599 |
+
chosen_sol = solutions[sel_opt]
|
| 600 |
+
best_sched = chosen_sol['schedule']
|
| 601 |
+
emps = res['employees']
|
| 602 |
+
tgt = res['target']
|
| 603 |
+
|
| 604 |
+
st.subheader(f"Dashboard Copertura: Soluzione #{sel_opt+1}")
|
| 605 |
+
st.caption("Confronto visivo tra le persone richieste (linea rossa tratteggiata) e le persone messe a turno (area blu).")
|
| 606 |
+
|
| 607 |
+
d_tabs = st.tabs(["Lunedì", "Martedì", "Mercoledì", "Giovedì", "Venerdì", "Sabato", "Domenica"])
|
| 608 |
+
|
| 609 |
+
# Proietto il genoma elaborato (best_sched) sulla matrice di copertura per le charts
|
| 610 |
+
cov_mat = get_final_coverage_matrix(best_sched, emps)
|
| 611 |
+
|
| 612 |
+
for i, t in enumerate(d_tabs):
|
| 613 |
+
with t:
|
| 614 |
+
fig, ax = plt.subplots(figsize=(8, 2))
|
| 615 |
+
x = range(cfg.daily_slots)
|
| 616 |
+
ax.fill_between(x, cov_mat[i], alpha=0.3)
|
| 617 |
+
ax.plot(x, cov_mat[i], label="Staff Schedulato")
|
| 618 |
+
ax.plot(x, tgt[i], 'r--', label="Staff Richiesto (Target)")
|
| 619 |
+
tick_step = 4
|
| 620 |
+
lbls = [minutes_to_time(k * cfg.system_slot_minutes) for k in x[::tick_step]]
|
| 621 |
+
ax.set_xticks(list(x)[::tick_step])
|
| 622 |
+
ax.set_xticklabels(lbls, rotation=0, fontsize=4)
|
| 623 |
+
ax.legend()
|
| 624 |
+
st.pyplot(fig)
|
| 625 |
+
plt.close(fig)
|
| 626 |
+
|
| 627 |
+
st.subheader("Tabellone Turni (Export)")
|
| 628 |
+
data_rows = []
|
| 629 |
+
days = ["Lun", "Mar", "Mer", "Gio", "Ven", "Sab", "Dom"]
|
| 630 |
+
for idx, e in enumerate(emps):
|
| 631 |
+
row = {"Dipendente": e['id']}
|
| 632 |
+
for d in range(7):
|
| 633 |
+
s = best_sched[idx, d]
|
| 634 |
+
if s == -1: txt = "OFF"
|
| 635 |
+
elif s == -2: txt = "ABS"
|
| 636 |
+
else:
|
| 637 |
+
start = s * cfg.system_slot_minutes
|
| 638 |
+
end = start + (e['shift_len'] * cfg.system_slot_minutes)
|
| 639 |
+
txt = f"{minutes_to_time(start)}-{minutes_to_time(end)}"
|
| 640 |
+
row[days[d]] = txt
|
| 641 |
+
data_rows.append(row)
|
| 642 |
+
st.dataframe(pd.DataFrame(data_rows), width='stretch')
|
| 643 |
+
|
| 644 |
+
st.markdown("---")
|
| 645 |
+
st.subheader("🔬 Ispezione Micro-Turno (Maschere VDT e Pause)")
|
| 646 |
+
st.caption("Verifica la corretta allocazione delle pause VDT all'interno dello spezzato del singolo operatore.")
|
| 647 |
+
|
| 648 |
+
# Micro-rendering della maschera binaria per l'ispezione visiva dei sub-slot
|
| 649 |
+
c1, c2 = st.columns(2)
|
| 650 |
+
sel_emp = c1.selectbox("Seleziona Operatore", [e['id'] for e in emps])
|
| 651 |
+
sel_day = c2.selectbox("Seleziona Giorno", range(7), format_func=lambda x: days[x])
|
| 652 |
+
|
| 653 |
+
e_idx = next(i for i,e in enumerate(emps) if e['id'] == sel_emp)
|
| 654 |
+
s_start = best_sched[e_idx, sel_day]
|
| 655 |
+
|
| 656 |
+
if s_start >= 0:
|
| 657 |
+
mask = emps[e_idx]['mask']
|
| 658 |
+
html = ""
|
| 659 |
+
for k, bit in enumerate(mask):
|
| 660 |
+
t_str = minutes_to_time((s_start + k) * cfg.system_slot_minutes)
|
| 661 |
+
col = "#4CAF50" if bit else "#FF5252"
|
| 662 |
+
html += f"<div style='display:inline-block;width:35px;background:{col};color:white;font-size:10px;text-align:center;margin:1px;'>{t_str}</div>"
|
| 663 |
+
st.markdown(html, unsafe_allow_html=True)
|
| 664 |
+
st.caption("**Legenda:** [Verde] = Operatività a Terminale | [Rosso] = Pausa/Pranzo")
|
| 665 |
+
else:
|
| 666 |
+
st.info("Status per il giorno selezionato: RIPOSO o ASSENTE.")
|
| 667 |
+
|
| 668 |
+
# ------------------------------------------------------------------------------
|
| 669 |
+
# TAB 5: BOOTSTRAPPING DI MOCK SCENARIOS (GENERATORE)
|
| 670 |
+
# ------------------------------------------------------------------------------
|
| 671 |
+
with tab5:
|
| 672 |
+
st.header("⚡ Generatore Ambienti di Test (Mock Scenarios)")
|
| 673 |
+
st.markdown("""
|
| 674 |
+
Crea rapidamente nuovi scenari completi per testare come il motore AI reagisce a diverse
|
| 675 |
+
composizioni della forza lavoro (es. alta rigidità vs alta flessibilità).
|
| 676 |
+
""")
|
| 677 |
+
|
| 678 |
+
col_gen_L, col_gen_R = st.columns([1, 2])
|
| 679 |
+
|
| 680 |
+
with col_gen_L:
|
| 681 |
+
st.subheader("1. Setup Spazio Dati")
|
| 682 |
+
new_scenario_name = st.text_input("Nome del nuovo Scenario", value="Nuovo_Test_BPO")
|
| 683 |
+
new_emp_count = st.number_input("Numero Dipendenti Fittizi", min_value=1, max_value=2000, value=300, step=10)
|
| 684 |
+
|
| 685 |
+
st.markdown("---")
|
| 686 |
+
st.subheader("📊 Modello Matematico del Fabbisogno")
|
| 687 |
+
st.caption("Scegli una distribuzione che simuli fedelmente il traffico del servizio.")
|
| 688 |
+
|
| 689 |
+
curve_options = {
|
| 690 |
+
"Doppia Campana (Tipico BPO Voice)": "double_bell",
|
| 691 |
+
"Campana Centrale (Es. Delivery/Pausa Pranzo)": "single_bell_center",
|
| 692 |
+
"Picco Mattutino (Es. Helpdesk IT)": "morning_peak",
|
| 693 |
+
"Piatto Costante (Es. Backoffice/Data Entry)": "steady_high"
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
selected_curve_label = st.selectbox("Seleziona Modello di Carico:", options=list(curve_options.keys()), index=0)
|
| 697 |
+
curve_key = curve_options[selected_curve_label]
|
| 698 |
+
|
| 699 |
+
st.caption("Anteprima Forma:")
|
| 700 |
+
|
| 701 |
+
# Anteprima visiva matematica della distribuzione scelta
|
| 702 |
+
preview_x = np.linspace(8, 22, 50)
|
| 703 |
+
if curve_key == "double_bell":
|
| 704 |
+
preview_y = np.exp(-((preview_x - 11)**2)/4) + np.exp(-((preview_x - 16)**2)/4)
|
| 705 |
+
elif curve_key == "single_bell_center":
|
| 706 |
+
preview_y = np.exp(-((preview_x - 13)**2)/9)
|
| 707 |
+
elif curve_key == "morning_peak":
|
| 708 |
+
preview_y = np.exp(-((preview_x - 9.5)**2)/5)
|
| 709 |
+
else:
|
| 710 |
+
preview_y = np.ones_like(preview_x) * 0.8
|
| 711 |
+
|
| 712 |
+
fig_curve_prev, ax_cp = plt.subplots(figsize=(4, 1.5))
|
| 713 |
+
ax_cp.plot(preview_x, preview_y, color='#2ecc71', lw=2)
|
| 714 |
+
ax_cp.fill_between(preview_x, preview_y, color='#2ecc71', alpha=0.2)
|
| 715 |
+
ax_cp.set_yticks([])
|
| 716 |
+
ax_cp.set_xticks([8, 12, 16, 20])
|
| 717 |
+
ax_cp.set_xlim(8, 22)
|
| 718 |
+
fig_curve_prev.patch.set_alpha(0.0)
|
| 719 |
+
ax_cp.patch.set_alpha(0.0)
|
| 720 |
+
st.pyplot(fig_curve_prev)
|
| 721 |
+
|
| 722 |
+
with col_gen_R:
|
| 723 |
+
st.subheader("2. Strategia HR (Mix Contrattuale)")
|
| 724 |
+
st.caption("Simula il livello di flessibilità del personale.")
|
| 725 |
+
|
| 726 |
+
pct_ft40 = st.slider("🔵 Full Time (8h - Alta rigidità)", 0, 100, 60)
|
| 727 |
+
pct_pt30 = st.slider("🟡 Part Time (6h - Media flessibilità)", 0, 100, 20)
|
| 728 |
+
remaining = max(0, 100 - (pct_ft40 + pct_pt30))
|
| 729 |
+
pct_pt20 = st.slider("🔴 Part Time (4h - Alta flessibilità)", 0, 100, remaining)
|
| 730 |
+
|
| 731 |
+
total_mix = pct_ft40 + pct_pt30 + pct_pt20
|
| 732 |
+
|
| 733 |
+
if total_mix != 100:
|
| 734 |
+
st.warning(f"⚠️ La somma deve essere 100%. Attuale: {total_mix}%.")
|
| 735 |
+
else:
|
| 736 |
+
st.success("✅ Composizione valida.")
|
| 737 |
+
|
| 738 |
+
fig_preview, ax_prev = plt.subplots(figsize=(3, 1.5))
|
| 739 |
+
data_prev, labels_prev, colors_prev = [pct_ft40, pct_pt30, pct_pt20], ['FT 8h', 'PT 6h', 'PT 4h'], ['#3498db', '#f1c40f', '#e74c3c']
|
| 740 |
+
d_clean, l_clean, c_clean = zip(*[(d, l, c) for d, l, c in zip(data_prev, labels_prev, colors_prev) if d > 0])
|
| 741 |
+
|
| 742 |
+
wedges, texts, autotexts = ax_prev.pie(d_clean, labels=None, colors=c_clean, autopct='%1.0f%%', textprops={'color':"white", 'fontsize': 8, 'weight': 'bold'}, pctdistance=0.5)
|
| 743 |
+
ax_prev.axis('equal')
|
| 744 |
+
fig_preview.patch.set_alpha(0.0)
|
| 745 |
+
|
| 746 |
+
leg = ax_prev.legend(wedges, l_clean, loc="center left", bbox_to_anchor=(1, 0, 0.5, 1), frameon=False, labelcolor='white', fontsize=7)
|
| 747 |
+
st.pyplot(fig_preview, width='content')
|
| 748 |
+
|
| 749 |
+
st.divider()
|
| 750 |
+
|
| 751 |
+
btn_col, _ = st.columns([1, 3])
|
| 752 |
+
if btn_col.button("🚀 Inizializza Scenario su HF", type="primary", disabled=(total_mix != 100)):
|
| 753 |
+
if not new_scenario_name.strip():
|
| 754 |
+
st.error("Nome scenario non valido.")
|
| 755 |
+
else:
|
| 756 |
+
with st.spinner("Creazione dati fittizi e upload in corso..."):
|
| 757 |
+
mix_dict = {'FT40': pct_ft40, 'PT30': pct_pt30, 'PT20': pct_pt20}
|
| 758 |
+
success, msg = generate_scenario_files(new_scenario_name, new_emp_count, mix_dict, curve_key)
|
| 759 |
+
|
| 760 |
+
if success:
|
| 761 |
+
st.success(f"{msg}")
|
| 762 |
+
st.info("🔄 Clicca su 'Ricarica App' nella barra laterale sinistra per gestire questo nuovo scenario.")
|
| 763 |
+
else:
|
| 764 |
+
st.error(f"Errore di sistema: {msg}")
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy
|
| 2 |
+
matplotlib
|
| 3 |
+
streamlit
|
| 4 |
+
pandas
|
| 5 |
+
numba
|
| 6 |
+
huggingface_hub
|
src/__init__.py
ADDED
|
File without changes
|
src/config.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
from src.utils.hf_storage import load_json
|
| 4 |
+
|
| 5 |
+
class ConfigManager:
|
| 6 |
+
"""
|
| 7 |
+
Singleton implementation for global configuration state management.
|
| 8 |
+
Gestisce il caricamento a cascata (Cascade Loading) dei parametri di sistema e di dominio.
|
| 9 |
+
"""
|
| 10 |
+
_instance = None
|
| 11 |
+
|
| 12 |
+
def __new__(cls, activity_name=None):
|
| 13 |
+
if cls._instance is None:
|
| 14 |
+
cls._instance = super(ConfigManager, cls).__new__(cls)
|
| 15 |
+
cls._instance.initialized = False
|
| 16 |
+
return cls._instance
|
| 17 |
+
|
| 18 |
+
def __init__(self, activity_name=None):
|
| 19 |
+
# Lazy Initialization: previene la sovrascrittura dello stato su chiamate multiple
|
| 20 |
+
if getattr(self, 'initialized', False) and not activity_name:
|
| 21 |
+
return
|
| 22 |
+
|
| 23 |
+
# --- L0: HARDCODED FALLBACKS (Safety Net) ---
|
| 24 |
+
self.activity_name = None
|
| 25 |
+
self.system_slot_minutes = 15
|
| 26 |
+
self.planning_slot_minutes = 30
|
| 27 |
+
self.expansion_factor = 2
|
| 28 |
+
self.daily_slots = 96
|
| 29 |
+
|
| 30 |
+
# Safe allocations per le strutture di dominio
|
| 31 |
+
self.client_settings = {"day_start_hour": 8, "day_end_hour": 20}
|
| 32 |
+
self.system_settings = {"vdt_interval_minutes": 120, "vdt_break_minutes": 15}
|
| 33 |
+
self.weights = {"understaffing": 1000, "overstaffing": 10, "homogeneity": 20, "soft_preference": 50}
|
| 34 |
+
|
| 35 |
+
# Hyper-parametri di default per l'Engine Genetico
|
| 36 |
+
self.genetic_params = {
|
| 37 |
+
"population_size": 200, "generations": 100, "mutation_rate": 0.3,
|
| 38 |
+
"crossover_rate": 0.8, "elitism_rate": 0.02, "tournament_size": 5,
|
| 39 |
+
"heuristic_rate": 0.8, "guided_mutation_split": 0.4, "heuristic_noise": 0.2
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# Inizializzazione sicura del routing orario
|
| 43 |
+
self.operating_hours = {"default": "09:00-18:00", "exceptions": {}}
|
| 44 |
+
self.hours = self.operating_hours
|
| 45 |
+
|
| 46 |
+
if activity_name:
|
| 47 |
+
self.load_configurations(activity_name)
|
| 48 |
+
else:
|
| 49 |
+
print("[WARN] ConfigManager istanziato senza context. Approvvigionamento defaults (L0) completato.")
|
| 50 |
+
self.initialized = True
|
| 51 |
+
|
| 52 |
+
def load_configurations(self, activity_name):
|
| 53 |
+
"""Orchestratore del configuration loading a 3 livelli (L0 -> L1 -> L2)."""
|
| 54 |
+
self.activity_name = activity_name
|
| 55 |
+
base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 56 |
+
|
| 57 |
+
# --- L1: ENGINE CONFIG (Global System Overrides) ---
|
| 58 |
+
engine_path = os.path.join(base_path, "src", "config", "engine_config.json")
|
| 59 |
+
try:
|
| 60 |
+
if os.path.exists(engine_path):
|
| 61 |
+
with open(engine_path, 'r') as f:
|
| 62 |
+
engine_data = json.load(f)
|
| 63 |
+
if 'system_settings' in engine_data:
|
| 64 |
+
self.system_settings.update(engine_data['system_settings'])
|
| 65 |
+
self.system_slot_minutes = self.system_settings.get('system_slot_minutes', 15)
|
| 66 |
+
if 'genetic_params' in engine_data:
|
| 67 |
+
self.genetic_params.update(engine_data['genetic_params'])
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f"[WARN] Impossibile risolvere L1 engine_config.json ({e}). Proceeding with L0.")
|
| 70 |
+
|
| 71 |
+
# --- L2: ACTIVITY CONFIG (Tenant/Domain Specifics via Object Storage) ---
|
| 72 |
+
activity_data = load_json(activity_name, "activity_config.json")
|
| 73 |
+
|
| 74 |
+
if not activity_data:
|
| 75 |
+
print(f"[FATAL] Configurazione L2 mancante sul Dataset HF per l'attività '{activity_name}'.")
|
| 76 |
+
return
|
| 77 |
+
|
| 78 |
+
# Merging dei layer applicativi
|
| 79 |
+
self.client_settings = activity_data.get('client_settings', self.client_settings)
|
| 80 |
+
self.planning_slot_minutes = self.client_settings.get('planning_slot_minutes', 30)
|
| 81 |
+
|
| 82 |
+
if 'weights' in activity_data:
|
| 83 |
+
self.weights = activity_data['weights']
|
| 84 |
+
if 'operating_hours' in activity_data:
|
| 85 |
+
self.operating_hours = activity_data['operating_hours']
|
| 86 |
+
self.hours = self.operating_hours
|
| 87 |
+
if 'genetic_params' in activity_data:
|
| 88 |
+
self.genetic_params.update(activity_data['genetic_params'])
|
| 89 |
+
|
| 90 |
+
# --- DERIVED METRICS COMPUTATION ---
|
| 91 |
+
self.slot_minutes = self.system_slot_minutes
|
| 92 |
+
self.expansion_factor = int(self.planning_slot_minutes / self.system_slot_minutes)
|
| 93 |
+
if self.expansion_factor < 1:
|
| 94 |
+
self.expansion_factor = 1
|
| 95 |
+
|
| 96 |
+
# Calcolo dimensione tensore giornaliero
|
| 97 |
+
start = self.client_settings.get('day_start_hour', 8)
|
| 98 |
+
end = self.client_settings.get('day_end_hour', 20)
|
| 99 |
+
self.daily_slots = int((end - start) * 60 / self.system_slot_minutes)
|
| 100 |
+
if self.daily_slots <= 0:
|
| 101 |
+
self.daily_slots = 96
|
| 102 |
+
|
| 103 |
+
self.initialized = True
|
| 104 |
+
print(f"[OK] State Sync completato: {activity_name} (Shift Bounds: {start}:00-{end}:00, Grid: {self.daily_slots} slots)")
|
| 105 |
+
|
| 106 |
+
def get_closing_slot(self, day_idx):
|
| 107 |
+
"""Mappa l'orario di chiusura algebrico sull'indice dello slot di sistema."""
|
| 108 |
+
day_str = str(day_idx)
|
| 109 |
+
exceptions = self.hours.get('exceptions', {})
|
| 110 |
+
|
| 111 |
+
# 1. Rule Extraction (Exception override vs Baseline)
|
| 112 |
+
if day_str in exceptions:
|
| 113 |
+
time_range = exceptions[day_str]
|
| 114 |
+
else:
|
| 115 |
+
time_range = self.hours.get('default', "09:00-18:00")
|
| 116 |
+
|
| 117 |
+
# 2. Explicit closure flag
|
| 118 |
+
if str(time_range).strip().upper() == "CLOSED":
|
| 119 |
+
return 0
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
# 3. Time-to-Slot Quantization
|
| 123 |
+
_, close_time = time_range.split('-')
|
| 124 |
+
h, m = map(int, close_time.split(':'))
|
| 125 |
+
|
| 126 |
+
start_h = self.client_settings.get('day_start_hour', 8)
|
| 127 |
+
minutes_from_start = (h - start_h) * 60 + m
|
| 128 |
+
|
| 129 |
+
divisor = self.system_slot_minutes if self.system_slot_minutes > 0 else 15
|
| 130 |
+
|
| 131 |
+
slot_idx = int(minutes_from_start / divisor)
|
| 132 |
+
return max(0, slot_idx)
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
# Failsafe: Parsing error mappato come hard closure per prevenire out-of-bounds nell'engine C/Numba
|
| 136 |
+
print(f"[ERROR] Constraint parsing fallito sul Day {day_idx} ('{time_range}'): {e}. Forzatura status CLOSED.")
|
| 137 |
+
return 0
|
| 138 |
+
|
| 139 |
+
def is_day_closed(self, day_idx):
|
| 140 |
+
return self.get_closing_slot(day_idx) == 0
|
| 141 |
+
|
| 142 |
+
# Istanza globale esportata per i moduli downstream
|
| 143 |
+
cfg = ConfigManager()
|
src/config/engine_config.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"system_settings": {
|
| 3 |
+
"system_slot_minutes": 15,
|
| 4 |
+
"vdt_interval_minutes": 120,
|
| 5 |
+
"vdt_break_minutes": 15,
|
| 6 |
+
"version": "1.0"
|
| 7 |
+
},
|
| 8 |
+
"genetic_params": {
|
| 9 |
+
"population_size": 200,
|
| 10 |
+
"generations": 500,
|
| 11 |
+
"mutation_rate": 0.2,
|
| 12 |
+
"crossover_rate": 0.85,
|
| 13 |
+
"elitism_rate": 0.02,
|
| 14 |
+
"tournament_size": 5,
|
| 15 |
+
"heuristic_rate": 0.8,
|
| 16 |
+
"guided_mutation_split": 0.4
|
| 17 |
+
}
|
| 18 |
+
}
|
src/engine/__init__.py
ADDED
|
File without changes
|
src/engine/crossover.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import random
|
| 3 |
+
from src.config import cfg
|
| 4 |
+
|
| 5 |
+
def crossover(parent1, parent2):
|
| 6 |
+
"""
|
| 7 |
+
Operatore di Uniform Crossover vettorializzato.
|
| 8 |
+
Scambia le agende settimanali intere (righe) tra i due genitori
|
| 9 |
+
per mantenere la coerenza dei vincoli e del target ore sul singolo dipendente.
|
| 10 |
+
"""
|
| 11 |
+
rate = cfg.genetic_params.get('crossover_rate', 0.85)
|
| 12 |
+
|
| 13 |
+
# Bypass dell'operatore in base alla probabilità (preservazione dei tratti parentali)
|
| 14 |
+
if random.random() > rate:
|
| 15 |
+
return parent1.copy(), parent2.copy()
|
| 16 |
+
|
| 17 |
+
rows, _ = parent1.shape
|
| 18 |
+
|
| 19 |
+
# Generazione di una maschera booleana 1D per l'estrazione delle righe (dipendenti)
|
| 20 |
+
mask = np.random.rand(rows) < 0.5
|
| 21 |
+
|
| 22 |
+
child1 = parent1.copy()
|
| 23 |
+
child2 = parent2.copy()
|
| 24 |
+
|
| 25 |
+
# Swap vettorializzato: incrociamo i genomi sovrascrivendo le righe mascherate
|
| 26 |
+
child1[mask] = parent2[mask]
|
| 27 |
+
child2[mask] = parent1[mask]
|
| 28 |
+
|
| 29 |
+
return child1, child2
|
src/engine/evolution.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from numba import njit, prange
|
| 3 |
+
from src.config import cfg
|
| 4 |
+
from src.problems.my_problem import convert_employees_to_numpy
|
| 5 |
+
from src.engine.selection import tournament_selection, calculate_population_fitness_parallel, calculate_fitness_numba, calculate_detailed_score, get_valid_start_slots_numba, CODE_OFF
|
| 6 |
+
from src.engine.crossover import crossover
|
| 7 |
+
from src.engine.mutation import mutate
|
| 8 |
+
from src.utils.health import calculate_diversity_snapshot
|
| 9 |
+
|
| 10 |
+
@njit(cache=True)
|
| 11 |
+
def _generate_single_random_individual(num_emps, num_days, lengths, target_days_arr, cons_types, cons_vals, closing_slots, is_closed_arr):
|
| 12 |
+
"""
|
| 13 |
+
Genera un individuo con assegnazioni di turni completamente casuali.
|
| 14 |
+
"""
|
| 15 |
+
roster = np.full((num_emps, num_days), CODE_OFF, dtype=np.int64)
|
| 16 |
+
|
| 17 |
+
for i in range(num_emps):
|
| 18 |
+
shift_len = lengths[i]
|
| 19 |
+
days_worked_indices = np.empty(7, dtype=np.int64)
|
| 20 |
+
dw_ptr = 0
|
| 21 |
+
|
| 22 |
+
# Assegnazione casuale degli slot validi
|
| 23 |
+
for d in range(num_days):
|
| 24 |
+
ctype = cons_types[i, d]
|
| 25 |
+
cval = cons_vals[i, d]
|
| 26 |
+
c_slot = closing_slots[d]
|
| 27 |
+
closed = is_closed_arr[d]
|
| 28 |
+
|
| 29 |
+
valid = get_valid_start_slots_numba(d, shift_len, ctype, cval, c_slot, closed)
|
| 30 |
+
|
| 31 |
+
if len(valid) > 0:
|
| 32 |
+
chosen = valid[np.random.randint(0, len(valid))]
|
| 33 |
+
roster[i, d] = chosen
|
| 34 |
+
if chosen >= 0:
|
| 35 |
+
days_worked_indices[dw_ptr] = d
|
| 36 |
+
dw_ptr += 1
|
| 37 |
+
|
| 38 |
+
# Drop dei giorni in eccesso rispetto al target contrattuale
|
| 39 |
+
tgt = target_days_arr[i]
|
| 40 |
+
while dw_ptr > tgt:
|
| 41 |
+
idx_in_buffer = np.random.randint(0, dw_ptr)
|
| 42 |
+
day_to_remove = days_worked_indices[idx_in_buffer]
|
| 43 |
+
roster[i, day_to_remove] = CODE_OFF
|
| 44 |
+
|
| 45 |
+
days_worked_indices[idx_in_buffer] = days_worked_indices[dw_ptr - 1]
|
| 46 |
+
dw_ptr -= 1
|
| 47 |
+
|
| 48 |
+
return roster
|
| 49 |
+
|
| 50 |
+
@njit(cache=True)
|
| 51 |
+
def _generate_single_heuristic_individual(num_emps, num_days, lengths, target_days_arr, cons_types, cons_vals, closing_slots, is_closed_arr, target_demand, randomness):
|
| 52 |
+
"""
|
| 53 |
+
Genera un individuo usando un approccio greedy basato sulla demand residua.
|
| 54 |
+
"""
|
| 55 |
+
num_slots = target_demand.shape[1]
|
| 56 |
+
roster = np.full((num_emps, num_days), CODE_OFF, dtype=np.int64)
|
| 57 |
+
|
| 58 |
+
# Copia locale della demand per aggiornare i residui
|
| 59 |
+
residual = target_demand.copy().astype(np.float64)
|
| 60 |
+
emp_order = np.random.permutation(num_emps)
|
| 61 |
+
|
| 62 |
+
for i in emp_order:
|
| 63 |
+
shift_len = lengths[i]
|
| 64 |
+
tgt = target_days_arr[i]
|
| 65 |
+
|
| 66 |
+
# Calcolo dei giorni con maggior necessità operativa
|
| 67 |
+
daily_needs = np.zeros(num_days, dtype=np.float64)
|
| 68 |
+
for d in range(num_days):
|
| 69 |
+
s = 0.0
|
| 70 |
+
for k in range(num_slots):
|
| 71 |
+
s += residual[d, k]
|
| 72 |
+
daily_needs[d] = s
|
| 73 |
+
|
| 74 |
+
if randomness > 0:
|
| 75 |
+
noise = np.random.randn(num_days) * (np.mean(daily_needs) * randomness)
|
| 76 |
+
daily_needs += noise
|
| 77 |
+
|
| 78 |
+
sorted_days = np.argsort(daily_needs)
|
| 79 |
+
start_idx = max(0, num_days - tgt)
|
| 80 |
+
preferred_days = sorted_days[start_idx:]
|
| 81 |
+
|
| 82 |
+
# Assegnazione dello slot migliore sul giorno scelto
|
| 83 |
+
for day in preferred_days:
|
| 84 |
+
ctype = cons_types[i, day]
|
| 85 |
+
cval = cons_vals[i, day]
|
| 86 |
+
c_slot = closing_slots[day]
|
| 87 |
+
closed = is_closed_arr[day]
|
| 88 |
+
|
| 89 |
+
valid = get_valid_start_slots_numba(day, shift_len, ctype, cval, c_slot, closed)
|
| 90 |
+
|
| 91 |
+
if len(valid) == 0:
|
| 92 |
+
roster[i, day] = CODE_OFF
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
# Torneo limitato a 5 candidati per ridurre l'overhead
|
| 96 |
+
n_cand = len(valid)
|
| 97 |
+
limit = 5
|
| 98 |
+
if n_cand <= limit:
|
| 99 |
+
candidates = valid
|
| 100 |
+
else:
|
| 101 |
+
candidates = np.empty(limit, dtype=np.int64)
|
| 102 |
+
for k in range(limit):
|
| 103 |
+
candidates[k] = valid[np.random.randint(0, n_cand)]
|
| 104 |
+
|
| 105 |
+
best_slot = candidates[0]
|
| 106 |
+
best_score = -1e9
|
| 107 |
+
|
| 108 |
+
for slot in candidates:
|
| 109 |
+
if slot < 0: continue
|
| 110 |
+
end = min(slot + shift_len, num_slots)
|
| 111 |
+
score = 0.0
|
| 112 |
+
for k in range(slot, end):
|
| 113 |
+
score += residual[day, k]
|
| 114 |
+
if score > best_score:
|
| 115 |
+
best_score = score
|
| 116 |
+
best_slot = slot
|
| 117 |
+
|
| 118 |
+
roster[i, day] = best_slot
|
| 119 |
+
|
| 120 |
+
if best_slot >= 0:
|
| 121 |
+
end = min(best_slot + shift_len, num_slots)
|
| 122 |
+
for k in range(best_slot, end):
|
| 123 |
+
val = residual[day, k] - 1.0
|
| 124 |
+
if val < 0: val = 0.0
|
| 125 |
+
residual[day, k] = val
|
| 126 |
+
|
| 127 |
+
return roster
|
| 128 |
+
|
| 129 |
+
@njit(parallel=True, cache=True)
|
| 130 |
+
def initialize_population_fast_wrapper(pop_size, heuristic_limit, numpy_data, target_demand, randomness, closing_slots, is_closed_arr):
|
| 131 |
+
"""
|
| 132 |
+
Wrapper JIT parallelo per la generazione massiva della popolazione.
|
| 133 |
+
"""
|
| 134 |
+
_, lengths, target_days_arr, cons_types, cons_vals = numpy_data
|
| 135 |
+
num_emps = len(lengths)
|
| 136 |
+
num_days = 7
|
| 137 |
+
|
| 138 |
+
population = np.zeros((pop_size, num_emps, num_days), dtype=np.int64)
|
| 139 |
+
|
| 140 |
+
for p in prange(pop_size):
|
| 141 |
+
if p < heuristic_limit:
|
| 142 |
+
population[p] = _generate_single_heuristic_individual(
|
| 143 |
+
num_emps, num_days, lengths, target_days_arr, cons_types, cons_vals,
|
| 144 |
+
closing_slots, is_closed_arr, target_demand, randomness
|
| 145 |
+
)
|
| 146 |
+
else:
|
| 147 |
+
population[p] = _generate_single_random_individual(
|
| 148 |
+
num_emps, num_days, lengths, target_days_arr, cons_types, cons_vals,
|
| 149 |
+
closing_slots, is_closed_arr
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
return population
|
| 153 |
+
|
| 154 |
+
def initialize_population_numba(pop_size, numpy_data, target_demand, cfg):
|
| 155 |
+
heuristic_rate = cfg.genetic_params.get('heuristic_rate', 0.8)
|
| 156 |
+
heuristic_noise = cfg.genetic_params.get('heuristic_noise', 0.2)
|
| 157 |
+
|
| 158 |
+
num_heuristic = int(pop_size * heuristic_rate)
|
| 159 |
+
|
| 160 |
+
print(f"[*] Inizializzazione popolazione: {num_heuristic} greedy, {pop_size - num_heuristic} random")
|
| 161 |
+
|
| 162 |
+
# Passaggio array espliciti per bypassare le limitazioni di Numba sugli oggetti custom
|
| 163 |
+
closing_slots = np.array([cfg.get_closing_slot(d) for d in range(7)], dtype=np.int64)
|
| 164 |
+
is_closed_arr = np.array([1 if cfg.is_day_closed(d) else 0 for d in range(7)], dtype=np.int64)
|
| 165 |
+
|
| 166 |
+
return initialize_population_fast_wrapper(
|
| 167 |
+
pop_size, num_heuristic, numpy_data, target_demand, heuristic_noise, closing_slots, is_closed_arr
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
def precompute_slots_cache_numba(numpy_data):
|
| 171 |
+
"""Pre-calcolo della cache degli slot validi per velocizzare le mutazioni."""
|
| 172 |
+
masks, lengths, target_days_arr, cons_types, cons_vals = numpy_data
|
| 173 |
+
num_emps = len(lengths)
|
| 174 |
+
cache = []
|
| 175 |
+
for i in range(num_emps):
|
| 176 |
+
emp_days = []
|
| 177 |
+
shift_len = lengths[i]
|
| 178 |
+
for d in range(7):
|
| 179 |
+
ctype = cons_types[i, d]
|
| 180 |
+
cval = cons_vals[i, d]
|
| 181 |
+
closing_slot = cfg.get_closing_slot(d)
|
| 182 |
+
is_closed = cfg.is_day_closed(d)
|
| 183 |
+
slots = get_valid_start_slots_numba(d, shift_len, ctype, cval, closing_slot, is_closed)
|
| 184 |
+
emp_days.append(slots)
|
| 185 |
+
cache.append(emp_days)
|
| 186 |
+
return cache
|
| 187 |
+
|
| 188 |
+
def run_genetic_algorithm(employees, target_demand, progress_callback=None):
|
| 189 |
+
print("[*] Conversione dati in tensori NumPy...")
|
| 190 |
+
numpy_data = convert_employees_to_numpy(employees)
|
| 191 |
+
|
| 192 |
+
if numpy_data[0] is None:
|
| 193 |
+
return []
|
| 194 |
+
|
| 195 |
+
print("[*] Generazione cache degli slot...")
|
| 196 |
+
slots_cache = precompute_slots_cache_numba(numpy_data)
|
| 197 |
+
|
| 198 |
+
pop_size = cfg.genetic_params['population_size']
|
| 199 |
+
generations = cfg.genetic_params['generations']
|
| 200 |
+
|
| 201 |
+
population = initialize_population_numba(pop_size, numpy_data, target_demand, cfg)
|
| 202 |
+
|
| 203 |
+
weights_arr = np.array([
|
| 204 |
+
cfg.weights['understaffing'],
|
| 205 |
+
cfg.weights['overstaffing'],
|
| 206 |
+
cfg.weights['homogeneity'],
|
| 207 |
+
cfg.weights['soft_preference'],
|
| 208 |
+
0.0
|
| 209 |
+
], dtype=np.float64)
|
| 210 |
+
|
| 211 |
+
daily_slots_scalar = int(cfg.daily_slots)
|
| 212 |
+
elitism_rate = cfg.genetic_params.get('elitism_rate', 0.02)
|
| 213 |
+
|
| 214 |
+
masks, lengths, target_days_arr, cons_types, cons_vals = numpy_data
|
| 215 |
+
|
| 216 |
+
best_score = float('inf')
|
| 217 |
+
best_coverage = None
|
| 218 |
+
best_overall = None
|
| 219 |
+
|
| 220 |
+
diversity_history = []
|
| 221 |
+
current_diversity = 0.0
|
| 222 |
+
|
| 223 |
+
print(f"[*] Avvio evoluzione: {generations} generazioni, size {len(population)}")
|
| 224 |
+
|
| 225 |
+
for gen in range(generations):
|
| 226 |
+
|
| 227 |
+
# 1. Valutazione Fitness
|
| 228 |
+
scores = calculate_population_fitness_parallel(
|
| 229 |
+
population,
|
| 230 |
+
masks, lengths, target_days_arr, cons_types, cons_vals,
|
| 231 |
+
target_demand, weights_arr, daily_slots_scalar
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
min_curr = np.min(scores)
|
| 235 |
+
best_idx = np.argmin(scores)
|
| 236 |
+
|
| 237 |
+
if min_curr < best_score:
|
| 238 |
+
best_score = min_curr
|
| 239 |
+
best_overall = population[best_idx].copy()
|
| 240 |
+
_, best_coverage = calculate_fitness_numba(
|
| 241 |
+
population[best_idx], masks, lengths, target_days_arr, cons_types, cons_vals,
|
| 242 |
+
target_demand, weights_arr, daily_slots_scalar
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
if gen % 5 == 0 or gen == generations - 1:
|
| 246 |
+
sample_rate = 0.20
|
| 247 |
+
dynamic_sample = int(len(population) * sample_rate)
|
| 248 |
+
|
| 249 |
+
# Clamp del sample size per bilanciare significatività statistica e overhead Numba
|
| 250 |
+
sample_size = max(20, min(dynamic_sample, 200))
|
| 251 |
+
sample_size = min(sample_size, len(population))
|
| 252 |
+
|
| 253 |
+
indices = np.random.choice(len(population), sample_size, replace=False)
|
| 254 |
+
pop_sample = population[indices]
|
| 255 |
+
|
| 256 |
+
current_diversity = calculate_diversity_snapshot(pop_sample)
|
| 257 |
+
diversity_history.append(current_diversity)
|
| 258 |
+
|
| 259 |
+
if progress_callback:
|
| 260 |
+
progress_callback(gen + 1, generations, best_score, current_diversity)
|
| 261 |
+
|
| 262 |
+
# 2. Setup Deficit per le mutazioni guidate
|
| 263 |
+
if best_coverage is None:
|
| 264 |
+
current_gap = target_demand
|
| 265 |
+
else:
|
| 266 |
+
current_gap = target_demand - best_coverage
|
| 267 |
+
|
| 268 |
+
daily_deficit = np.sum(np.maximum(current_gap, 0), axis=1)
|
| 269 |
+
total_deficit = np.sum(daily_deficit)
|
| 270 |
+
if total_deficit > 0:
|
| 271 |
+
daily_deficit_probs = daily_deficit / total_deficit
|
| 272 |
+
else:
|
| 273 |
+
daily_deficit_probs = np.ones(7) / 7.0
|
| 274 |
+
|
| 275 |
+
# 3. Next Gen & Elitismo
|
| 276 |
+
new_pop = []
|
| 277 |
+
n_elites = max(2, int(len(population) * elitism_rate))
|
| 278 |
+
elite_indices = np.argsort(scores)[:n_elites]
|
| 279 |
+
for idx in elite_indices:
|
| 280 |
+
new_pop.append(population[idx].copy())
|
| 281 |
+
|
| 282 |
+
while len(new_pop) < len(population):
|
| 283 |
+
p1 = tournament_selection(population, scores)
|
| 284 |
+
p2 = tournament_selection(population, scores)
|
| 285 |
+
c1, c2 = crossover(p1, p2)
|
| 286 |
+
new_pop.append(mutate(c1, slots_cache, daily_deficit_probs))
|
| 287 |
+
if len(new_pop) < len(population):
|
| 288 |
+
new_pop.append(mutate(c2, slots_cache, daily_deficit_probs))
|
| 289 |
+
|
| 290 |
+
population = np.array(new_pop)
|
| 291 |
+
|
| 292 |
+
print("[*] Estrazione top 5 soluzioni uniche...")
|
| 293 |
+
final_scores = calculate_population_fitness_parallel(
|
| 294 |
+
population, masks, lengths, target_days_arr, cons_types, cons_vals,
|
| 295 |
+
target_demand, weights_arr, daily_slots_scalar
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
sorted_indices = np.argsort(final_scores)
|
| 299 |
+
top_solutions = []
|
| 300 |
+
seen_hashes = set()
|
| 301 |
+
|
| 302 |
+
for idx in sorted_indices:
|
| 303 |
+
ind = population[idx]
|
| 304 |
+
ind_hash = ind.tobytes()
|
| 305 |
+
if ind_hash in seen_hashes: continue
|
| 306 |
+
seen_hashes.add(ind_hash)
|
| 307 |
+
|
| 308 |
+
details = calculate_detailed_score(
|
| 309 |
+
ind, masks, lengths, target_days_arr, cons_types, cons_vals,
|
| 310 |
+
target_demand, weights_arr, daily_slots_scalar
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
sol_data = {
|
| 314 |
+
"schedule": ind.copy(),
|
| 315 |
+
"total_score": details[0],
|
| 316 |
+
"understaffing": details[1],
|
| 317 |
+
"overstaffing": details[2],
|
| 318 |
+
"homogeneity": details[3],
|
| 319 |
+
"soft_preferences": details[4],
|
| 320 |
+
"contract": details[5],
|
| 321 |
+
"equity": details[6]
|
| 322 |
+
}
|
| 323 |
+
top_solutions.append(sol_data)
|
| 324 |
+
|
| 325 |
+
if len(top_solutions) >= 5: break
|
| 326 |
+
|
| 327 |
+
# Fallback in caso di mancata convergenza su soluzioni uniche
|
| 328 |
+
if not top_solutions and best_overall is not None:
|
| 329 |
+
details = calculate_detailed_score(
|
| 330 |
+
best_overall, masks, lengths, target_days_arr, cons_types, cons_vals,
|
| 331 |
+
target_demand, weights_arr, daily_slots_scalar
|
| 332 |
+
)
|
| 333 |
+
top_solutions.append({
|
| 334 |
+
"schedule": best_overall,
|
| 335 |
+
"total_score": details[0],
|
| 336 |
+
"understaffing": details[1],
|
| 337 |
+
"overstaffing": details[2],
|
| 338 |
+
"homogeneity": details[3],
|
| 339 |
+
"soft_preferences": details[4],
|
| 340 |
+
"contract": details[5],
|
| 341 |
+
"equity": details[6]
|
| 342 |
+
})
|
| 343 |
+
|
| 344 |
+
return top_solutions, diversity_history
|
src/engine/mutation.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import random
|
| 3 |
+
from src.config import cfg
|
| 4 |
+
|
| 5 |
+
CODE_OFF = -1
|
| 6 |
+
|
| 7 |
+
def mutate(individual, slots_cache, daily_deficit_probs):
|
| 8 |
+
"""
|
| 9 |
+
Applica operatore di mutazione con logica ibrida (Guided/Random).
|
| 10 |
+
"""
|
| 11 |
+
mut_rate = cfg.genetic_params.get('mutation_rate', 0.4)
|
| 12 |
+
split_rate = cfg.genetic_params.get('guided_mutation_split', 0.4)
|
| 13 |
+
|
| 14 |
+
# Early exit se non triggeriamo la mutazione (risparmio computazionale)
|
| 15 |
+
if random.random() > mut_rate:
|
| 16 |
+
return individual
|
| 17 |
+
|
| 18 |
+
mutated = individual.copy()
|
| 19 |
+
num_emps, num_days = mutated.shape
|
| 20 |
+
emp_idx = random.randint(0, num_emps - 1)
|
| 21 |
+
|
| 22 |
+
if random.random() < split_rate:
|
| 23 |
+
# --- MUTATION STRATEGY 1: Day Swap (Guided) ---
|
| 24 |
+
# Sposta un turno da un giorno all'altro cercando di coprire i deficit operativi
|
| 25 |
+
row = mutated[emp_idx]
|
| 26 |
+
days_worked = np.where(row >= 0)[0]
|
| 27 |
+
days_off = np.where(row == CODE_OFF)[0]
|
| 28 |
+
|
| 29 |
+
if len(days_worked) > 0 and len(days_off) > 0:
|
| 30 |
+
|
| 31 |
+
# 1. Selezione del giorno da riempire (pesata sul deficit)
|
| 32 |
+
try:
|
| 33 |
+
probs_off = daily_deficit_probs[days_off]
|
| 34 |
+
if np.sum(probs_off) > 0:
|
| 35 |
+
probs_off = probs_off / np.sum(probs_off)
|
| 36 |
+
day_to_fill = np.random.choice(days_off, p=probs_off)
|
| 37 |
+
else:
|
| 38 |
+
day_to_fill = np.random.choice(days_off)
|
| 39 |
+
except:
|
| 40 |
+
# Safe check in caso di anomalie nei tensori
|
| 41 |
+
day_to_fill = np.random.choice(days_off)
|
| 42 |
+
|
| 43 |
+
# 2. Selezione del giorno da svuotare (inversamente proporzionale al deficit)
|
| 44 |
+
try:
|
| 45 |
+
probs_work = 1.0 - daily_deficit_probs[days_worked]
|
| 46 |
+
probs_work = probs_work + 0.01 # Smoothing per evitare probabilità nulle
|
| 47 |
+
probs_work = probs_work / np.sum(probs_work)
|
| 48 |
+
day_to_empty = np.random.choice(days_worked, p=probs_work)
|
| 49 |
+
except:
|
| 50 |
+
day_to_empty = np.random.choice(days_worked)
|
| 51 |
+
|
| 52 |
+
# Esegue lo swap solo se esistono slot validi nella cache pre-calcolata
|
| 53 |
+
valid_slots = slots_cache[emp_idx][day_to_fill]
|
| 54 |
+
if len(valid_slots) > 0:
|
| 55 |
+
mutated[emp_idx, day_to_fill] = np.random.choice(valid_slots)
|
| 56 |
+
mutated[emp_idx, day_to_empty] = CODE_OFF
|
| 57 |
+
else:
|
| 58 |
+
# --- MUTATION STRATEGY 2: Time Shift ---
|
| 59 |
+
# Perturba randomicamente l'orario di inizio turno sullo stesso giorno
|
| 60 |
+
day_idx = random.randint(0, 6)
|
| 61 |
+
if mutated[emp_idx, day_idx] >= 0:
|
| 62 |
+
valid = slots_cache[emp_idx][day_idx]
|
| 63 |
+
if len(valid) > 0:
|
| 64 |
+
mutated[emp_idx, day_idx] = np.random.choice(valid)
|
| 65 |
+
|
| 66 |
+
return mutated
|
src/engine/selection.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from numba import njit, prange
|
| 3 |
+
from src.config import cfg
|
| 4 |
+
|
| 5 |
+
# Costanti di dominio (mapping per la vettorializzazione)
|
| 6 |
+
CODE_OFF = -1
|
| 7 |
+
CODE_ABSENCE = -2
|
| 8 |
+
CONS_TYPE_HARD = 1
|
| 9 |
+
CONS_TYPE_SOFT = 2
|
| 10 |
+
CONS_TYPE_ABSENCE = 3
|
| 11 |
+
|
| 12 |
+
@njit(cache=True)
|
| 13 |
+
def get_valid_start_slots_numba(day_idx, shift_len, cons_type, cons_val, closing_slot, is_closed):
|
| 14 |
+
"""
|
| 15 |
+
Calcolo degli slot di inizio turno validi.
|
| 16 |
+
JIT-compiled per massimizzare il throughput. Ritorna strictly un ndarray.
|
| 17 |
+
"""
|
| 18 |
+
# 1. Vincoli Hard (Assenze e Turni Fissi)
|
| 19 |
+
if cons_type == CONS_TYPE_ABSENCE:
|
| 20 |
+
return np.array([CODE_ABSENCE], dtype=np.int64)
|
| 21 |
+
|
| 22 |
+
if cons_type == CONS_TYPE_HARD:
|
| 23 |
+
target_slot = cons_val
|
| 24 |
+
# Boundary check per evitare sforamenti oltre l'orario di chiusura
|
| 25 |
+
if is_closed or (target_slot + shift_len > closing_slot):
|
| 26 |
+
return np.array([CODE_OFF], dtype=np.int64)
|
| 27 |
+
return np.array([target_slot], dtype=np.int64)
|
| 28 |
+
|
| 29 |
+
# 2. Assegnazione Standard
|
| 30 |
+
if is_closed:
|
| 31 |
+
return np.empty(0, dtype=np.int64)
|
| 32 |
+
|
| 33 |
+
max_start = closing_slot - shift_len
|
| 34 |
+
if max_start < 0:
|
| 35 |
+
return np.empty(0, dtype=np.int64)
|
| 36 |
+
|
| 37 |
+
# Generazione array di slot contigui validi
|
| 38 |
+
return np.arange(0, max_start + 1, dtype=np.int64)
|
| 39 |
+
|
| 40 |
+
@njit(cache=True)
|
| 41 |
+
def calculate_fitness_numba(individual, masks, lengths, target_days_arr, cons_types, cons_vals, target_demand, weights, daily_slots):
|
| 42 |
+
"""
|
| 43 |
+
Motore core di valutazione della fitness.
|
| 44 |
+
Aggrega i vincoli operativi (contratti, weekend, preferenze) e vettorializza le penalità.
|
| 45 |
+
"""
|
| 46 |
+
num_emps = len(lengths)
|
| 47 |
+
num_days = 7
|
| 48 |
+
num_slots = daily_slots
|
| 49 |
+
|
| 50 |
+
current_coverage = np.zeros((num_days, num_slots), dtype=np.int64)
|
| 51 |
+
|
| 52 |
+
homogeneity_penalty = 0.0
|
| 53 |
+
soft_pref_penalty = 0.0
|
| 54 |
+
contract_penalty = 0.0
|
| 55 |
+
equity_penalty = 0.0
|
| 56 |
+
|
| 57 |
+
W_UNDER = weights[0]
|
| 58 |
+
W_OVER = weights[1]
|
| 59 |
+
W_HOMO = weights[2]
|
| 60 |
+
W_SOFT = weights[3]
|
| 61 |
+
|
| 62 |
+
PENALTY_PER_DAY_MISMATCH = W_UNDER * 2.0
|
| 63 |
+
|
| 64 |
+
sum_wk_work = 0.0
|
| 65 |
+
sum_sq_wk_work = 0.0
|
| 66 |
+
|
| 67 |
+
# Scansione matrice turni per estrazione metriche operative
|
| 68 |
+
for i in range(num_emps):
|
| 69 |
+
mask_len = lengths[i]
|
| 70 |
+
real_mask = masks[i, :mask_len]
|
| 71 |
+
|
| 72 |
+
days_worked_count = 0
|
| 73 |
+
weekend_days = 0
|
| 74 |
+
|
| 75 |
+
sum_slots = 0.0
|
| 76 |
+
sum_sq_slots = 0.0
|
| 77 |
+
count_slots = 0
|
| 78 |
+
|
| 79 |
+
for day in range(num_days):
|
| 80 |
+
start = individual[i, day]
|
| 81 |
+
|
| 82 |
+
if start >= 0:
|
| 83 |
+
days_worked_count += 1
|
| 84 |
+
sum_slots += start
|
| 85 |
+
sum_sq_slots += start**2
|
| 86 |
+
count_slots += 1
|
| 87 |
+
if day >= 5: weekend_days += 1
|
| 88 |
+
|
| 89 |
+
end = min(start + mask_len, num_slots)
|
| 90 |
+
real_len = end - start
|
| 91 |
+
|
| 92 |
+
# Proiezione del fenotipo (maschera VDT) sulla coverage matrix
|
| 93 |
+
if real_len > 0:
|
| 94 |
+
current_coverage[day, start:end] += real_mask[:real_len]
|
| 95 |
+
|
| 96 |
+
# Valutazione desiderata (Soft Constraint)
|
| 97 |
+
if cons_types[i, day] == CONS_TYPE_SOFT and start >= 0:
|
| 98 |
+
target_slot = cons_vals[i, day]
|
| 99 |
+
if start != target_slot:
|
| 100 |
+
soft_pref_penalty += abs(start - target_slot)
|
| 101 |
+
|
| 102 |
+
# Check target contrattuale (mix lavorativi vs riposi)
|
| 103 |
+
tgt = target_days_arr[i]
|
| 104 |
+
if days_worked_count != tgt:
|
| 105 |
+
contract_penalty += (abs(days_worked_count - tgt) * PENALTY_PER_DAY_MISMATCH)
|
| 106 |
+
|
| 107 |
+
# Calcolo varianza per penalizzare pattern orari troppo frammentati
|
| 108 |
+
if count_slots > 1:
|
| 109 |
+
mean = sum_slots / count_slots
|
| 110 |
+
var = (sum_sq_slots / count_slots) - (mean**2)
|
| 111 |
+
if var > 0: homogeneity_penalty += np.sqrt(var)
|
| 112 |
+
|
| 113 |
+
sum_wk_work += weekend_days
|
| 114 |
+
sum_sq_wk_work += weekend_days**2
|
| 115 |
+
|
| 116 |
+
# Equità aziendale: bilanciamento dei carichi sui weekend tra dipendenti
|
| 117 |
+
if num_emps > 1:
|
| 118 |
+
mean_wk = sum_wk_work / num_emps
|
| 119 |
+
var_wk = (sum_sq_wk_work / num_emps) - (mean_wk**2)
|
| 120 |
+
if var_wk > 0:
|
| 121 |
+
equity_penalty = np.sqrt(var_wk) * W_HOMO * 10.0
|
| 122 |
+
|
| 123 |
+
# Calcolo delta rispetto al fabbisogno (Demand vs Coverage)
|
| 124 |
+
diff = current_coverage - target_demand
|
| 125 |
+
|
| 126 |
+
under_score = 0.0
|
| 127 |
+
flattened_diff = diff.flatten()
|
| 128 |
+
for val in flattened_diff:
|
| 129 |
+
if val < 0: under_score += abs(val)
|
| 130 |
+
under_score *= W_UNDER
|
| 131 |
+
|
| 132 |
+
over_score = 0.0
|
| 133 |
+
safe_target = target_demand.flatten()
|
| 134 |
+
for k in range(len(flattened_diff)):
|
| 135 |
+
val = flattened_diff[k]
|
| 136 |
+
if val > 0:
|
| 137 |
+
tgt_val = safe_target[k]
|
| 138 |
+
if tgt_val == 0: tgt_val = 1
|
| 139 |
+
over_score += (val / tgt_val)
|
| 140 |
+
over_score *= (W_OVER * 10.0)
|
| 141 |
+
|
| 142 |
+
# Aggregazione Loss Function
|
| 143 |
+
total_score = (under_score +
|
| 144 |
+
over_score +
|
| 145 |
+
(homogeneity_penalty * W_HOMO) +
|
| 146 |
+
(soft_pref_penalty * W_SOFT) +
|
| 147 |
+
contract_penalty +
|
| 148 |
+
equity_penalty)
|
| 149 |
+
|
| 150 |
+
return total_score, current_coverage
|
| 151 |
+
|
| 152 |
+
@njit(parallel=True, cache=True)
|
| 153 |
+
def calculate_population_fitness_parallel(population, masks, lengths, target_days_arr, cons_types, cons_vals, target_demand, weights, daily_slots):
|
| 154 |
+
"""
|
| 155 |
+
Valutazione massiva della popolazione. Sfrutta il multithreading (prange) di Numba.
|
| 156 |
+
"""
|
| 157 |
+
pop_size = len(population)
|
| 158 |
+
scores = np.zeros(pop_size, dtype=np.float64)
|
| 159 |
+
for i in prange(pop_size):
|
| 160 |
+
sc, _ = calculate_fitness_numba(
|
| 161 |
+
population[i],
|
| 162 |
+
masks, lengths, target_days_arr, cons_types, cons_vals,
|
| 163 |
+
target_demand, weights, daily_slots
|
| 164 |
+
)
|
| 165 |
+
scores[i] = sc
|
| 166 |
+
return scores
|
| 167 |
+
|
| 168 |
+
def tournament_selection(population, fitness_scores):
|
| 169 |
+
"""Selezione a torneo standard."""
|
| 170 |
+
k = cfg.genetic_params.get('tournament_size', 5)
|
| 171 |
+
indices = np.random.choice(len(population), k, replace=False)
|
| 172 |
+
best_idx = indices[np.argmin(fitness_scores[indices])]
|
| 173 |
+
return population[best_idx]
|
| 174 |
+
|
| 175 |
+
@njit(cache=True)
|
| 176 |
+
def calculate_detailed_score(individual, masks, lengths, target_days_arr, cons_types, cons_vals, target_demand, weights, daily_slots):
|
| 177 |
+
"""
|
| 178 |
+
Versione verbosa del calcolo fitness usata post-convergenza
|
| 179 |
+
per l'estrazione delle metriche finali di business da mostrare in UI.
|
| 180 |
+
"""
|
| 181 |
+
num_emps = len(lengths)
|
| 182 |
+
num_days = 7
|
| 183 |
+
num_slots = daily_slots
|
| 184 |
+
|
| 185 |
+
current_coverage = np.zeros((num_days, num_slots), dtype=np.int64)
|
| 186 |
+
|
| 187 |
+
homogeneity_penalty = 0.0
|
| 188 |
+
soft_pref_penalty = 0.0
|
| 189 |
+
contract_penalty = 0.0
|
| 190 |
+
equity_penalty = 0.0
|
| 191 |
+
|
| 192 |
+
W_UNDER = weights[0]
|
| 193 |
+
W_OVER = weights[1]
|
| 194 |
+
W_HOMO = weights[2]
|
| 195 |
+
W_SOFT = weights[3]
|
| 196 |
+
|
| 197 |
+
PENALTY_PER_DAY_MISMATCH = W_UNDER * 2.0
|
| 198 |
+
|
| 199 |
+
sum_wk_work = 0.0
|
| 200 |
+
sum_sq_wk_work = 0.0
|
| 201 |
+
|
| 202 |
+
for i in range(num_emps):
|
| 203 |
+
mask_len = lengths[i]
|
| 204 |
+
real_mask = masks[i, :mask_len]
|
| 205 |
+
days_worked_count = 0
|
| 206 |
+
weekend_days = 0
|
| 207 |
+
sum_slots = 0.0
|
| 208 |
+
sum_sq_slots = 0.0
|
| 209 |
+
count_slots = 0
|
| 210 |
+
|
| 211 |
+
for day in range(num_days):
|
| 212 |
+
start = individual[i, day]
|
| 213 |
+
if start >= 0:
|
| 214 |
+
days_worked_count += 1
|
| 215 |
+
sum_slots += start
|
| 216 |
+
sum_sq_slots += start**2
|
| 217 |
+
count_slots += 1
|
| 218 |
+
if day >= 5: weekend_days += 1
|
| 219 |
+
|
| 220 |
+
end = min(start + mask_len, num_slots)
|
| 221 |
+
real_len = end - start
|
| 222 |
+
if real_len > 0:
|
| 223 |
+
current_coverage[day, start:end] += real_mask[:real_len]
|
| 224 |
+
|
| 225 |
+
if cons_types[i, day] == CONS_TYPE_SOFT and start >= 0:
|
| 226 |
+
target_slot = cons_vals[i, day]
|
| 227 |
+
if start != target_slot:
|
| 228 |
+
soft_pref_penalty += abs(start - target_slot)
|
| 229 |
+
|
| 230 |
+
tgt = target_days_arr[i]
|
| 231 |
+
if days_worked_count != tgt:
|
| 232 |
+
contract_penalty += (abs(days_worked_count - tgt) * PENALTY_PER_DAY_MISMATCH)
|
| 233 |
+
|
| 234 |
+
if count_slots > 1:
|
| 235 |
+
mean = sum_slots / count_slots
|
| 236 |
+
var = (sum_sq_slots / count_slots) - (mean**2)
|
| 237 |
+
if var > 0: homogeneity_penalty += np.sqrt(var)
|
| 238 |
+
|
| 239 |
+
sum_wk_work += weekend_days
|
| 240 |
+
sum_sq_wk_work += weekend_days**2
|
| 241 |
+
|
| 242 |
+
if num_emps > 1:
|
| 243 |
+
mean_wk = sum_wk_work / num_emps
|
| 244 |
+
var_wk = (sum_sq_wk_work / num_emps) - (mean_wk**2)
|
| 245 |
+
if var_wk > 0:
|
| 246 |
+
equity_penalty = np.sqrt(var_wk) * W_HOMO * 10.0
|
| 247 |
+
|
| 248 |
+
diff = current_coverage - target_demand
|
| 249 |
+
|
| 250 |
+
under_score = 0.0
|
| 251 |
+
flattened_diff = diff.flatten()
|
| 252 |
+
for val in flattened_diff:
|
| 253 |
+
if val < 0: under_score += abs(val)
|
| 254 |
+
under_score *= W_UNDER
|
| 255 |
+
|
| 256 |
+
over_score = 0.0
|
| 257 |
+
safe_target = target_demand.flatten()
|
| 258 |
+
for k in range(len(flattened_diff)):
|
| 259 |
+
val = flattened_diff[k]
|
| 260 |
+
if val > 0:
|
| 261 |
+
tgt_val = safe_target[k]
|
| 262 |
+
if tgt_val == 0: tgt_val = 1
|
| 263 |
+
over_score += (val / tgt_val)
|
| 264 |
+
over_score *= (W_OVER * 10.0)
|
| 265 |
+
|
| 266 |
+
cost_homo = homogeneity_penalty * W_HOMO
|
| 267 |
+
cost_soft = soft_pref_penalty * W_SOFT
|
| 268 |
+
|
| 269 |
+
total = under_score + over_score + cost_homo + cost_soft + contract_penalty + equity_penalty
|
| 270 |
+
|
| 271 |
+
# Ritorna l'array esploso delle loss per i grafici
|
| 272 |
+
return np.array([total, under_score, over_score, cost_homo, cost_soft, contract_penalty, equity_penalty])
|
src/models/__init__.py
ADDED
|
File without changes
|
src/models/individual.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from src.config import cfg
|
| 3 |
+
|
| 4 |
+
class ShiftPatterns:
|
| 5 |
+
"""
|
| 6 |
+
Modellazione del fenotipo dei turni.
|
| 7 |
+
Mappa le regole contrattuali e i vincoli normativi (es. pause VDT) in tensori 1D
|
| 8 |
+
che verranno successivamente proiettati sulla matrice di coverage.
|
| 9 |
+
"""
|
| 10 |
+
def __init__(self):
|
| 11 |
+
# Parametri normativi (es. Pausa ogni 2 ore per i video-terminalisti)
|
| 12 |
+
self.vdt_interval_min = cfg.system_settings.get('vdt_interval_minutes', 120)
|
| 13 |
+
self.vdt_break_min = cfg.system_settings.get('vdt_break_minutes', 15)
|
| 14 |
+
|
| 15 |
+
# Quantizzazione: trasformazione dai minuti agli slot di sistema
|
| 16 |
+
self.max_work_slots = int(self.vdt_interval_min / cfg.system_slot_minutes)
|
| 17 |
+
self.vdt_slots = max(1, int(self.vdt_break_min / cfg.system_slot_minutes))
|
| 18 |
+
|
| 19 |
+
def _create_block_inside_vdt(self, duration_minutes, flip_strategy=False):
|
| 20 |
+
"""
|
| 21 |
+
Genera un macro-blocco lavorativo iniettando le micro-pause VDT obbligatorie.
|
| 22 |
+
"""
|
| 23 |
+
total_slots = int(round(duration_minutes / cfg.system_slot_minutes))
|
| 24 |
+
mask = np.ones(total_slots, dtype=int)
|
| 25 |
+
|
| 26 |
+
# Early exit: se il turno è più corto dell'intervallo VDT, niente pause
|
| 27 |
+
if total_slots <= self.max_work_slots:
|
| 28 |
+
return mask
|
| 29 |
+
|
| 30 |
+
# Sliding window per "scavare" gli slot di pausa all'interno della maschera booleana
|
| 31 |
+
cursor = 0
|
| 32 |
+
while cursor < total_slots:
|
| 33 |
+
break_start_idx = cursor + self.max_work_slots
|
| 34 |
+
if break_start_idx >= total_slots:
|
| 35 |
+
break
|
| 36 |
+
break_end_idx = min(break_start_idx + self.vdt_slots, total_slots)
|
| 37 |
+
mask[break_start_idx : break_end_idx] = 0
|
| 38 |
+
cursor = break_end_idx
|
| 39 |
+
|
| 40 |
+
# Il flip garantisce varianza fenotipica a parità di orario (es. pausa all'inizio vs alla fine)
|
| 41 |
+
return np.flip(mask) if flip_strategy else mask
|
| 42 |
+
|
| 43 |
+
def get_mask_dynamic(self, work_hours, lunch_minutes, variant_seed=0):
|
| 44 |
+
"""
|
| 45 |
+
Costruisce la firma oraria completa (fenotipo) del dipendente.
|
| 46 |
+
Gestisce dinamicamente split-shift e variazioni deterministiche tramite seed.
|
| 47 |
+
"""
|
| 48 |
+
total_contract_min = work_hours * 60
|
| 49 |
+
lunch_slots = int(round(lunch_minutes / cfg.system_slot_minutes))
|
| 50 |
+
|
| 51 |
+
# Decodifica bit a bit del seed per alterare la struttura delle pause
|
| 52 |
+
# mantenendo un output deterministico per lo stesso ID dipendente
|
| 53 |
+
flip_p1 = (variant_seed % 2) != 0
|
| 54 |
+
flip_p2 = ((variant_seed >> 1) % 2) != 0
|
| 55 |
+
|
| 56 |
+
# Vincolo normativo: i turni lunghi richiedono uno spezzato (es. mattina / pomeriggio)
|
| 57 |
+
if work_hours > 6:
|
| 58 |
+
first_part_min = 4 * 60 # Blocco standard pre-pranzo
|
| 59 |
+
second_part_min = total_contract_min - first_part_min
|
| 60 |
+
has_lunch = (lunch_slots > 0)
|
| 61 |
+
else:
|
| 62 |
+
first_part_min = total_contract_min
|
| 63 |
+
second_part_min = 0
|
| 64 |
+
has_lunch = False
|
| 65 |
+
|
| 66 |
+
mask_part1 = self._create_block_inside_vdt(first_part_min, flip_strategy=flip_p1)
|
| 67 |
+
mask_part2 = self._create_block_inside_vdt(second_part_min, flip_strategy=flip_p2) if second_part_min > 0 else np.array([], dtype=int)
|
| 68 |
+
|
| 69 |
+
# Assemblaggio vettoriale del turno completo
|
| 70 |
+
components = [mask_part1]
|
| 71 |
+
if has_lunch:
|
| 72 |
+
components.append(np.zeros(lunch_slots, dtype=int))
|
| 73 |
+
if len(mask_part2) > 0:
|
| 74 |
+
components.append(mask_part2)
|
| 75 |
+
|
| 76 |
+
return np.concatenate(components)
|
src/problems/__init__.py
ADDED
|
File without changes
|
src/problems/my_problem.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from src.config import cfg
|
| 3 |
+
|
| 4 |
+
# Mapping dei constraint per la vettorializzazione (flag numerici per il motore JIT)
|
| 5 |
+
CONS_TYPE_NONE = 0
|
| 6 |
+
CONS_TYPE_HARD = 1
|
| 7 |
+
CONS_TYPE_SOFT = 2
|
| 8 |
+
CONS_TYPE_ABSENCE = 3
|
| 9 |
+
|
| 10 |
+
def process_demand(raw_data):
|
| 11 |
+
"""
|
| 12 |
+
Upsampling della demand operativa.
|
| 13 |
+
Espande la granularità di business (es. slot da 30/60 min)
|
| 14 |
+
sul clock interno di sistema (es. 15 min) per il calcolo matriciale.
|
| 15 |
+
"""
|
| 16 |
+
weekly_demand = np.zeros((7, cfg.daily_slots), dtype=int)
|
| 17 |
+
ratio = int(cfg.expansion_factor)
|
| 18 |
+
|
| 19 |
+
for day_idx, day_data in enumerate(raw_data):
|
| 20 |
+
# Cast robusto dei valori input (fallback a 0 per dati sporchi/missing)
|
| 21 |
+
input_vals = [int(x) if str(x).isdigit() else 0 for x in day_data[1:]]
|
| 22 |
+
|
| 23 |
+
curr_sys_slot = 0
|
| 24 |
+
for val in input_vals:
|
| 25 |
+
end_sys_slot = curr_sys_slot + ratio
|
| 26 |
+
# Boundary check per evitare out-of-bounds se l'orario di chiusura varia
|
| 27 |
+
if end_sys_slot <= cfg.daily_slots:
|
| 28 |
+
weekly_demand[day_idx, curr_sys_slot:end_sys_slot] = val
|
| 29 |
+
curr_sys_slot += ratio
|
| 30 |
+
|
| 31 |
+
return weekly_demand
|
| 32 |
+
|
| 33 |
+
def convert_employees_to_numpy(employees):
|
| 34 |
+
"""
|
| 35 |
+
Tensorizzazione dell'anagrafica.
|
| 36 |
+
Estrae le liste di dizionari e crea array contigui in memoria (ndarrays)
|
| 37 |
+
per bypassare l'overhead degli oggetti Python dentro i loop di Numba.
|
| 38 |
+
"""
|
| 39 |
+
num_emps = len(employees)
|
| 40 |
+
|
| 41 |
+
# Fallback di sicurezza se l'anagrafica è vuota
|
| 42 |
+
if num_emps == 0:
|
| 43 |
+
return None, None, None, None, None
|
| 44 |
+
|
| 45 |
+
# Ricerca dinamica della shift mask più lunga per dimensionare il tensore 2D
|
| 46 |
+
max_len = max(len(e['mask']) for e in employees)
|
| 47 |
+
|
| 48 |
+
# Inizializzazione tensori pre-allocati
|
| 49 |
+
masks_matrix = np.zeros((num_emps, max_len), dtype=int)
|
| 50 |
+
lengths_array = np.zeros(num_emps, dtype=int)
|
| 51 |
+
target_days_array = np.zeros(num_emps, dtype=int)
|
| 52 |
+
|
| 53 |
+
cons_type_matrix = np.zeros((num_emps, 7), dtype=int)
|
| 54 |
+
cons_val_matrix = np.full((num_emps, 7), -1, dtype=int)
|
| 55 |
+
|
| 56 |
+
def _to_slot(t_str):
|
| 57 |
+
"""Quantizzazione del timestamp testuale in indice slot di sistema."""
|
| 58 |
+
try:
|
| 59 |
+
h, m = map(int, t_str.split(':'))
|
| 60 |
+
minutes = (h - cfg.client_settings['day_start_hour']) * 60 + m
|
| 61 |
+
return int(minutes / cfg.system_slot_minutes)
|
| 62 |
+
except:
|
| 63 |
+
return -1 # Fallback error code
|
| 64 |
+
|
| 65 |
+
# Compilazione massiva delle matrici
|
| 66 |
+
for i, emp in enumerate(employees):
|
| 67 |
+
curr_len = len(emp['mask'])
|
| 68 |
+
|
| 69 |
+
# Inserimento maschera con zero-padding implicito a destra
|
| 70 |
+
masks_matrix[i, :curr_len] = emp['mask']
|
| 71 |
+
lengths_array[i] = curr_len
|
| 72 |
+
|
| 73 |
+
# Recupero target contrattuale
|
| 74 |
+
target_days_array[i] = emp.get('target_days', 5)
|
| 75 |
+
|
| 76 |
+
# Mapping spaziale dei constraint (Hard/Soft/Assenze)
|
| 77 |
+
constraints = emp.get('constraints', {})
|
| 78 |
+
for day_str, rule in constraints.items():
|
| 79 |
+
d = int(day_str)
|
| 80 |
+
if d < 0 or d > 6:
|
| 81 |
+
continue
|
| 82 |
+
|
| 83 |
+
rtype = rule.get('type')
|
| 84 |
+
if rtype == 'hard':
|
| 85 |
+
cons_type_matrix[i, d] = CONS_TYPE_HARD
|
| 86 |
+
cons_val_matrix[i, d] = _to_slot(rule.get('start_time', '00:00'))
|
| 87 |
+
elif rtype == 'soft':
|
| 88 |
+
cons_type_matrix[i, d] = CONS_TYPE_SOFT
|
| 89 |
+
cons_val_matrix[i, d] = _to_slot(rule.get('start_time', '00:00'))
|
| 90 |
+
elif rtype == 'absence':
|
| 91 |
+
cons_type_matrix[i, d] = CONS_TYPE_ABSENCE
|
| 92 |
+
|
| 93 |
+
return masks_matrix, lengths_array, target_days_array, cons_type_matrix, cons_val_matrix
|
src/utils/__init__.py
ADDED
|
File without changes
|
src/utils/demand_processing.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
|
| 3 |
+
def align_demand_to_grid(raw_demand_row, current_config):
|
| 4 |
+
"""
|
| 5 |
+
Allinea la time-series della demand giornaliera alla griglia oraria di configurazione.
|
| 6 |
+
Gestisce dinamicamente truncation e padding in base a shift operativi di start/end.
|
| 7 |
+
"""
|
| 8 |
+
# Estrazione parametri di quantizzazione temporale
|
| 9 |
+
start_h = current_config['client_settings']['day_start_hour']
|
| 10 |
+
end_h = current_config['client_settings']['day_end_hour']
|
| 11 |
+
slot_min = current_config['client_settings']['planning_slot_minutes']
|
| 12 |
+
|
| 13 |
+
slots_per_day = int((end_h - start_h) * 60 / slot_min)
|
| 14 |
+
|
| 15 |
+
# Fallback strutturale per payload mancanti o corrotti
|
| 16 |
+
if not raw_demand_row:
|
| 17 |
+
return np.ones(slots_per_day, dtype=int)
|
| 18 |
+
|
| 19 |
+
# Early exit se la dimensionalità è già coerente con il setup
|
| 20 |
+
if len(raw_demand_row) == slots_per_day:
|
| 21 |
+
return np.array(raw_demand_row, dtype=int)
|
| 22 |
+
|
| 23 |
+
# --- RESHAPING PIPELINE ---
|
| 24 |
+
# Inizializzazione target array con baseline di staff (safety net = 1)
|
| 25 |
+
adjusted_demand = np.ones(slots_per_day, dtype=int)
|
| 26 |
+
|
| 27 |
+
# Euristica "Best Effort" per il mapping dei dati storici sulla nuova griglia.
|
| 28 |
+
# Assunto: invarianza del delta temporale (slot_min).
|
| 29 |
+
# TODO: In caso di mutazione della granularità (es. 15m -> 30m) serve un resampler/interpolatore.
|
| 30 |
+
limit = min(len(raw_demand_row), slots_per_day)
|
| 31 |
+
adjusted_demand[:limit] = raw_demand_row[:limit]
|
| 32 |
+
|
| 33 |
+
return adjusted_demand
|
| 34 |
+
|
| 35 |
+
def sanitize_weekly_demand(weekly_demand, current_config):
|
| 36 |
+
"""
|
| 37 |
+
Pipeline di sanitizzazione massiva per l'intera matrice settimanale.
|
| 38 |
+
Esegue stripping delle label testuali ed enforcing della consistenza dimensionale (7xN).
|
| 39 |
+
"""
|
| 40 |
+
sanitized = []
|
| 41 |
+
|
| 42 |
+
# Enforcing del vincolo a 7 giorni operativi
|
| 43 |
+
for i in range(7):
|
| 44 |
+
if i < len(weekly_demand):
|
| 45 |
+
row_data = weekly_demand[i]
|
| 46 |
+
|
| 47 |
+
# Stripping della label descrittiva se presente (es. "Giorno_0")
|
| 48 |
+
if isinstance(row_data[0], str):
|
| 49 |
+
row_vals = row_data[1:]
|
| 50 |
+
else:
|
| 51 |
+
row_vals = row_data
|
| 52 |
+
|
| 53 |
+
aligned = align_demand_to_grid(row_vals, current_config)
|
| 54 |
+
sanitized.append(aligned)
|
| 55 |
+
else:
|
| 56 |
+
# Imputazione di una curva flat di fallback per le giornate out-of-bounds
|
| 57 |
+
dummy = align_demand_to_grid([], current_config)
|
| 58 |
+
sanitized.append(dummy)
|
| 59 |
+
|
| 60 |
+
return sanitized
|
src/utils/generator.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import random
|
| 3 |
+
from src.utils.hf_storage import list_activities, upload_new_scenario
|
| 4 |
+
|
| 5 |
+
def generate_demand_curve(slots_per_day, planning_slot, peak_staff, shape_type):
|
| 6 |
+
"""
|
| 7 |
+
Generatore di workload sintetico basato su distribuzioni Gaussiane.
|
| 8 |
+
Modella profili di carico tipici dei settori BPO e Operations su base slot.
|
| 9 |
+
"""
|
| 10 |
+
daily_req = []
|
| 11 |
+
|
| 12 |
+
for s in range(slots_per_day):
|
| 13 |
+
hour = 8 + (s * planning_slot / 60) # Offset dalle 08:00
|
| 14 |
+
|
| 15 |
+
if shape_type == "double_bell":
|
| 16 |
+
# Distribuzione Bimodale (M-Shape): Tipica dell'Inbound Voice BPO (Picchi 11:00 e 16:00)
|
| 17 |
+
val = np.exp(-((hour - 11)**2) / 4) + np.exp(-((hour - 16)**2) / 4)
|
| 18 |
+
val = val * 0.8 # Normalizzazione euristica
|
| 19 |
+
|
| 20 |
+
elif shape_type == "single_bell_center":
|
| 21 |
+
# Unimodale centrata: Tipica del settore Delivery/Food o Customer Care pausa pranzo
|
| 22 |
+
val = np.exp(-((hour - 13)**2) / 9)
|
| 23 |
+
|
| 24 |
+
elif shape_type == "morning_peak":
|
| 25 |
+
# Skewed left: Supporto Tecnico B2B o Helpdesk IT (Picco decrescente dalle 09:30)
|
| 26 |
+
val = np.exp(-((hour - 9.5)**2) / 5)
|
| 27 |
+
|
| 28 |
+
elif shape_type == "steady_high":
|
| 29 |
+
# Workload Flat: Backoffice, Data Entry o Processi Asincroni
|
| 30 |
+
# Iniezione di white noise per evitare un rettangolo artificiale
|
| 31 |
+
noise = np.random.normal(0, 0.05)
|
| 32 |
+
val = 0.8 + noise
|
| 33 |
+
|
| 34 |
+
else: # Fallback
|
| 35 |
+
val = 0.5
|
| 36 |
+
|
| 37 |
+
# Scaling del volume basato sulla capacity massima
|
| 38 |
+
staff_needed = int(val * peak_staff)
|
| 39 |
+
|
| 40 |
+
# Lower-bound di sicurezza: previene divisioni per zero o matrici vuote nei layer a valle
|
| 41 |
+
daily_req.append(max(5, staff_needed))
|
| 42 |
+
|
| 43 |
+
return daily_req
|
| 44 |
+
|
| 45 |
+
def generate_scenario_files(scenario_name, num_employees, mix_ratios, curve_shape="double_bell"):
|
| 46 |
+
"""
|
| 47 |
+
Orchestratore per il bootstrap di scenari di test.
|
| 48 |
+
Istanzia anagrafiche, time-series della demand e hyper-parametri standard,
|
| 49 |
+
pushandoli direttamente sul Data Lake (HF Dataset).
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
# 1. State check sul repository remoto
|
| 53 |
+
existing_activities = list_activities()
|
| 54 |
+
if scenario_name in existing_activities:
|
| 55 |
+
return False, f"Esiste già uno scenario con nome '{scenario_name}'."
|
| 56 |
+
|
| 57 |
+
PLANNING_SLOT = 30
|
| 58 |
+
|
| 59 |
+
# 2. Iniezione Configurazione Base (Default Engine Params)
|
| 60 |
+
activity_conf = {
|
| 61 |
+
"client_settings": {
|
| 62 |
+
"planning_slot_minutes": PLANNING_SLOT,
|
| 63 |
+
"day_start_hour": 8,
|
| 64 |
+
"day_end_hour": 22
|
| 65 |
+
},
|
| 66 |
+
"operating_hours": {
|
| 67 |
+
"default": "08:00-22:00",
|
| 68 |
+
"exceptions": {}
|
| 69 |
+
},
|
| 70 |
+
"weights": {
|
| 71 |
+
"understaffing": 1000.0,
|
| 72 |
+
"overstaffing": 10.0,
|
| 73 |
+
"homogeneity": 400.0,
|
| 74 |
+
"soft_preference": 50.0
|
| 75 |
+
},
|
| 76 |
+
"genetic_params": {
|
| 77 |
+
"population_size": 1000,
|
| 78 |
+
"generations": 350,
|
| 79 |
+
"mutation_rate": 0.45,
|
| 80 |
+
"crossover_rate": 0.85,
|
| 81 |
+
"elitism_rate": 0.02,
|
| 82 |
+
"tournament_size": 2,
|
| 83 |
+
"heuristic_rate": 0.4,
|
| 84 |
+
"heuristic_noise": 0.5
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
# 3. Campionamento Anagrafica (Contract Mix)
|
| 89 |
+
employees = []
|
| 90 |
+
contracts_def = {
|
| 91 |
+
"FT40": {"wh": 8, "bd": 30},
|
| 92 |
+
"PT30": {"wh": 6, "bd": 0},
|
| 93 |
+
"PT20": {"wh": 4, "bd": 0}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
contract_pool = []
|
| 97 |
+
for c_type, pct in mix_ratios.items():
|
| 98 |
+
count = int(num_employees * (pct / 100.0))
|
| 99 |
+
contract_pool.extend([c_type] * count)
|
| 100 |
+
|
| 101 |
+
# Padding contrattuale per gestire eventuali sfridi degli arrotondamenti percentuali
|
| 102 |
+
while len(contract_pool) < num_employees:
|
| 103 |
+
contract_pool.append("FT40")
|
| 104 |
+
|
| 105 |
+
random.shuffle(contract_pool)
|
| 106 |
+
|
| 107 |
+
for i, c_type in enumerate(contract_pool):
|
| 108 |
+
specs = contracts_def[c_type]
|
| 109 |
+
# Assegnazione probabilistica dei pattern di flessibilità settimanale (Work vs Off)
|
| 110 |
+
if c_type == "FT40":
|
| 111 |
+
mix = {"WORK": 5, "OFF": 2}
|
| 112 |
+
else:
|
| 113 |
+
mix = {"WORK": 6, "OFF": 1} if random.random() < 0.3 else {"WORK": 5, "OFF": 2}
|
| 114 |
+
|
| 115 |
+
emp = {
|
| 116 |
+
"id": f"User_{i:03d}_{c_type}",
|
| 117 |
+
"contract": c_type,
|
| 118 |
+
"work_hours": float(specs["wh"]),
|
| 119 |
+
"break_duration": specs["bd"],
|
| 120 |
+
"shift_mix": mix,
|
| 121 |
+
"constraints": {}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
# Iniezione randomica di soft-constraints (es. preferenza oraria)
|
| 125 |
+
if random.random() < 0.2:
|
| 126 |
+
emp["constraints"]["0"] = {"type": "soft", "start_time": "09:00"}
|
| 127 |
+
|
| 128 |
+
employees.append(emp)
|
| 129 |
+
|
| 130 |
+
# 4. Generazione Time-Series della Demand
|
| 131 |
+
slots_per_day = int((22 - 8) * 60 / PLANNING_SLOT)
|
| 132 |
+
weekly_demand = []
|
| 133 |
+
|
| 134 |
+
# Baseline calcolata sul 70% della forza lavoro (forza un understaffing strutturale per sfidare il motore)
|
| 135 |
+
peak_staff = int(num_employees * 0.7)
|
| 136 |
+
|
| 137 |
+
base_daily_curve = generate_demand_curve(slots_per_day, PLANNING_SLOT, peak_staff, curve_shape)
|
| 138 |
+
|
| 139 |
+
for day in range(7):
|
| 140 |
+
# Data Augmentation: applicazione di rumore (+/- 10%) per sfasare i pattern giornalieri
|
| 141 |
+
daily_req_noisy = []
|
| 142 |
+
for val in base_daily_curve:
|
| 143 |
+
noise_factor = random.uniform(0.9, 1.1)
|
| 144 |
+
daily_req_noisy.append(int(val * noise_factor))
|
| 145 |
+
|
| 146 |
+
weekly_demand.append([f"Giorno_{day}"] + daily_req_noisy)
|
| 147 |
+
|
| 148 |
+
# 5. Pipeline I/O verso HF Hub
|
| 149 |
+
try:
|
| 150 |
+
upload_new_scenario(scenario_name, activity_conf, employees, weekly_demand)
|
| 151 |
+
return True, f"Scenario '{scenario_name}' inizializzato con successo (Shape: {curve_shape})."
|
| 152 |
+
except Exception as e:
|
| 153 |
+
return False, str(e)
|
src/utils/health.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from numba import njit
|
| 2 |
+
|
| 3 |
+
# --- 1. METRICHE PRE-FLIGHT: Indice di Bilanciamento Evolutivo (IBE) ---
|
| 4 |
+
def calculate_ibe(pop_size, generations, p_cross, p_mut, p_heur, p_elite):
|
| 5 |
+
"""
|
| 6 |
+
Calcola l'Indice di Bilanciamento Evolutivo (IBE), una metrica euristica
|
| 7 |
+
per stimare il trade-off tra esplorazione (crossover/mutazione) e
|
| 8 |
+
sfruttamento o pressione selettiva (euristiche/elitismo).
|
| 9 |
+
|
| 10 |
+
Target empirico di stabilità: 1000 - 3000.
|
| 11 |
+
"""
|
| 12 |
+
# Energia cinetica (Generazione di nuova varianza)
|
| 13 |
+
numerator = (p_cross * pop_size) + (p_mut * pop_size * generations)
|
| 14 |
+
|
| 15 |
+
# Fattori frenanti (Scaling 0-100 per bilanciare l'ordine di grandezza del numeratore)
|
| 16 |
+
h_val_score = max(p_heur * 100.0, 1.0)
|
| 17 |
+
e_val_score = max(p_elite * 100.0, 0.1)
|
| 18 |
+
denominator = h_val_score * e_val_score
|
| 19 |
+
|
| 20 |
+
return numerator / denominator
|
| 21 |
+
|
| 22 |
+
def interpret_ibe(ibe_value):
|
| 23 |
+
"""Valuta il setup dei parametri rispetto al regime di stabilità ottimale."""
|
| 24 |
+
if 1000 <= ibe_value <= 3000:
|
| 25 |
+
return "OTTIMALE (Bilanciato)", "normal"
|
| 26 |
+
elif ibe_value < 1000:
|
| 27 |
+
return "RISCHIO STALLO (Eccessiva pressione selettiva)", "off"
|
| 28 |
+
else:
|
| 29 |
+
return "RISCHIO CAOS (Esplorazione incontrollata)", "inverse"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# --- 2. METRICHE POST-FLIGHT: Distanza di Hamming & Convergenza ---
|
| 33 |
+
@njit(cache=True)
|
| 34 |
+
def calculate_diversity_snapshot(population):
|
| 35 |
+
"""
|
| 36 |
+
Calcola la diversità genetica della popolazione tramite Distanza di Hamming.
|
| 37 |
+
Implementazione JIT con campionamento stocastico per evitare l'overhead O(N^2).
|
| 38 |
+
"""
|
| 39 |
+
pop_size, num_emps, num_days = population.shape
|
| 40 |
+
if pop_size < 2: return 0.0
|
| 41 |
+
|
| 42 |
+
# Clamp del sample size per limitare il costo computazionale
|
| 43 |
+
sample_size = min(50, pop_size)
|
| 44 |
+
total_diffs = 0
|
| 45 |
+
total_comparisons = 0
|
| 46 |
+
|
| 47 |
+
genome_len = num_emps * num_days
|
| 48 |
+
|
| 49 |
+
# Valutazione differenziale cross-individuo
|
| 50 |
+
for i in range(sample_size):
|
| 51 |
+
for j in range(i + 1, pop_size):
|
| 52 |
+
diff_count = 0
|
| 53 |
+
for e in range(num_emps):
|
| 54 |
+
for d in range(num_days):
|
| 55 |
+
if population[i, e, d] != population[j, e, d]:
|
| 56 |
+
diff_count += 1
|
| 57 |
+
|
| 58 |
+
total_diffs += diff_count
|
| 59 |
+
total_comparisons += 1
|
| 60 |
+
|
| 61 |
+
if total_comparisons == 0: return 0.0
|
| 62 |
+
|
| 63 |
+
avg_diff = total_diffs / total_comparisons
|
| 64 |
+
|
| 65 |
+
# Normalizzazione % rispetto allo spazio di ricerca del singolo fenotipo
|
| 66 |
+
diversity_percentage = (avg_diff / genome_len) * 100.0
|
| 67 |
+
return diversity_percentage
|
| 68 |
+
|
| 69 |
+
def analyze_convergence_quality(history):
|
| 70 |
+
"""
|
| 71 |
+
Analizza la time-series della diversità per classificare il regime di convergenza
|
| 72 |
+
(Matura, Prematura o Divergente).
|
| 73 |
+
"""
|
| 74 |
+
if not history:
|
| 75 |
+
return "Dati insufficienti", "off"
|
| 76 |
+
|
| 77 |
+
final_diversity = history[-1]
|
| 78 |
+
|
| 79 |
+
# 1. Divergenza di sistema (Assenza di convergenza locale o globale)
|
| 80 |
+
if final_diversity > 40.0:
|
| 81 |
+
return "⚠️ CRITICO: Caos (Assenza di convergenza)", "inverse"
|
| 82 |
+
|
| 83 |
+
# 2. Regime esplorativo ancora attivo
|
| 84 |
+
if final_diversity >= 5.0:
|
| 85 |
+
return f"✅ SANO: Diversità Attiva ({final_diversity:.2f}%)", "normal"
|
| 86 |
+
|
| 87 |
+
# 3. Analisi del drop-rate per rilevare 'Premature Convergence' (Collasso genotipico)
|
| 88 |
+
threshold_idx = -1
|
| 89 |
+
for i, val in enumerate(history):
|
| 90 |
+
if val < 5.0:
|
| 91 |
+
threshold_idx = i
|
| 92 |
+
break
|
| 93 |
+
|
| 94 |
+
total_steps = len(history)
|
| 95 |
+
|
| 96 |
+
# Se la diversità fisiologica si è mantenuta intatta fino all'ultimo delta
|
| 97 |
+
if threshold_idx == -1:
|
| 98 |
+
return "✅ SANO: Convergenza in corso", "normal"
|
| 99 |
+
|
| 100 |
+
# Classificazione basata sull'epoca del collasso (< 20% delle generazioni totali = prematuro)
|
| 101 |
+
if threshold_idx < (total_steps * 0.20):
|
| 102 |
+
return "⚠️ CRITICO: Convergenza Prematura (Collasso genotipico)", "inverse"
|
| 103 |
+
else:
|
| 104 |
+
return "🏆 ECCELLENTE: Convergenza Matura (Consenso Raggiunto)", "success"
|
src/utils/helpers.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import zlib
|
| 2 |
+
import numpy as np
|
| 3 |
+
from src.models.individual import ShiftPatterns
|
| 4 |
+
from src.config import cfg
|
| 5 |
+
from src.utils.hf_storage import load_json
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def load_employees_from_json(activity_folder):
|
| 9 |
+
"""
|
| 10 |
+
Hydration del modello di dominio anagrafico.
|
| 11 |
+
Recupera i dati dal Dataset remoto e istanzia i fenotipi dei turni (maschere VDT).
|
| 12 |
+
"""
|
| 13 |
+
data = load_json(activity_folder, "employees.json")
|
| 14 |
+
|
| 15 |
+
if not data:
|
| 16 |
+
return []
|
| 17 |
+
|
| 18 |
+
employees = []
|
| 19 |
+
patterns = ShiftPatterns()
|
| 20 |
+
|
| 21 |
+
for record in data:
|
| 22 |
+
w_hours = float(record.get('work_hours', 8.0))
|
| 23 |
+
l_min = int(record.get('break_duration', 0))
|
| 24 |
+
|
| 25 |
+
# Hashing deterministico dell'ID per garantire che la stessa singola risorsa
|
| 26 |
+
# mantenga sempre la stessa firma fenotipica (riproducibilità degli spezzati)
|
| 27 |
+
seed = zlib.adler32(record['id'].encode('utf-8'))
|
| 28 |
+
|
| 29 |
+
mask = patterns.get_mask_dynamic(w_hours, l_min, variant_seed=seed)
|
| 30 |
+
|
| 31 |
+
# Parsing flessibile del target contrattuale (backward compatibility)
|
| 32 |
+
mix = record.get('shift_mix', record.get('weekly_mix', {"WORK": 5, "OFF": 2}))
|
| 33 |
+
target_days = int(mix["WORK"]) if "WORK" in mix else 7 - int(mix.get("OFF", 2))
|
| 34 |
+
|
| 35 |
+
employees.append({
|
| 36 |
+
"id": record['id'],
|
| 37 |
+
"contract": record['contract'],
|
| 38 |
+
"mask": mask,
|
| 39 |
+
"shift_len": len(mask),
|
| 40 |
+
"target_days": target_days,
|
| 41 |
+
"constraints": record.get("constraints", {})
|
| 42 |
+
})
|
| 43 |
+
return employees
|
| 44 |
+
|
| 45 |
+
def check_hours_balance(employees, target_matrix):
|
| 46 |
+
"""
|
| 47 |
+
Validazione strutturale pre-ottimizzazione.
|
| 48 |
+
Calcola la capacità produttiva netta (escludendo shrinkage come pause VDT/Pranzo)
|
| 49 |
+
e la confronta con il total workload richiesto dalla Demand.
|
| 50 |
+
"""
|
| 51 |
+
slot_hours = cfg.system_slot_minutes / 60.0
|
| 52 |
+
total_demand_slots = target_matrix.sum()
|
| 53 |
+
total_demand_hours = total_demand_slots * slot_hours
|
| 54 |
+
|
| 55 |
+
total_capacity_slots = 0
|
| 56 |
+
|
| 57 |
+
for emp in employees:
|
| 58 |
+
# Calcolo della capacità netta computando solo i flag attivi (1) nella maschera booleana
|
| 59 |
+
work_slots = np.sum(emp['mask'])
|
| 60 |
+
days_per_week = emp.get('target_days', 5)
|
| 61 |
+
total_capacity_slots += (work_slots * days_per_week)
|
| 62 |
+
|
| 63 |
+
total_capacity_hours = total_capacity_slots * slot_hours
|
| 64 |
+
|
| 65 |
+
print("\n[*] Analisi Strutturale: Bilancio Capacity vs Demand")
|
| 66 |
+
print(f" ├─ Target Workload: {total_demand_hours:,.1f} h")
|
| 67 |
+
print(f" ├─ Net Capacity: {total_capacity_hours:,.1f} h")
|
| 68 |
+
|
| 69 |
+
diff = total_capacity_hours - total_demand_hours
|
| 70 |
+
|
| 71 |
+
if diff >= 0:
|
| 72 |
+
surplus_perc = (diff / total_demand_hours) * 100
|
| 73 |
+
print(f" └─ STATO: [OK] Surplus Teorico (+{diff:.1f}h / +{surplus_perc:.1f}%)")
|
| 74 |
+
else:
|
| 75 |
+
deficit_perc = (abs(diff) / total_demand_hours) * 100
|
| 76 |
+
print(f" └─ STATO: [WARN] Deficit Strutturale (-{abs(diff):.1f}h / -{deficit_perc:.1f}%)")
|
| 77 |
+
|
| 78 |
+
return diff
|
| 79 |
+
|
| 80 |
+
def minutes_to_time(slot_minutes_count):
|
| 81 |
+
"""
|
| 82 |
+
Utility per la conversione dello slot offset in timestamp leggibile (HH:MM).
|
| 83 |
+
"""
|
| 84 |
+
start_h = cfg.client_settings.get('day_start_hour', 8)
|
| 85 |
+
total_minutes = (start_h * 60) + slot_minutes_count
|
| 86 |
+
|
| 87 |
+
# Safe check per il wrap-around della mezzanotte (modulo 24h) per turni notturni
|
| 88 |
+
total_minutes = total_minutes % (24 * 60)
|
| 89 |
+
|
| 90 |
+
h = int(total_minutes // 60)
|
| 91 |
+
m = int(total_minutes % 60)
|
| 92 |
+
return f"{h:02d}:{m:02d}"
|
src/utils/hf_storage.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 4 |
+
from huggingface_hub.utils import EntryNotFoundError
|
| 5 |
+
|
| 6 |
+
DATASET_REPO_ID = "NextGenTech/geneticWFM-activities"
|
| 7 |
+
|
| 8 |
+
# --- ENVIRONMENT DETECTION ---
|
| 9 |
+
# Switch dinamico Dev/Prod basato sull'injection di SPACE_ID da parte del container HF
|
| 10 |
+
IS_LOCAL = os.environ.get("SPACE_ID") is None
|
| 11 |
+
|
| 12 |
+
# Risoluzione dinamica della project root per gestire l'I/O in local fallback
|
| 13 |
+
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 14 |
+
LOCAL_BASE_PATH = os.path.join(PROJECT_ROOT, "data", "activities")
|
| 15 |
+
|
| 16 |
+
def get_hf_api():
|
| 17 |
+
"""Istanzia il client HF sfruttando i secrets di environment."""
|
| 18 |
+
return HfApi(token=os.environ.get("HF_TOKEN"))
|
| 19 |
+
|
| 20 |
+
def list_activities():
|
| 21 |
+
"""
|
| 22 |
+
Discovery dinamica dei workspace operativi.
|
| 23 |
+
Implementa routing Dev (OS Locale) / Prod (HF Datasets API).
|
| 24 |
+
"""
|
| 25 |
+
if IS_LOCAL:
|
| 26 |
+
if not os.path.exists(LOCAL_BASE_PATH):
|
| 27 |
+
return []
|
| 28 |
+
return sorted([d for d in os.listdir(LOCAL_BASE_PATH) if os.path.isdir(os.path.join(LOCAL_BASE_PATH, d))])
|
| 29 |
+
|
| 30 |
+
# --- PROD LOGIC ---
|
| 31 |
+
api = get_hf_api()
|
| 32 |
+
try:
|
| 33 |
+
files = api.list_repo_files(repo_id=DATASET_REPO_ID, repo_type="dataset")
|
| 34 |
+
activities = set()
|
| 35 |
+
for f in files:
|
| 36 |
+
parts = f.split("/")
|
| 37 |
+
if len(parts) > 2 and parts[0] == "activities":
|
| 38 |
+
activities.add(parts[1])
|
| 39 |
+
return sorted(list(activities))
|
| 40 |
+
except Exception as e:
|
| 41 |
+
print(f"[ERROR] Discovery attività fallita sul layer HF: {e}")
|
| 42 |
+
return []
|
| 43 |
+
|
| 44 |
+
def load_json(activity_name, filename):
|
| 45 |
+
"""
|
| 46 |
+
I/O Read: Deserializzazione del payload JSON.
|
| 47 |
+
"""
|
| 48 |
+
if IS_LOCAL:
|
| 49 |
+
path = os.path.join(LOCAL_BASE_PATH, activity_name, filename)
|
| 50 |
+
if os.path.exists(path):
|
| 51 |
+
with open(path, 'r') as f:
|
| 52 |
+
return json.load(f)
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
# --- PROD LOGIC ---
|
| 56 |
+
repo_path = f"activities/{activity_name}/{filename}"
|
| 57 |
+
try:
|
| 58 |
+
downloaded_path = hf_hub_download(
|
| 59 |
+
repo_id=DATASET_REPO_ID,
|
| 60 |
+
repo_type="dataset",
|
| 61 |
+
filename=repo_path,
|
| 62 |
+
token=os.environ.get("HF_TOKEN")
|
| 63 |
+
)
|
| 64 |
+
with open(downloaded_path, 'r') as f:
|
| 65 |
+
return json.load(f)
|
| 66 |
+
except EntryNotFoundError:
|
| 67 |
+
# Silenziamo l'errore se il file semplicemente non esiste ancora
|
| 68 |
+
return None
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(f"[ERROR] API Download fallito per {repo_path}: {e}")
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
def save_json(activity_name, filename, data):
|
| 74 |
+
"""
|
| 75 |
+
I/O Write: Serializzazione su disco e successiva replica su object storage.
|
| 76 |
+
"""
|
| 77 |
+
if IS_LOCAL:
|
| 78 |
+
path = os.path.join(LOCAL_BASE_PATH, activity_name, filename)
|
| 79 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 80 |
+
with open(path, 'w') as f:
|
| 81 |
+
json.dump(data, f, indent=2)
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
# --- PROD LOGIC ---
|
| 85 |
+
repo_path = f"activities/{activity_name}/{filename}"
|
| 86 |
+
local_tmp_path = f"/tmp/{activity_name}_{filename}"
|
| 87 |
+
|
| 88 |
+
# Scrittura su layer effimero del container (/tmp)
|
| 89 |
+
os.makedirs(os.path.dirname(local_tmp_path), exist_ok=True)
|
| 90 |
+
with open(local_tmp_path, 'w') as f:
|
| 91 |
+
json.dump(data, f, indent=2)
|
| 92 |
+
|
| 93 |
+
api = get_hf_api()
|
| 94 |
+
api.upload_file(
|
| 95 |
+
path_or_fileobj=local_tmp_path,
|
| 96 |
+
path_in_repo=repo_path,
|
| 97 |
+
repo_id=DATASET_REPO_ID,
|
| 98 |
+
repo_type="dataset",
|
| 99 |
+
commit_message=f"Sync {filename} -> {activity_name}"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
def upload_new_scenario(activity_name, activity_conf, employees, weekly_demand):
|
| 103 |
+
"""
|
| 104 |
+
Commit massivo (atomic push) di un nuovo scenario generato.
|
| 105 |
+
Evita commit parziali raggruppando Config, Anagrafica e Demand.
|
| 106 |
+
"""
|
| 107 |
+
if IS_LOCAL:
|
| 108 |
+
base_path = os.path.join(LOCAL_BASE_PATH, activity_name)
|
| 109 |
+
os.makedirs(base_path, exist_ok=True)
|
| 110 |
+
with open(os.path.join(base_path, "activity_config.json"), 'w') as f:
|
| 111 |
+
json.dump(activity_conf, f, indent=2)
|
| 112 |
+
with open(os.path.join(base_path, "employees.json"), 'w') as f:
|
| 113 |
+
json.dump(employees, f, indent=2)
|
| 114 |
+
with open(os.path.join(base_path, "demand.json"), 'w') as f:
|
| 115 |
+
json.dump(weekly_demand, f, indent=2)
|
| 116 |
+
return
|
| 117 |
+
|
| 118 |
+
# --- PROD LOGIC ---
|
| 119 |
+
api = get_hf_api()
|
| 120 |
+
local_dir = f"/tmp/activities/{activity_name}"
|
| 121 |
+
os.makedirs(local_dir, exist_ok=True)
|
| 122 |
+
|
| 123 |
+
# Scrittura layer effimero
|
| 124 |
+
with open(os.path.join(local_dir, "activity_config.json"), 'w') as f:
|
| 125 |
+
json.dump(activity_conf, f, indent=2)
|
| 126 |
+
with open(os.path.join(local_dir, "employees.json"), 'w') as f:
|
| 127 |
+
json.dump(employees, f, indent=2)
|
| 128 |
+
with open(os.path.join(local_dir, "demand.json"), 'w') as f:
|
| 129 |
+
json.dump(weekly_demand, f, indent=2)
|
| 130 |
+
|
| 131 |
+
# Push massivo della cartella temporanea
|
| 132 |
+
api.upload_folder(
|
| 133 |
+
folder_path=local_dir,
|
| 134 |
+
path_in_repo=f"activities/{activity_name}",
|
| 135 |
+
repo_id=DATASET_REPO_ID,
|
| 136 |
+
repo_type="dataset",
|
| 137 |
+
commit_message=f"Init workspace: {activity_name}"
|
| 138 |
+
)
|
src/utils/visualization.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from src.config import cfg
|
| 3 |
+
|
| 4 |
+
def get_final_coverage_matrix(schedule, employees):
|
| 5 |
+
"""
|
| 6 |
+
Proietta il genoma ottimizzato (la schedule finale) sulla matrice temporale.
|
| 7 |
+
Genera il tensore 2D di copertura reale (Staffing effettivo)
|
| 8 |
+
utilizzato per il rendering grafico su UI e l'analisi finale degli scostamenti.
|
| 9 |
+
"""
|
| 10 |
+
num_days = 7
|
| 11 |
+
num_slots = cfg.daily_slots
|
| 12 |
+
|
| 13 |
+
# Inizializzazione matrice globale di copertura
|
| 14 |
+
coverage = np.zeros((num_days, num_slots), dtype=int)
|
| 15 |
+
|
| 16 |
+
for i, emp in enumerate(employees):
|
| 17 |
+
# Estrazione del fenotipo (firma oraria comprensiva di pause VDT)
|
| 18 |
+
mask = emp['mask']
|
| 19 |
+
|
| 20 |
+
for day in range(num_days):
|
| 21 |
+
start = schedule[i, day]
|
| 22 |
+
|
| 23 |
+
# Applicazione solo se il dipendente è schedulato (esclude OFF/Assenze)
|
| 24 |
+
if start >= 0:
|
| 25 |
+
# Boundary check: tronca la maschera se il turno sforerebbe la griglia oraria del giorno
|
| 26 |
+
end = min(start + len(mask), num_slots)
|
| 27 |
+
real_len = end - start
|
| 28 |
+
|
| 29 |
+
# Proiezione algebrica della maschera operativa sulla coverage globale
|
| 30 |
+
if real_len > 0:
|
| 31 |
+
coverage[day, start:end] += mask[:real_len]
|
| 32 |
+
|
| 33 |
+
return coverage
|