GaetanoParente commited on
Commit
9e62f55
·
1 Parent(s): 52d378a

first commit

Browse files
.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