---
appId: orario-scolastico
title: Orario Scolastico
description: "Anagrafiche, cattedre, laboratori, griglia oraria trascinabile, controllo dei vincoli, generazione assistita con blocchi e compresenze, qualità e stampe per un istituto di II grado."
icon: education
lang: it
version: "2.0"
author: ReactiveNET
date: "2026-08-13"
---

::page{title="Avvio" icon="education"}

# Orario Scolastico

Costruisce e mantiene il **quadro orario settimanale** di un istituto secondario
di II grado. Il software è assistivo: propone una collocazione, tu la correggi
trascinando, e il controllo dice subito che cosa si è rotto. Non decide al posto
di chi fa l'orario, e non manda niente a nessuno — tutto resta in questo browser.

## Che cosa fa, e che cosa non fa

| Fa | Non fa |
| --- | --- |
| Anagrafiche di classi, docenti, aule e discipline, con i loro moduli | Non parla col SIDI né con un registro elettronico |
| Cattedre con le ore settimanali e i riferimenti alle anagrafiche | Non ricava il quadro orario ministeriale al posto tuo |
| **Sceglie l'aula**: la disciplina chiede un tipo, il solver trova quale è libera, capiente e nel plesso giusto | Non conosce le attrezzature della singola aula: distingue i tipi che scrivi tu |
| **Blocchi di ore consecutive**, che è come si fa laboratorio | Non spezza un blocco per salvare una preferenza: o ci sta intero, o non ci sta |
| **Compresenze in casella**: il sostegno segue la classe, l'ITP affianca il titolare | Non gestisce le sostituzioni giornaliere né le supplenze |
| Vincoli del docente: giorni di non disponibilità, massimo ore al giorno, mai in prima ora, spostamenti fra plessi | Non conosce i contratti: quello che sa è quello che scrivi in anagrafica |
| Griglia giorno × ora trascinabile: per classe, per docente, per aula | Non si trascina col dito: le correzioni da telefono sono le pre-assegnazioni |
| Controllo dei vincoli, con l'elenco delle violazioni e le celle chiuse | Non impedisce di tenere un orario che viola qualcosa |
| Generazione greedy più miglioramento a scambi e spostamenti | Non garantisce l'ottimo, e può lasciare ore non collocate |
| Misura la qualità: saturazione delle aule, ore buche, giorni liberi ottenuti | Non sceglie per te quale orario è il migliore |
| Stampa una pagina per classe, per docente e per aula, in orizzontale | Non produce il tracciato di nessun applicativo di segreteria |

## Come sono fatti i dati, e perché

Un blocco Python **riscrive per intero** la collezione che gli è affidata: se
scrivesse direttamente l'anagrafica, ogni esecuzione cancellerebbe quello che hai
aggiunto tu. Perciò ogni collezione qui dentro ha **un padrone solo**, e sono due
i tipi.

| Padrone | Collezioni | Come si cambiano |
| --- | --- | --- |
| Tu, con un modulo | le aggiunte alle quattro anagrafiche e alle cattedre, i divieti, le pre-assegnazioni | si scrivono, si correggono e si cancellano dalle loro tabelle |
| Un blocco | le anagrafiche in uso, le cattedre in uso, l'orario, le violazioni, le due misure di qualità | si rifanno premendo il loro pulsante |

Un'anagrafica **in uso** è dunque *costruita*: l'istituto di esempio scritto nel
blocco, più le righe che hai aggiunto tu. Aggiungi, poi premi *Ricostruisci*.

## Semina l'istituto di esempio

L'app parte con un istituto **inventato** — l'IIS «Ada Lovelace», due indirizzi
su due plessi, sei classi, tredici docenti, undici aule fra cui **tre laboratori
e due palestre**, tredici discipline, sessantasei cattedre — perché un guscio
vuoto non si può provare. Le righe si creano soltanto scrivendo, e il documento
non ha modo di dichiararle: le semina un blocco `::python{manual}`, che parte col
suo pulsante **Esegui** e con nient'altro.

Premi i cinque pulsanti in ordine: le cattedre citano gli id delle altre quattro.
La prima esecuzione scarica l'interprete (13 MB, poi resta in cache); nessuno di
questi blocchi usa pacchetti.

::python{data="classi-aggiunte" writes="classi" manual}
```python
# Sei classi, due indirizzi, due plessi. L'id è scritto a mano perché le cattedre
# lo citano. "alunni" serve a scartare un laboratorio troppo piccolo.
result = [
    {"id": "c-1a", "nome": "1A", "anno": "1", "sezione": "A", "alunni": "26",
     "indirizzo": "Informatica e Telecomunicazioni", "aulaBase": "A101", "plesso": "Centrale"},
    {"id": "c-2a", "nome": "2A", "anno": "2", "sezione": "A", "alunni": "24",
     "indirizzo": "Informatica e Telecomunicazioni", "aulaBase": "A102", "plesso": "Centrale"},
    {"id": "c-3a", "nome": "3A", "anno": "3", "sezione": "A", "alunni": "22",
     "indirizzo": "Informatica e Telecomunicazioni", "aulaBase": "A103", "plesso": "Centrale"},
    {"id": "c-1b", "nome": "1B", "anno": "1", "sezione": "B", "alunni": "27",
     "indirizzo": "Amministrazione, Finanza e Marketing", "aulaBase": "B201", "plesso": "Succursale"},
    {"id": "c-2b", "nome": "2B", "anno": "2", "sezione": "B", "alunni": "25",
     "indirizzo": "Amministrazione, Finanza e Marketing", "aulaBase": "B202", "plesso": "Succursale"},
    {"id": "c-3b", "nome": "3B", "anno": "3", "sezione": "B", "alunni": "21",
     "indirizzo": "Amministrazione, Finanza e Marketing", "aulaBase": "B203", "plesso": "Succursale"},
]

# Le classi aggiunte a mano, con il loro id: così le cattedre che le citano
# restano valide da una ricostruzione all'altra.
campi = ["nome", "anno", "sezione", "alunni", "indirizzo", "aulaBase", "plesso"]
for riga in data["classi-aggiunte"]:
    nuova = {"id": riga.get("id", "")}
    for campo in campi:
        nuova[campo] = riga.get(campo, "")
    result.append(nuova)

print("%d classi in anagrafica, di cui %d aggiunte da te."
      % (len(result), len(data["classi-aggiunte"])))
```
::/python

::python{data="docenti-aggiunte" writes="docenti" manual}
```python
# Tredici docenti. "oreCattedra" è il monte ore; quello che le cattedre non
# coprono sono ore a disposizione, non un errore.
#
# I tre vincoli nuovi sono duri e vanno usati con parsimonia: "giorniNon" sono i
# giorni in cui il docente non è in servizio (lo spezzone che completa altrove),
# "maxOreGiorno" il tetto giornaliero, "nonPrimaOra" chi non può aprire la
# giornata. Il giorno libero invece resta una PREFERENZA, pesata da uno slider.
def d(i, cognome, nome, cdc, ore, tipo, libero, plesso,
      giorni_non="", max_ore="", non_prima="false", note=""):
    return {"id": i, "cognome": cognome, "nome": nome, "classeConcorso": cdc,
            "oreCattedra": ore, "tipo": tipo, "giornoLibero": libero,
            "plesso": plesso, "giorniNon": giorni_non, "maxOreGiorno": max_ore,
            "nonPrimaOra": non_prima, "note": note}

result = [
    d("d-basile",     "Basile",     "Chiara",    "A012", "18", "ordinario",     "sab", "Centrale",
      max_ore="5", note="Non più di cinque ore al giorno"),
    d("d-ferraro",    "Ferraro",    "Michele",   "A012", "18", "ordinario",     "lun", "Succursale"),
    d("d-grimaldi",   "Grimaldi",   "Elena",     "AB24", "18", "ordinario",     "mer", "Centrale"),
    d("d-rinaldi",    "Rinaldi",    "Paolo",     "A026", "18", "ordinario",     "ven", "Centrale",
      max_ore="5"),
    d("d-costantini", "Costantini", "Sara",      "A026", "18", "potenziamento", "mar", "Succursale",
      note="Due ore a disposizione"),
    d("d-vitali",     "Vitali",     "Andrea",    "A041", "18", "ordinario",     "gio", "Centrale"),
    d("d-marchetti",  "Marchetti",  "Luca",      "B016", "18", "ITP",           "mar", "Centrale",
      note="Due ore a disposizione"),
    d("d-lombardi",   "Lombardi",   "Davide",    "B016", "11", "ITP",           "gio", "Centrale",
      giorni_non="lun,mar", note="Spezzone: il lunedì e il martedì è in un altro istituto"),
    d("d-desantis",   "De Santis",  "Giulia",    "A045", "18", "ordinario",     "ven", "Succursale"),
    d("d-palmieri",   "Palmieri",   "Roberto",   "A046", "15", "ordinario",     "mer", "Succursale",
      non_prima="true", note="Spezzone di quindici ore, mai in prima ora"),
    d("d-neri",       "Neri",       "Francesca", "A048", "12", "ordinario",     "lun", "Centrale",
      note="Spezzone di dodici ore"),
    d("d-iodice",     "Iodice",     "Marco",     "IRC",  "6",  "religione",     "",    "Centrale"),
    d("d-serra",      "Serra",      "Valentina", "ADSS", "18", "sostegno",      "gio", "Centrale",
      note="Sostegno sulla 2A, in compresenza"),
]

campi = ["cognome", "nome", "classeConcorso", "oreCattedra", "tipo", "giornoLibero",
         "plesso", "giorniNon", "maxOreGiorno", "nonPrimaOra", "note"]
for riga in data["docenti-aggiunte"]:
    nuovo = {"id": riga.get("id", "")}
    for campo in campi:
        nuovo[campo] = riga.get(campo, "")
    result.append(nuovo)

print("%d docenti in anagrafica, di cui %d aggiunti da te."
      % (len(result), len(data["docenti-aggiunte"])))
```
::/python

::python{data="aule-aggiunte" writes="aule" manual}
```python
# Undici aule. Il "tipo" è quello che le discipline chiedono: una disciplina dice
# "voglio un laboratorio", non "voglio LAB1". Ogni plesso ha il suo laboratorio e
# la sua palestra, perché una classe che attraversa la città fra un'ora e l'altra
# è un orario che sulla carta funziona e nella realtà no.
result = [
    {"id": "a-a101", "nome": "A101", "tipo": "aula",        "capienza": "28", "plesso": "Centrale"},
    {"id": "a-a102", "nome": "A102", "tipo": "aula",        "capienza": "28", "plesso": "Centrale"},
    {"id": "a-a103", "nome": "A103", "tipo": "aula",        "capienza": "26", "plesso": "Centrale"},
    {"id": "a-b201", "nome": "B201", "tipo": "aula",        "capienza": "28", "plesso": "Succursale"},
    {"id": "a-b202", "nome": "B202", "tipo": "aula",        "capienza": "27", "plesso": "Succursale"},
    {"id": "a-b203", "nome": "B203", "tipo": "aula",        "capienza": "25", "plesso": "Succursale"},
    {"id": "a-lab1", "nome": "LAB1", "tipo": "laboratorio", "capienza": "26", "plesso": "Centrale"},
    {"id": "a-lab2", "nome": "LAB2", "tipo": "laboratorio", "capienza": "24", "plesso": "Centrale"},
    {"id": "a-lab3", "nome": "LAB3", "tipo": "laboratorio", "capienza": "28", "plesso": "Succursale"},
    {"id": "a-pal",  "nome": "PAL",  "tipo": "palestra",    "capienza": "60", "plesso": "Centrale"},
    {"id": "a-pal2", "nome": "PAL2", "tipo": "palestra",    "capienza": "50", "plesso": "Succursale"},
]

campi = ["nome", "tipo", "capienza", "plesso"]
for riga in data["aule-aggiunte"]:
    nuova = {"id": riga.get("id", "")}
    for campo in campi:
        nuova[campo] = riga.get(campo, "")
    result.append(nuova)

print("%d aule in anagrafica, di cui %d aggiunte da te."
      % (len(result), len(data["aule-aggiunte"])))
```
::/python

::python{data="discipline-aggiunte" writes="discipline" manual}
```python
# "monteOre" è l'ordine di grandezza settimanale della disciplina; le ore che
# valgono davvero sono quelle scritte sulla singola cattedra, classe per classe.
#
# "aulaRichiesta" è un TIPO di aula, non una stanza: vuoto significa l'aula base
# della classe. "blocco" è quante ore consecutive vanno tenute insieme — due, per
# un laboratorio, perché in cinquanta minuti non si monta niente.
def m(i, nome, area, ore, aula, mattino, blocco="1"):
    return {"id": i, "nome": nome, "area": area, "monteOre": ore,
            "aulaRichiesta": aula, "mattino": mattino, "blocco": blocco}

result = [
    m("m-ita", "Italiano",                   "umanistica",  "4", "",            "true"),
    m("m-sto", "Storia",                     "umanistica",  "2", "",            "false"),
    m("m-ing", "Inglese",                    "umanistica",  "3", "",            "true"),
    m("m-mat", "Matematica",                 "scientifica", "4", "",            "true"),
    m("m-sci", "Scienze integrate",          "scientifica", "3", "",            "false"),
    m("m-dir", "Diritto ed economia",        "giuridica",   "3", "",            "false"),
    m("m-inf", "Informatica",                "tecnica",     "4", "laboratorio", "true",  "2"),
    m("m-sr",  "Sistemi e reti",             "tecnica",     "4", "laboratorio", "false", "2"),
    m("m-tec", "Tecnologie e progettazione", "tecnica",     "3", "",            "false"),
    m("m-eaz", "Economia aziendale",         "economica",   "5", "",            "true"),
    m("m-smo", "Scienze motorie",            "motoria",     "2", "palestra",    "false", "2"),
    m("m-rel", "Religione cattolica",        "religione",   "1", "",            "false"),
    m("m-sos", "Sostegno",                   "sostegno",    "0", "",            "false"),
]

campi = ["nome", "area", "monteOre", "aulaRichiesta", "mattino", "blocco"]
for riga in data["discipline-aggiunte"]:
    nuova = {"id": riga.get("id", "")}
    for campo in campi:
        nuova[campo] = riga.get(campo, "")
    result.append(nuova)

print("%d discipline in anagrafica, di cui %d aggiunte da te."
      % (len(result), len(data["discipline-aggiunte"])))
```
::/python

::python{data="cattedre-aggiunte" writes="cattedre" manual}
```python
# Il quadro orario di ogni classe, scritto una volta sola: disciplina, ore
# settimanali, docente titolare. Da qui escono le cattedre, una riga per ogni
# accoppiata classe-disciplina. Trenta ore a classe, sei giorni per sei ore.
#
# L'aula NON è scritta qui: la decide la disciplina chiedendo un tipo, e quale
# stanza di quel tipo la sceglie il generatore. Il campo "aula" resta per il caso
# in cui una classe debba stare in una stanza precisa e in nessun'altra.
quadro = {
    "c-1a": [("m-ita",4,"d-basile"), ("m-sto",2,"d-basile"), ("m-ing",3,"d-grimaldi"),
             ("m-mat",4,"d-rinaldi"), ("m-sci",3,"d-costantini"), ("m-dir",2,"d-palmieri"),
             ("m-inf",4,"d-vitali"), ("m-sr",2,"d-marchetti"), ("m-tec",3,"d-vitali"),
             ("m-smo",2,"d-neri"), ("m-rel",1,"d-iodice")],
    "c-2a": [("m-ita",4,"d-basile"), ("m-sto",2,"d-basile"), ("m-ing",3,"d-grimaldi"),
             ("m-mat",4,"d-rinaldi"), ("m-sci",3,"d-costantini"), ("m-dir",2,"d-palmieri"),
             ("m-inf",4,"d-vitali"), ("m-sr",2,"d-marchetti"), ("m-tec",3,"d-vitali"),
             ("m-smo",2,"d-neri"), ("m-rel",1,"d-iodice")],
    "c-3a": [("m-ita",4,"d-basile"), ("m-sto",2,"d-basile"), ("m-ing",3,"d-grimaldi"),
             ("m-mat",3,"d-rinaldi"), ("m-inf",4,"d-vitali"), ("m-sr",6,"d-marchetti"),
             ("m-tec",3,"d-lombardi"), ("m-eaz",2,"d-desantis"), ("m-smo",2,"d-neri"),
             ("m-rel",1,"d-iodice")],
    "c-1b": [("m-ita",4,"d-ferraro"), ("m-sto",2,"d-ferraro"), ("m-ing",3,"d-grimaldi"),
             ("m-mat",4,"d-rinaldi"), ("m-sci",3,"d-costantini"), ("m-dir",3,"d-palmieri"),
             ("m-eaz",4,"d-desantis"), ("m-inf",2,"d-marchetti"), ("m-tec",2,"d-lombardi"),
             ("m-smo",2,"d-neri"), ("m-rel",1,"d-iodice")],
    "c-2b": [("m-ita",4,"d-ferraro"), ("m-sto",2,"d-ferraro"), ("m-ing",3,"d-grimaldi"),
             ("m-mat",4,"d-costantini"), ("m-sci",3,"d-costantini"), ("m-dir",3,"d-palmieri"),
             ("m-eaz",4,"d-desantis"), ("m-inf",2,"d-marchetti"), ("m-tec",2,"d-lombardi"),
             ("m-smo",2,"d-neri"), ("m-rel",1,"d-iodice")],
    "c-3b": [("m-ita",4,"d-ferraro"), ("m-sto",2,"d-ferraro"), ("m-ing",3,"d-grimaldi"),
             ("m-mat",3,"d-rinaldi"), ("m-eaz",8,"d-desantis"), ("m-dir",5,"d-palmieri"),
             ("m-inf",2,"d-marchetti"), ("m-smo",2,"d-neri"), ("m-rel",1,"d-iodice")],
}

result = []
for classe, righe in quadro.items():
    for disciplina, ore, docente in righe:
        result.append({
            "id": "k-" + classe + "-" + disciplina,
            "classe": classe, "disciplina": disciplina, "docente": docente,
            "aula": "", "ore": str(ore), "blocco": "",
            "compresenza": "false", "affianca": "", "note": "",
        })

# Le due compresenze. Non producono ore proprie: si appoggiano su caselle già
# collocate, e per questo sono sistemate DOPO tutto il resto.
#   - il sostegno segue la classe: nessun "affianca", e prende le sue prime ore;
#   - l'ITP affianca UNA cattedra precisa, che è come si sta in laboratorio.
result.append({
    "id": "k-c-2a-sos", "classe": "c-2a", "disciplina": "m-sos", "docente": "d-serra",
    "aula": "", "ore": "18", "blocco": "", "compresenza": "true", "affianca": "",
    "note": "Segue la 2A: si mette dove è già la classe",
})
result.append({
    "id": "k-c-3a-itp", "classe": "c-3a", "disciplina": "m-inf", "docente": "d-lombardi",
    "aula": "", "ore": "4", "blocco": "", "compresenza": "true", "affianca": "k-c-3a-m-inf",
    "note": "ITP in compresenza sull'Informatica della 3A",
})

campi = ["classe", "disciplina", "docente", "aula", "ore", "blocco",
         "compresenza", "affianca", "note"]
for riga in data["cattedre-aggiunte"]:
    nuova = {"id": riga.get("id", "")}
    for campo in campi:
        nuova[campo] = riga.get(campo, "")
    result.append(nuova)

ore = sum(int(r["ore"] or "0") for r in result if r["compresenza"] != "true")
comp = sum(int(r["ore"] or "0") for r in result if r["compresenza"] == "true")
print("%d cattedre: %d ore disciplinari da collocare, %d ore in compresenza."
      % (len(result), ore, comp))
```
::/python

## E poi

Le quattro anagrafiche stanno in **Anagrafiche**, le cattedre in **Cattedre**. La
griglia si riempie da **Controlli e generazione**: prima *Genera l'orario*, poi
*Controlla i vincoli*, che è anche quello che chiude le celle vietate. Quello che
esce si misura in **Laboratori e qualità**, si corregge trascinando in **Orario**,
e si stampa da **Stampe**.

::/page

::page{title="Anagrafiche" icon="user"}

# Anagrafiche

Le quattro tabelle da cui dipende tutto il resto. Qui si aggiunge e si corregge;
niente di quello che si scrive qui colloca una lezione — le lezioni nascono dalle
cattedre, e le cattedre stanno nella pagina dopo.

Ogni anagrafica ha due tabelle. **Le tue aggiunte** sono tue e non le tocca
nessuno; **l'anagrafica in uso** è quella che leggono le cattedre, la generazione
e le stampe, ed è rifatta dal pulsante *Ricostruisci* in **Avvio** ogni volta che
aggiungi qualcosa.

## Classi

Il numero di **alunni** non è un dato di segreteria messo qui per completezza: è
quello che esclude un laboratorio troppo piccolo, ed è l'unico modo che il solver
ha di saperlo.

::form{path="classi-aggiunte" id="formClasse"}
::input{field="nome" legend="Classe" required help="Come si scrive sull'orario: 1A, 3B"}
::input{field="anno" legend="Anno di corso" type="number" min="1" max="5" required}
::input{field="sezione" legend="Sezione"}
::input{field="alunni" legend="Alunni" type="number" min="0" max="40" help="Serve per la capienza delle aule"}
::input{field="indirizzo" legend="Indirizzo di studio"}
::input{field="aulaBase" legend="Aula base" help="Il nome dell'aula, come compare fra le aule"}
::input{field="plesso" legend="Plesso"}
::save{label="Aggiungi la classe"}
::/form

::if-any{path="classi-aggiunte"}
Le tue **:count{path="classi-aggiunte"}** classi aggiunte, in attesa della
prossima ricostruzione.

::table{path="classi-aggiunte" sort="nome" deletable editform="formClasse"}
::column{field="nome" label="Classe"}
::column{field="anno" label="Anno" align="end"}
::column{field="alunni" label="Alunni" align="end"}
::column{field="indirizzo" label="Indirizzo"}
::column{field="aulaBase" label="Aula base"}
::/table
::/if-any

::if-empty{path="classi"}
L'anagrafica in uso è vuota: torna in **Avvio** e premi il primo pulsante.
::/if-empty

::if-any{path="classi"}
**:count{path="classi"}** classi in uso, **:sum{path="classi" field="alunni"}**
alunni in tutto.

::table{path="classi" search sort="nome" filters="indirizzo,plesso"}
::column{field="nome" label="Classe"}
::column{field="anno" label="Anno" align="end"}
::column{field="alunni" label="Alunni" align="end"}
::column{field="indirizzo" label="Indirizzo"}
::column{field="aulaBase" label="Aula base"}
::column{field="plesso" label="Plesso"}
::/table
::/if-any

## Docenti

`oreCattedra` è il monte ore di cattedra. Quello che le cattedre effettive non
coprono sono **ore a disposizione**, e sono una cosa normale: uno spezzone si
completa altrove, un docente di potenziamento tiene qualche ora libera per le
sostituzioni.

Attenzione alla differenza fra le due colonne che sembrano dire la stessa cosa.
Il **giorno libero** è *richiesto*: la generazione ci prova e lo scrive nella
qualità se non ci riesce. I **giorni di non disponibilità** sono un fatto — quel
giorno il docente è altrove — e sono un vincolo duro: nessuna lezione ci finisce
mai. Metterne troppi è il modo più semplice per rendere l'orario impossibile.

::form{path="docenti-aggiunte" id="formDocente"}
::input{field="cognome" legend="Cognome" required}
::input{field="nome" legend="Nome"}
::input{field="classeConcorso" legend="Classe di concorso" help="A012, AB24, B016, ADSS, IRC"}
::input{field="oreCattedra" legend="Ore di cattedra" type="number" min="0" max="24" required}
::input{field="tipo" legend="Tipo" help="ordinario, sostegno, ITP, potenziamento, religione"}
::input{field="giornoLibero" legend="Giorno libero richiesto" pattern="lun|mar|mer|gio|ven|sab|" message="Scrivi lun, mar, mer, gio, ven, sab, oppure lascia vuoto"}
::input{field="giorniNon" legend="Giorni di non disponibilità" help="Vincolo duro, separati da virgola: lun,mar"}
::input{field="maxOreGiorno" legend="Massimo ore al giorno" type="number" min="0" max="6" help="Vuoto: fino a sei"}
::input{field="nonPrimaOra" legend="Mai in prima ora" type="checkbox"}
::input{field="plesso" legend="Plesso di servizio"}
::input{field="note" legend="Note"}
::save{label="Aggiungi il docente"}
::/form

::if-any{path="docenti-aggiunte"}
::table{path="docenti-aggiunte" sort="cognome" deletable editform="formDocente"}
::column{field="cognome" label="Cognome"}
::column{field="classeConcorso" label="Classe di concorso"}
::column{field="oreCattedra" label="Ore" align="end"}
::column{field="giornoLibero" label="Giorno libero"}
::/table
::/if-any

::if-any{path="docenti"}
**:count{path="docenti"}** docenti in uso, per
**:sum{path="docenti" field="oreCattedra"}** ore di cattedra dichiarate.

::table{path="docenti" search sort="cognome" filters="tipo,plesso,giornoLibero"}
::column{field="cognome" label="Cognome"}
::column{field="nome" label="Nome"}
::column{field="classeConcorso" label="C.d.C."}
::column{field="oreCattedra" label="Ore" align="end"}
::column{field="tipo" label="Tipo"}
::column{field="giornoLibero" label="G. libero"}
::column{field="giorniNon" label="Non disponibile"}
::column{field="maxOreGiorno" label="Max/giorno" align="end"}
::column{field="plesso" label="Plesso"}
::/table
::/if-any

## Aule

Il **tipo** è la parte che lavora: una disciplina non chiede LAB1, chiede *un
laboratorio*, e quale sia lo decide il generatore fra quelli liberi, abbastanza
capienti e nel plesso della classe. Aggiungere un secondo laboratorio è, in
questa app, il modo di far respirare un orario che non si chiude.

::form{path="aule-aggiunte" id="formAula"}
::input{field="nome" legend="Aula" required help="A101, LAB2, PAL"}
::input{field="tipo" legend="Tipo" required help="aula, laboratorio, palestra — è quello che le discipline chiedono"}
::input{field="capienza" legend="Capienza" type="number" min="0"}
::input{field="plesso" legend="Plesso"}
::save{label="Aggiungi l'aula"}
::/form

::if-any{path="aule-aggiunte"}
::table{path="aule-aggiunte" sort="nome" deletable editform="formAula"}
::column{field="nome" label="Aula"}
::column{field="tipo" label="Tipo"}
::column{field="capienza" label="Capienza" align="end"}
::/table
::/if-any

::if-any{path="aule"}
**:count{path="aule"}** aule in uso, capienza media
**:avg{path="aule" field="capienza" decimals="0"}** posti.

::table{path="aule" search sort="nome" filters="tipo,plesso"}
::column{field="nome" label="Aula"}
::column{field="tipo" label="Tipo"}
::column{field="capienza" label="Capienza" align="end"}
::column{field="plesso" label="Plesso"}
::/table
::/if-any

## Discipline

`aulaRichiesta` è quello che rende un'aula un vincolo vero, e da questa versione
è **letto davvero**: scrivi il *tipo* di aula — `laboratorio`, `palestra` — e la
disciplina si farà in una stanza di quel tipo. Vuoto significa l'aula base della
classe.

`blocco` dice quante ore consecutive vanno tenute insieme: due per un
laboratorio. Un blocco è indivisibile — o ci sta intero, o quell'ora non si
colloca — ed è la ragione per cui conviene metterlo solo dove serve davvero.
`mattino` dice che quella disciplina sta meglio nelle prime ore: è una
preferenza, e quanto pesa lo decide uno slider.

::form{path="discipline-aggiunte" id="formDisciplina"}
::input{field="nome" legend="Disciplina" required}
::input{field="area" legend="Area"}
::input{field="monteOre" legend="Monte ore indicativo" type="number" min="0" max="12" help="Le ore che contano sono quelle della cattedra, classe per classe"}
::input{field="aulaRichiesta" legend="Tipo di aula richiesto" help="laboratorio, palestra, oppure vuoto per l'aula base"}
::input{field="blocco" legend="Ore consecutive" type="number" min="1" max="4" help="2 per un laboratorio; vuoto o 1 per un'ora sola"}
::input{field="mattino" legend="Meglio nelle prime ore" type="checkbox"}
::save{label="Aggiungi la disciplina"}
::/form

::if-any{path="discipline-aggiunte"}
::table{path="discipline-aggiunte" sort="nome" deletable editform="formDisciplina"}
::column{field="nome" label="Disciplina"}
::column{field="area" label="Area"}
::column{field="aulaRichiesta" label="Aula richiesta"}
::column{field="blocco" label="Blocco" align="end"}
::/table
::/if-any

::if-any{path="discipline"}
::table{path="discipline" search sort="nome" filters="area,aulaRichiesta"}
::column{field="nome" label="Disciplina"}
::column{field="area" label="Area"}
::column{field="monteOre" label="Monte ore" align="end"}
::column{field="aulaRichiesta" label="Aula richiesta"}
::column{field="blocco" label="Blocco" align="end"}
::column{field="mattino" label="Al mattino"}
::/table
::/if-any

::/page

::page{title="Cattedre" icon="table"}

# Cattedre

Una cattedra è una classe, una disciplina, un docente e le ore settimanali. È
l'unica tabella che genera lavoro: ogni ora dichiarata qui è un'ora che il
generatore deve trovare dove mettere.

I riferimenti sono **id**, non nomi: `c-2a`, `m-mat`, `d-rinaldi`. Si leggono
nelle tabelle delle anagrafiche, e sono fatti così perché rinominare una classe
non deve rompere sessanta cattedre.

**L'aula si lascia vuota.** La decide la disciplina chiedendo un tipo, e quale
stanza di quel tipo la sceglie il generatore ora per ora — che è precisamente il
lavoro che ci si aspetta da un applicativo. Il campo resta per il caso in cui una
classe debba stare in una stanza sola e in nessun'altra.

Una cattedra in **compresenza** non produce ore proprie: si appoggia a caselle
già collocate. Senza `affianca` segue la classe — è il sostegno — e con
`affianca` si mette esattamente sulle ore di quella cattedra, che è come si sta
in laboratorio con l'ITP.

::form{path="cattedre-aggiunte" id="formCattedra"}
::input{field="classe" legend="Classe" required help="L'id della classe: c-2a"}
::input{field="disciplina" legend="Disciplina" required help="L'id della disciplina: m-mat"}
::input{field="docente" legend="Docente" required help="L'id del docente: d-rinaldi"}
::input{field="ore" legend="Ore settimanali" type="number" min="1" max="12" required}
::input{field="aula" legend="Aula obbligata" help="Vuoto: la sceglie il generatore dal tipo che chiede la disciplina"}
::input{field="blocco" legend="Ore consecutive" type="number" min="1" max="4" help="Vuoto: quello della disciplina"}
::input{field="compresenza" legend="Compresenza" type="checkbox"}
::input{field="affianca" legend="Affianca la cattedra" help="L'id di un'altra cattedra; vuoto: segue la classe"}
::input{field="note" legend="Note"}
::save{label="Aggiungi la cattedra"}
::/form

::if-any{path="cattedre-aggiunte"}
::table{path="cattedre-aggiunte" sort="classe" deletable editform="formCattedra"}
::column{field="classe" label="Classe"}
::column{field="disciplina" label="Disciplina"}
::column{field="docente" label="Docente"}
::column{field="ore" label="Ore" align="end"}
::/table
::/if-any

::if-empty{path="cattedre"}
Nessuna cattedra in uso: torna in **Avvio** e premi il quinto pulsante.
::/if-empty

::if-any{path="cattedre"}
**:count{path="cattedre"}** cattedre in uso, per **:sum{path="cattedre" field="ore"}**
ore dichiarate — comprese quelle in compresenza, che non occupano una casella
propria.

::table{path="cattedre" search sort="classe" filters="compresenza" page-size="20"}
::column{field="classe" label="Classe"}
::column{field="disciplina" label="Disciplina"}
::column{field="docente" label="Docente"}
::column{field="ore" label="Ore" align="end"}
::column{field="blocco" label="Blocco" align="end"}
::column{field="compresenza" label="Compresenza"}
::column{field="affianca" label="Affianca"}
::/table
::/if-any

::/page

::page{title="Orario" icon="calendar"}

# Orario

La stessa griglia, guardata da tre parti: la classe, il docente, l'aula. La
settimana è di sei giorni per sei ore, e trascinare una scheda scrive insieme il
**giorno** e l'**ora** di quella lezione. Una casella con la bandierina viene da
una pre-assegnazione e non si muove.

Il trascinamento è quello del mouse. Da un telefono una lezione si toglie col suo
pulsante e si rimette come pre-assegnazione, in **Controlli e generazione**.

Le celle chiuse arrivano dalla collezione delle **violazioni**, che è scritta dal
controllo: finché non lo esegui almeno una volta, la griglia non vieta niente.

Un avvertimento sul trascinamento, ora che esistono i blocchi: la griglia sposta
**una lezione per volta**, quindi trascinare metà di un blocco di due ore lo
spezza. Il controllo non lo segnala — un blocco è una preferenza di generazione,
non un vincolo dell'orario — ma alla prossima generazione tornerà intero.

## Per classe

Scrivi la classe come compare in anagrafica: la griglia si restringe a quella.

::textfield[classeSel]{label="Classe da mostrare" value="1A" placeholder="1A"}

::if-any{path="classi"}
Classi disponibili:

::list{path="classi" sort="nome"}
{nome} — {indirizzo}
::/list
::/if-any

::timetable{path="lezioni" filter="classe=#classeSel" rows="ora" cols="giorno" row-values="1,2,3,4,5,6" row-labels="1ª,2ª,3ª,4ª,5ª,6ª" col-values="lun,mar,mer,gio,ven,sab" col-labels="Lunedì,Martedì,Mercoledì,Giovedì,Venerdì,Sabato" colour="disciplina" pin="fisso" blocked="violazioni" deletable id="grigliaClasse"}
**{disciplina}**
{docente} · {aula}
::/timetable

::if-empty{path="lezioni"}
La griglia è vuota: l'orario si genera in **Controlli e generazione**.
::/if-empty

## Per docente

La stessa direttiva, filtrata sul cognome: le ore buche e il giorno libero si
leggono qui, non nella griglia della classe. Un docente in compresenza compare
qui con le ore su cui si appoggia — che è il solo posto dove quelle ore si vedono
come sue.

::textfield[docenteSel]{label="Docente da mostrare" value="Basile" placeholder="Basile"}

::timetable{path="lezioni" filter="docente=#docenteSel" rows="ora" cols="giorno" row-values="1,2,3,4,5,6" row-labels="1ª,2ª,3ª,4ª,5ª,6ª" col-values="lun,mar,mer,gio,ven,sab" col-labels="Lunedì,Martedì,Mercoledì,Giovedì,Venerdì,Sabato" colour="classe" pin="fisso" blocked="violazioni" id="grigliaDocente"}
**{classe}**
{disciplina} · {aula}
::/timetable

## Per aula

È la vista che conta per i laboratori e per le palestre, che sono poche e se le
contendono tutte le classi. La saturazione di ciascuna, in percentuale, sta in
**Laboratori e qualità**.

::textfield[aulaSel]{label="Aula da mostrare" value="LAB1" placeholder="LAB1"}

::timetable{path="lezioni" filter="aula=#aulaSel" rows="ora" cols="giorno" row-values="1,2,3,4,5,6" row-labels="1ª,2ª,3ª,4ª,5ª,6ª" col-values="lun,mar,mer,gio,ven,sab" col-labels="Lunedì,Martedì,Mercoledì,Giovedì,Venerdì,Sabato" colour="classe" pin="fisso" blocked="violazioni" id="grigliaAula"}
**{classe}**
{disciplina} · {docente}
::/timetable

**Perché la lezione porta i nomi e non gli id.** Le cattedre sono fatte di
riferimenti; la griglia no. Una casella si filtra, si stampa e si legge per come
è scritta — `classe=1A`, `docente=Basile` — e la generazione ci copia dentro i
nomi presi dalle cattedre. Il legame con la cattedra di partenza resta comunque
nel campo `cattedra` di ogni lezione.

::/page

::page{title="Controlli e generazione" icon="gears"}

# Controlli e generazione

Due blocchi, e nessuno dei due parte da solo: girano quando qualcuno preme il
loro pulsante. Il primo propone un orario, il secondo giudica quello che c'è.
Sono **Python puro**, senza pacchetti da scaricare, e mentre girano c'è il
pulsante **Ferma**, che termina l'esecuzione e tiene l'ultimo risultato
pubblicato.

## Divieti

Le ore in cui non si fa lezione. Senza **ambito** valgono per tutti — il plesso
chiuso, il collegio, il rientro che quest'anno non c'è — e diventano celle chiuse
nella griglia dopo il controllo, perché è il controllo a scrivere la collezione
che la griglia legge.

Con un ambito valgono per **una risorsa sola**: il laboratorio fermo per
manutenzione, il docente impegnato in commissione, la classe fuori per l'uscita
didattica. Sono altrettanto duri, ma non chiudono la casella a tutti gli altri, e
per questo la griglia non li disegna: comparirebbero come un divieto generale che
non sono. Il controllo li fa valere lo stesso, segnalando la lezione che ci
finisce dentro.

::form{path="divieti" id="formDivieto"}
::input{field="giorno" legend="Giorno" required pattern="lun|mar|mer|gio|ven|sab" message="Scrivi lun, mar, mer, gio, ven oppure sab"}
::input{field="ora" legend="Ora" type="number" min="1" max="6" required}
::input{field="ambito" legend="Ambito" help="Vuoto per tutti, oppure aula, docente, classe"}
::input{field="chi" legend="Chi" help="Il nome della risorsa: LAB1, Basile, 1A"}
::input{field="motivo" legend="Motivo" required help="Compare nella griglia quando qualcuno prova a rilasciare lì"}
::save{label="Vieta questa casella"}
::/form

::if-any{path="divieti"}
::table{path="divieti" sort="giorno" filters="ambito" deletable editform="formDivieto"}
::column{field="giorno" label="Giorno"}
::column{field="ora" label="Ora" align="end"}
::column{field="ambito" label="Ambito"}
::column{field="chi" label="Chi"}
::column{field="motivo" label="Motivo"}
::/table
::/if-any

## Pre-assegnazioni

Le ore decise prima di tutto il resto: la riunione del venerdì, il laboratorio
che quella classe ha soltanto il martedì, l'ora che hai spostato a mano e vuoi
rivedere uguale al prossimo giro. La generazione le colloca per prime e non le
tocca più, e nella griglia portano la bandierina.

Sono anche il modo di aggiungere una lezione **senza mouse**: la scrivi qui e
rigeneri.

Se la cattedra ha un blocco di due ore, l'ora che scrivi qui è quella di
**inizio**: il blocco resta intero e prende anche quella dopo.

::form{path="preassegnazioni" id="formPreassegnazione"}
::input{field="classe" legend="Classe" required help="Il nome, come in anagrafica: 1A"}
::input{field="disciplina" legend="Disciplina" required help="Il nome per esteso: Matematica"}
::input{field="docente" legend="Docente" required help="Il cognome: Rinaldi"}
::input{field="giorno" legend="Giorno" required pattern="lun|mar|mer|gio|ven|sab" message="Scrivi lun, mar, mer, gio, ven oppure sab"}
::input{field="ora" legend="Ora d'inizio" type="number" min="1" max="6" required}
::save{label="Fissa questa ora"}
::/form

::if-any{path="preassegnazioni"}
**:count{path="preassegnazioni"}** ore pre-assegnate.

::table{path="preassegnazioni" sort="classe" deletable editform="formPreassegnazione"}
::column{field="classe" label="Classe"}
::column{field="disciplina" label="Disciplina"}
::column{field="docente" label="Docente"}
::column{field="giorno" label="Giorno"}
::column{field="ora" label="Ora" align="end"}
::/table
::/if-any

## I pesi

Quattro pesi e il numero di mosse tentate dopo la prima collocazione. Non
cambiano niente finché non si rigenera: il blocco è manuale apposta, perché una
euristica non deve ripartire perché qualcuno ha corretto un cognome.

::slider[pesoBuche]{label="Ore buche del docente" min="0" max="100" value="70"}
Peso: :value[pesoBuche]{ref="#pesoBuche"}

::slider[pesoGiornoLibero]{label="Giorno libero richiesto" min="0" max="100" value="90"}
Peso: :value[pesoGiornoLibero]{ref="#pesoGiornoLibero"}

::slider[pesoMattino]{label="Discipline pesanti nelle prime ore" min="0" max="100" value="40"}
Peso: :value[pesoMattino]{ref="#pesoMattino"}

::slider[pesoRipetizioni]{label="Stessa disciplina due volte nello stesso giorno" min="0" max="100" value="60"}
Peso: :value[pesoRipetizioni]{ref="#pesoRipetizioni"}

::slider[iterazioni]{label="Mosse tentate nel miglioramento" min="0" max="6000" value="2000" step="100"}
Mosse: :value[iterazioni]{ref="#iterazioni"}

## Genera l'orario

Prima una **greedy**: le ore più vincolate per prime — un blocco di laboratorio
conteso da tre classi e un docente con poche caselle libere vengono serviti prima
di un'ora di religione — ciascuna nella casella e nell'aula meno costose fra
quelle ammesse. Poi un **miglioramento** che alterna due mosse: lo scambio di due
blocchi della stessa lunghezza, anche di classi diverse, e lo spostamento di un
blocco in una casella libera migliore. Lo scambio fra classi è quello che chiude
le ore buche, perché una buca di un docente si toglie quasi sempre muovendo l'ora
di un'altra classe.

I vincoli duri non si negoziano: una classe una lezione per volta, un docente in
un posto solo, un'aula a un turno solo, la capienza dell'aula, i giorni di non
disponibilità, il tetto giornaliero, la prima ora di chi non la fa, i divieti — e
**nessun docente cambia plesso da un'ora alla successiva**, che è il vincolo che
sulla carta non si vede e nella realtà è il primo a saltare. Tutto il resto è
preferenza pesata, e una preferenza si può perdere.

**Questo blocco riscrive tutto l'orario.** Quello che deve sopravvivere va messo
fra le pre-assegnazioni qui sopra.

::python{data="cattedre,classi,docenti,discipline,aule,divieti,preassegnazioni" params="pesoBuche,pesoGiornoLibero,pesoMattino,pesoRipetizioni,iterazioni" writes="lezioni" manual}
```python
import random

GIORNI = ["lun", "mar", "mer", "gio", "ven", "sab"]
ORE = ["1", "2", "3", "4", "5", "6"]
NORE = len(ORE)

def numero(v, d=0.0):
    try:
        return float(str(v).strip().replace(",", "."))
    except (TypeError, ValueError):
        return d

def intero(v, d=0):
    try:
        return int(float(str(v).strip().replace(",", ".")))
    except (TypeError, ValueError):
        return d

def vero(v):
    return str(v).strip().lower() in ("true", "1", "yes", "on", "si", "sì")

P_BUCHE = numero(params.get("pesoBuche"), 70.0) / 10.0
P_LIBERO = numero(params.get("pesoGiornoLibero"), 90.0) / 10.0
P_MATTINO = numero(params.get("pesoMattino"), 40.0) / 10.0
P_RIP = numero(params.get("pesoRipetizioni"), 60.0) / 10.0
P_FUORI = 500.0            # un'aula fuori dal plesso della classe: quasi un divieto
MOSSE = intero(params.get("iterazioni"), 2000)

classi = {r["id"]: r for r in data["classi"]}
docenti = {r["id"]: r for r in data["docenti"]}
discipline = {r["id"]: r for r in data["discipline"]}
aule = list(data["aule"])
aula_per_nome = {r.get("nome", ""): r for r in aule}

# I divieti. Senza ambito valgono per tutti; con un ambito valgono per una
# risorsa sola, e sono altrettanto duri.
vietate = set()
vietate_per = {"aula": set(), "docente": set(), "classe": set()}
for v in data["divieti"]:
    g, o = v.get("giorno", ""), str(v.get("ora", ""))
    ambito = str(v.get("ambito", "")).strip().lower()
    chi = str(v.get("chi", "")).strip()
    if ambito in vietate_per and chi:
        vietate_per[ambito].add((chi, g, o))
    else:
        vietate.add((g, o))

celle = [(g, i) for g in GIORNI for i in range(NORE) if (g, ORE[i]) not in vietate]

# --- Le unità -------------------------------------------------------------
# Un'unità è un BLOCCO di ore consecutive da collocare insieme: quattro ore di
# informatica con blocco due sono due unità di due, non quattro di una. È la sola
# differenza strutturale con la versione precedente, e da lì viene tutto il resto:
# un blocco è indivisibile, quindi o entra intero o non entra.
#
# Le compresenze con "affianca" NON sono un secondo giro: sono attaccate qui
# all'unità del titolare, così i loro vincoli valgono mentre si sceglie la
# casella. Sistemarle dopo sembrava più semplice e produceva orari in cui l'ITP
# era in laboratorio un giorno in cui non è in servizio — e nessuno lo scopriva
# fino al controllo.
affiancanti = {}
for k in data["cattedre"]:
    if not vero(k.get("compresenza", "")):
        continue
    bersaglio = str(k.get("affianca", "")).strip()
    docente = docenti.get(k.get("docente", ""))
    disciplina = discipline.get(k.get("disciplina", ""))
    if not bersaglio or not docente:
        continue
    affiancanti.setdefault(bersaglio, []).append({
        "cattedra": k.get("id", ""),
        "cognome": docente.get("cognome", ""),
        "disciplina": disciplina.get("nome", "") if disciplina else "",
        "resto": intero(k.get("ore", ""), 0),
        "giorniNon": [s.strip() for s in str(docente.get("giorniNon", "")).split(",") if s.strip()],
        "maxOre": intero(docente.get("maxOreGiorno", ""), 0) or NORE,
        "nonPrima": vero(docente.get("nonPrimaOra", "")),
    })

unita = []
senza_aula = []
compresenze_scoperte = []
for k in data["cattedre"]:
    if vero(k.get("compresenza", "")):
        continue
    classe = classi.get(k.get("classe", ""))
    disciplina = discipline.get(k.get("disciplina", ""))
    docente = docenti.get(k.get("docente", ""))
    if not classe or not disciplina or not docente:
        continue
    alunni = intero(classe.get("alunni", ""), 0)
    plesso_classe = classe.get("plesso", "")

    # Le aule ammesse. Una stanza obbligata vince su tutto; altrimenti è il TIPO
    # chiesto dalla disciplina a decidere, e fra le stanze di quel tipo restano
    # quelle abbastanza capienti — nel plesso della classe, se ce n'è una.
    obbligata = aula_per_nome.get(str(k.get("aula", "")).strip())
    if obbligata:
        ammesse_aule = [obbligata]
    else:
        tipo = str(disciplina.get("aulaRichiesta", "")).strip().lower()
        if tipo == "":
            base = aula_per_nome.get(classe.get("aulaBase", ""))
            ammesse_aule = [base] if base else []
        else:
            capaci = [a for a in aule
                      if str(a.get("tipo", "")).strip().lower() == tipo
                      and intero(a.get("capienza", ""), 0) >= alunni]
            dentro = [a for a in capaci if a.get("plesso", "") == plesso_classe]
            ammesse_aule = dentro if dentro else capaci

    if not ammesse_aule:
        senza_aula.append("%s %s" % (classe.get("nome", ""), disciplina.get("nome", "")))
        continue

    lung = intero(k.get("blocco", ""), 0) or intero(disciplina.get("blocco", ""), 0) or 1
    lung = max(1, min(lung, NORE))
    resto = intero(k.get("ore", ""), 0)
    pezzi = []
    while resto > 0:
        pezzi.append(min(lung, resto))
        resto -= min(lung, resto)

    for n, L in enumerate(pezzi):
        # Chi affianca copre le ore del titolare in ordine, e solo a blocchi
        # interi: mezzo blocco di compresenza non è una cosa che la griglia sappia
        # scrivere, e fingere che lo sia perderebbe la metà che non ci sta.
        con = []
        for c in affiancanti.get(k.get("id", ""), []):
            if c["resto"] >= L:
                c["resto"] -= L
                con.append(c)
        unita.append({
            "cattedra": k.get("id", ""),
            "classe": classe.get("nome", ""),
            "plessoClasse": plesso_classe,
            "disciplina": disciplina.get("nome", ""),
            "docente": docente.get("cognome", ""),
            "aule": ammesse_aule,
            "nomiAule": {a.get("nome", "") for a in ammesse_aule},
            "compresenti": con,
            "lung": L,
            "n": n,
            "mattino": vero(disciplina.get("mattino", "")),
            "libero": docente.get("giornoLibero", ""),
            "giorniNon": [s.strip() for s in str(docente.get("giorniNon", "")).split(",") if s.strip()],
            "maxOre": intero(docente.get("maxOreGiorno", ""), 0) or NORE,
            "nonPrima": vero(docente.get("nonPrimaOra", "")),
            "giorno": "",
            "i0": -1,
            "aula": None,
        })

# Le ore di chi affianca che non hanno trovato un blocco intero da coprire: o la
# cattedra affiancata non esiste, o le sue ore sono meno di quelle dichiarate.
for lista in affiancanti.values():
    for c in lista:
        if c["resto"] > 0:
            compresenze_scoperte.append("%s (%d ore)" % (c["cognome"], c["resto"]))

if not unita:
    print("Nessuna cattedra collocabile: semina le anagrafiche e le cattedre in Avvio.")
    if senza_aula:
        print("Senza nessuna aula ammessa: %s." % ", ".join(sorted(set(senza_aula))))
    result = []
else:
    occ_classe = {}
    occ_docente = {}      # (docente, giorno, ora) -> plesso dell'aula
    occ_aula = {}
    ore_doc = {}          # (docente, giorno) -> set di indici d'ora
    disc_giorno = {}      # (classe, disciplina, giorno) -> quante unità
    collocate = []
    fissate = set()

    def hours(u, i0):
        return range(i0, i0 + u["lung"])

    def docente_libero(cog, giorni_non, max_ore, non_prima, g, i0, lung, plesso):
        # Le condizioni sono le stesse per il titolare e per chi lo affianca: chi
        # sta in quella casella ci sta, e il fatto che uno dei due sia in
        # compresenza non gli restituisce il giorno in cui non è in servizio.
        if g in giorni_non:
            return False
        if len(ore_doc.get((cog, g), ())) + lung > max_ore:
            return False
        for i in range(i0, i0 + lung):
            if non_prima and i == 0:
                return False
            if (cog, g, ORE[i]) in occ_docente:
                return False
            if (cog, g, ORE[i]) in vietate_per["docente"]:
                return False
        # Nessuno si sposta di plesso da un'ora alla successiva: si guardano l'ora
        # prima e l'ora dopo il blocco, che sono le sole che confinano.
        for i in (i0 - 1, i0 + lung):
            if 0 <= i < NORE:
                altro = occ_docente.get((cog, g, ORE[i]))
                if altro is not None and altro != plesso:
                    return False
        return True

    def ammessa(u, g, i0, a):
        if i0 < 0 or i0 + u["lung"] > NORE:
            return False
        cls, aul = u["classe"], a.get("nome", "")
        # L'aula deve essere una delle sue: uno scambio muove due blocchi e con
        # loro le stanze, e senza questa riga un'ora di matematica finiva in
        # un'aula che non era mai stata fra quelle ammesse — nel plesso giusto e
        # troppo piccola, che è il modo più discreto di sbagliare.
        if aul not in u["nomiAule"]:
            return False
        for i in hours(u, i0):
            o = ORE[i]
            if (g, o) in vietate:
                return False
            if (cls, g, o) in occ_classe or (aul, g, o) in occ_aula:
                return False
            if (aul, g, o) in vietate_per["aula"]:
                return False
            if (cls, g, o) in vietate_per["classe"]:
                return False
        plesso = a.get("plesso", "")
        if not docente_libero(u["docente"], u["giorniNon"], u["maxOre"], u["nonPrima"],
                              g, i0, u["lung"], plesso):
            return False
        for c in u["compresenti"]:
            if not docente_libero(c["cognome"], c["giorniNon"], c["maxOre"], c["nonPrima"],
                                  g, i0, u["lung"], plesso):
                return False
        return True

    def piazza(u, g, i0, a):
        cls, aul = u["classe"], a.get("nome", "")
        plesso = a.get("plesso", "")
        for i in hours(u, i0):
            o = ORE[i]
            occ_classe[(cls, g, o)] = u
            occ_aula[(aul, g, o)] = u
            for cog in [u["docente"]] + [c["cognome"] for c in u["compresenti"]]:
                occ_docente[(cog, g, o)] = plesso
                ore_doc.setdefault((cog, g), set()).add(i)
        chiave = (cls, u["disciplina"], g)
        disc_giorno[chiave] = disc_giorno.get(chiave, 0) + 1
        u["giorno"], u["i0"], u["aula"] = g, i0, a
        collocate.append(u)

    def togli(u):
        g, i0, a = u["giorno"], u["i0"], u["aula"]
        cls, aul = u["classe"], a.get("nome", "")
        for i in hours(u, i0):
            o = ORE[i]
            occ_classe.pop((cls, g, o), None)
            occ_aula.pop((aul, g, o), None)
            for cog in [u["docente"]] + [c["cognome"] for c in u["compresenti"]]:
                occ_docente.pop((cog, g, o), None)
                ore_doc.get((cog, g), set()).discard(i)
        chiave = (cls, u["disciplina"], g)
        disc_giorno[chiave] = max(0, disc_giorno.get(chiave, 1) - 1)
        collocate.remove(u)
        u["giorno"], u["i0"], u["aula"] = "", -1, None

    def costo(u, g, i0, a):
        # Il costo di TENERE u lì, calcolato quando u non è collocata: il
        # miglioramento toglie sempre prima di misurare, così le due misure sono
        # confrontabili e nessuna conta se stessa.
        c = 0.0
        for i in hours(u, i0):
            if u["mattino"]:
                c += P_MATTINO * i
        if u["libero"] and u["libero"] == g:
            c += P_LIBERO * 6.0 * u["lung"]
        c += P_RIP * disc_giorno.get((u["classe"], u["disciplina"], g), 0) * 3.0
        sue = ore_doc.get((u["docente"], g), set())
        if sue:
            tutte = sorted(set(sue) | set(hours(u, i0)))
            c += P_BUCHE * ((tutte[-1] - tutte[0] + 1) - len(tutte)) * 2.0
        if a.get("plesso", "") != u["plessoClasse"]:
            c += P_FUORI
        # A parità di tutto, la stanza più piccola fra quelle adeguate: tenere
        # libero il laboratorio grande è quello che salva la classe numerosa.
        c += intero(a.get("capienza", ""), 0) * 0.01
        return c

    def migliore(u, prima=None):
        scelte = []
        for (g, i0) in celle:
            for a in u["aule"]:
                if ammessa(u, g, i0, a):
                    scelte.append((costo(u, g, i0, a), GIORNI.index(g), i0,
                                   a.get("nome", ""), g, a))
        if not scelte:
            return None
        scelte.sort(key=lambda s: (s[0], s[1], s[2], s[3]))
        if prima is not None and scelte[0][0] >= prima:
            return None
        return scelte[0]

    def righe():
        fuori = []
        for u in collocate:
            for i in hours(u, u["i0"]):
                comune = {
                    "classe": u["classe"],
                    "aula": u["aula"].get("nome", ""),
                    "plesso": u["aula"].get("plesso", ""),
                    "giorno": u["giorno"],
                    "ora": ORE[i],
                }
                fuori.append(dict(comune, **{
                    "id": "l-%s-%s-%s" % (u["classe"], u["giorno"], ORE[i]),
                    "disciplina": u["disciplina"],
                    "docente": u["docente"],
                    "cattedra": u["cattedra"],
                    "compresenza": "false",
                    "fisso": "true" if id(u) in fissate else "false",
                }))
                for c in u["compresenti"]:
                    fuori.append(dict(comune, **{
                        "id": "l-%s-%s-%s-%s" % (u["classe"], u["giorno"], ORE[i], c["cattedra"]),
                        "disciplina": c["disciplina"] or u["disciplina"],
                        "docente": c["cognome"],
                        "cattedra": c["cattedra"],
                        "compresenza": "true",
                        "fisso": "false",
                    }))
        return fuori

    # Le pre-assegnazioni per prime, e restano dove sono.
    for p in data["preassegnazioni"]:
        g = p.get("giorno", "")
        o = str(p.get("ora", ""))
        if o not in ORE or g not in GIORNI:
            continue
        i0 = ORE.index(o)
        for u in unita:
            if u["giorno"]:
                continue
            if (u["classe"] == p.get("classe", "")
                    and u["disciplina"] == p.get("disciplina", "")
                    and u["docente"] == p.get("docente", "")):
                messa = False
                for a in u["aule"]:
                    if ammessa(u, g, i0, a):
                        piazza(u, g, i0, a)
                        fissate.add(id(u))
                        messa = True
                        break
                if messa:
                    break

    # Greedy: le unità più vincolate per prime. Un blocco lungo vincola più di
    # un'ora sola, un'aula contesa da più classi più di un'aula base, un docente
    # con giorni di non disponibilità più di uno sempre presente.
    contese = {}
    for u in unita:
        for a in u["aule"]:
            contese.setdefault(a.get("nome", ""), set()).add(u["classe"])

    def rigidita(u):
        peso = u["lung"] * 2
        if len(u["aule"]) == 1:
            peso += 1
        if any(len(contese.get(a.get("nome", ""), ())) > 1 for a in u["aule"]):
            peso += 3
        peso += len(u["giorniNon"])
        if u["maxOre"] < NORE:
            peso += 1
        if u["libero"]:
            peso += 1
        return (-peso, u["classe"], u["disciplina"], u["n"])

    da_fare = [u for u in unita if not u["giorno"]]
    da_fare.sort(key=rigidita)
    totale = max(len(da_fare), 1)
    non_collocate = []

    for i, u in enumerate(da_fare):
        scelta = migliore(u)
        if scelta is None:
            non_collocate.append(u)
        else:
            piazza(u, scelta[4], scelta[2], scelta[5])
        if i % 10 == 0 or i + 1 == totale:
            progress(i + 1, totale, "collocazione: %d blocchi su %d" % (i + 1, totale))
            partial(righe())

    # Miglioramento, con un seme fisso: due esecuzioni sugli stessi dati danno lo
    # stesso orario, che è quello che rende confrontabili due pesi.
    #
    # Due mosse alternate. Lo SPOSTAMENTO porta un blocco nella casella libera
    # migliore; lo SCAMBIO inverte due blocchi della stessa lunghezza, anche di
    # classi diverse — ed è quello che chiude le ore buche, perché la buca di un
    # docente si toglie quasi sempre muovendo l'ora di un'altra classe.
    sorte = random.Random(20260813)
    mobili = [u for u in collocate if id(u) not in fissate]
    accolte = 0
    if mobili and MOSSE > 0:
        for passo in range(MOSSE):
            if passo % 2 == 0 or len(mobili) < 2:
                u = sorte.choice(mobili)
                g0, i0, a0 = u["giorno"], u["i0"], u["aula"]
                prima = costo(u, g0, i0, a0)
                togli(u)
                scelta = migliore(u, prima)
                if scelta is None:
                    piazza(u, g0, i0, a0)
                else:
                    piazza(u, scelta[4], scelta[2], scelta[5])
                    accolte += 1
            else:
                a = sorte.choice(mobili)
                simili = [v for v in mobili if v is not a and v["lung"] == a["lung"]]
                if not simili:
                    continue
                b = sorte.choice(simili)
                ga, ia, aa = a["giorno"], a["i0"], a["aula"]
                gb, ib, ab = b["giorno"], b["i0"], b["aula"]
                prima = costo(a, ga, ia, aa)
                togli(a)
                prima += costo(b, gb, ib, ab)
                togli(b)
                dopo = None
                if ammessa(a, gb, ib, ab) and ammessa(b, ga, ia, aa):
                    dopo = costo(a, gb, ib, ab) + costo(b, ga, ia, aa)
                if dopo is not None and dopo < prima:
                    piazza(a, gb, ib, ab)
                    piazza(b, ga, ia, aa)
                    accolte += 1
                else:
                    piazza(a, ga, ia, aa)
                    piazza(b, gb, ib, ab)
            if passo % 100 == 0:
                progress(passo + 1, MOSSE, "miglioramento: %d mosse accolte" % accolte)
                partial(righe())

    result = righe()

    # --- Il sostegno --------------------------------------------------------
    # Una compresenza con "affianca" è già collocata: sta nell'unità del titolare
    # e ha pesato sulla scelta della casella. Resta quella SENZA, che non si
    # attacca a una cattedra ma alla classe intera, e prende le sue ore dalla
    # prima della settimana in avanti — che è il sostegno.
    #
    # Anche qui i vincoli valgono: un'ora rifiutata è un'ora che il docente non
    # può fare, e va detta, non nascosta prendendo la casella dopo in silenzio.
    posate = 0
    saltate = 0
    for k in data["cattedre"]:
        if not vero(k.get("compresenza", "")) or str(k.get("affianca", "")).strip():
            continue
        classe = classi.get(k.get("classe", ""))
        disciplina = discipline.get(k.get("disciplina", ""))
        docente = docenti.get(k.get("docente", ""))
        if not classe or not disciplina or not docente:
            continue
        doc = docente.get("cognome", "")
        giorni_non = [s.strip() for s in str(docente.get("giorniNon", "")).split(",") if s.strip()]
        max_ore = intero(docente.get("maxOreGiorno", ""), 0) or NORE
        non_prima = vero(docente.get("nonPrimaOra", ""))
        quante = intero(k.get("ore", ""), 0)
        caselle = []
        for u in collocate:
            if u["classe"] != classe.get("nome", ""):
                continue
            for i in hours(u, u["i0"]):
                caselle.append((GIORNI.index(u["giorno"]), i, u))
        # Il sostegno si distribuisce sulla settimana, non si accumula. Prese in
        # ordine, diciotto ore riempirebbero i primi tre giorni per intero e
        # lascerebbero la classe scoperta negli altri tre — che è esattamente
        # quello che il sostegno non è. Si gira per giorni, un'ora per volta
        # dall'inizio della mattina, e il giorno libero richiesto è l'ultimo a
        # essere toccato: con diciotto ore su cinque giorni non lo si tocca mai.
        per_giorno = {}
        for casella in caselle:
            per_giorno.setdefault(casella[0], []).append(casella)
        for lista in per_giorno.values():
            lista.sort(key=lambda c: c[1])
        chiesto = docente.get("giornoLibero", "")
        preferiti = [gi for gi in sorted(per_giorno) if GIORNI[gi] != chiesto]
        ultimo = [gi for gi in sorted(per_giorno) if GIORNI[gi] == chiesto]
        caselle = []
        # I giri sui giorni buoni si esauriscono TUTTI prima di toccare il giorno
        # libero: metterlo in fondo all'ordine di un giro solo non bastava, perché
        # ogni giro lo visitava comunque una volta.
        for gruppo in (preferiti, ultimo):
            giro = 0
            while any(len(per_giorno[gi]) > giro for gi in gruppo):
                for gi in gruppo:
                    if len(per_giorno[gi]) > giro:
                        caselle.append(per_giorno[gi][giro])
                giro += 1

        for (gi, i, u) in caselle:
            if quante <= 0:
                break
            g, o = GIORNI[gi], ORE[i]
            if not docente_libero(doc, giorni_non, max_ore, non_prima, g, i, 1,
                                  u["aula"].get("plesso", "")):
                continue
            occ_docente[(doc, g, o)] = u["aula"].get("plesso", "")
            ore_doc.setdefault((doc, g), set()).add(i)
            result.append({
                "id": "l-%s-%s-%s-%s" % (u["classe"], g, o, k.get("id", "")),
                "classe": u["classe"],
                "disciplina": disciplina.get("nome", ""),
                "docente": doc,
                "aula": u["aula"].get("nome", ""),
                "plesso": u["aula"].get("plesso", ""),
                "giorno": g,
                "ora": o,
                "cattedra": k.get("id", ""),
                "compresenza": "true",
                "fisso": "false",
            })
            posate += 1
            quante -= 1
        if quante > 0:
            saltate += quante

    progress(max(MOSSE, 1), max(MOSSE, 1), "fatto")
    partial(result)

    ore_tot = sum(u["lung"] for u in unita)
    ore_messe = sum(u["lung"] for u in collocate)
    print("Ore collocate: %d su %d, in %d blocchi." % (ore_messe, ore_tot, len(collocate)))
    print("Mosse accolte nel miglioramento: %d su %d tentate." % (accolte, MOSSE))
    print("Ore di sostegno appoggiate alla classe: %d." % posate)
    if saltate:
        print("Ore di sostegno non appoggiate: %d (il docente era occupato o non "
              "disponibile in quelle caselle)." % saltate)
    if compresenze_scoperte:
        print("Compresenze senza un blocco intero da coprire: %s."
              % ", ".join(sorted(set(compresenze_scoperte))))
        print("Controlla che 'affianca' nomini una cattedra che esiste e che le ore "
              "dichiarate non superino le sue.")
    if senza_aula:
        print("Cattedre senza nessuna aula ammessa: %s." % ", ".join(sorted(set(senza_aula))))
        print("Nessuna stanza del tipo richiesto è abbastanza capiente: aggiungine una "
              "in Anagrafiche, o correggi gli alunni della classe.")
    if non_collocate:
        mancanti = sorted({"%s %s (%d ore)" % (u["classe"], u["disciplina"], u["lung"])
                           for u in non_collocate})
        print("Blocchi non collocati (%d): %s." % (len(non_collocate), ", ".join(mancanti)))
        print("Sono i blocchi per cui non restava nessuna casella ammessa: allarga i "
              "divieti, togli qualche pre-assegnazione, riduci un blocco, o aggiungi "
              "un'aula del tipo richiesto.")
    print("Adesso esegui il controllo: la griglia non vieta niente finché la "
          "collezione delle violazioni non viene riscritta.")
```
::/python

## Controlla i vincoli

Legge l'orario e le anagrafiche e riscrive da capo la collezione delle
violazioni: i divieti dichiarati, più tutto quello che trova. Un conflitto porta
l'id della lezione a cui si riferisce, così la casella se ne accorge senza
chiudersi per tutti; un divieto generale non ha nessun id e chiude la casella per
chiunque.

Il controllo è indipendente dal generatore, e questo è il punto: giudica un
orario **comunque sia nato** — generato, trascinato a mano, importato da un file.
Per questo ricontrolla anche le cose che il generatore non violerebbe mai.

::python{data="lezioni,cattedre,classi,docenti,discipline,aule,divieti" writes="violazioni" manual}
```python
# Quello che il controllo cerca, in ordine di gravità: docente doppio, classe
# doppia, aula doppia, lezione in una casella vietata (generale o di quella
# risorsa), aula troppo piccola, docente che cambia plesso fra un'ora e la
# successiva, giorno di non disponibilità, tetto giornaliero, prima ora di chi non
# la fa, compresenza senza titolare — poi il giorno libero, che è una preferenza,
# e il monte ore che non torna fra la cattedra e le caselle collocate.
GIORNI = ["lun", "mar", "mer", "gio", "ven", "sab"]
ORE = ["1", "2", "3", "4", "5", "6"]

def intero(v, d=0):
    try:
        return int(float(str(v).strip().replace(",", ".")))
    except (TypeError, ValueError):
        return d

def vero(v):
    return str(v).strip().lower() in ("true", "1", "yes", "on", "si", "sì")

lezioni = data["lezioni"]
divieti = data["divieti"]
cattedre = data["cattedre"]

nome_classe = {r["id"]: r.get("nome", "") for r in data["classi"]}
nome_disciplina = {r["id"]: r.get("nome", "") for r in data["discipline"]}
cognome = {r["id"]: r.get("cognome", "") for r in data["docenti"]}
per_cognome = {r.get("cognome", ""): r for r in data["docenti"]}
per_aula = {r.get("nome", ""): r for r in data["aule"]}
alunni = {r.get("nome", ""): intero(r.get("alunni", ""), 0) for r in data["classi"]}
plesso_classe = {r.get("nome", ""): r.get("plesso", "") for r in data["classi"]}

result = []
contatore = [0]

def segnala(tipo, riga, colonna, perche, per="", gravita="alta"):
    contatore[0] += 1
    result.append({"id": "v-%04d" % contatore[0], "tipo": tipo, "row": riga,
                   "col": colonna, "why": perche, "for": per, "gravita": gravita})

vietate = set()
vietate_per = {"aula": set(), "docente": set(), "classe": set()}
motivo_di = {}
for v in divieti:
    g, o = v.get("giorno", ""), str(v.get("ora", ""))
    ambito = str(v.get("ambito", "")).strip().lower()
    chi = str(v.get("chi", "")).strip()
    perche = v.get("motivo", "Casella non utilizzabile")
    if ambito in vietate_per and chi:
        vietate_per[ambito].add((chi, g, o))
        motivo_di[(ambito, chi, g, o)] = perche
    else:
        vietate.add((g, o))
        # Senza id: chiude la casella per chiunque, ed è così che la griglia la legge.
        segnala("divieto", o, g, perche)

totale = max(len(lezioni), 1)
progress(0, totale, "leggo l'orario")

visto_docente = {}
visto_classe = {}
visto_aula = {}
ore_collocate = {}
plesso_ora = {}
ore_giorno = {}

for i, l in enumerate(lezioni):
    giorno = l.get("giorno", "")
    ora = str(l.get("ora", ""))
    classe = l.get("classe", "")
    docente = l.get("docente", "")
    aula = l.get("aula", "")
    disciplina = l.get("disciplina", "")
    lid = l.get("id", "")
    comp = vero(l.get("compresenza", ""))

    if docente:
        chiave = (docente, giorno, ora)
        if chiave in visto_docente:
            segnala("conflitto", ora, giorno,
                    "%s è già in %s a quest'ora" % (docente, visto_docente[chiave]), lid)
        else:
            visto_docente[chiave] = classe
        ore_giorno[(docente, giorno)] = ore_giorno.get((docente, giorno), 0) + 1

    # Una compresenza sta apposta nella casella di qualcun altro: la classe è la
    # stessa e l'aula è la stessa, e segnalarlo sarebbe segnalare che funziona.
    if classe and not comp:
        chiave = (classe, giorno, ora)
        if chiave in visto_classe:
            segnala("conflitto", ora, giorno,
                    "la %s ha già %s a quest'ora" % (classe, visto_classe[chiave]), lid)
        else:
            visto_classe[chiave] = disciplina

    if aula and not comp:
        chiave = (aula, giorno, ora)
        if chiave not in visto_aula:
            visto_aula[chiave] = classe
        elif visto_aula[chiave] != classe:
            # Se è la stessa classe l'ha già detto il controllo di sopra: due
            # segnalazioni per un errore solo fanno sembrare l'orario peggiore.
            segnala("conflitto", ora, giorno,
                    "l'aula %s è già occupata dalla %s" % (aula, visto_aula[chiave]), lid)

    if (giorno, ora) in vietate:
        segnala("conflitto", ora, giorno, "lezione collocata in una casella vietata", lid)
    for ambito, chi in (("aula", aula), ("docente", docente), ("classe", classe)):
        if chi and (chi, giorno, ora) in vietate_per[ambito]:
            segnala("conflitto", ora, giorno,
                    "%s non è disponibile a quest'ora: %s"
                    % (chi, motivo_di.get((ambito, chi, giorno, ora), "divieto")), lid)

    if aula and classe and not comp:
        posti = intero(per_aula.get(aula, {}).get("capienza", ""), 0)
        quanti = alunni.get(classe, 0)
        if posti and quanti and quanti > posti:
            segnala("capienza", ora, giorno,
                    "la %s è di %d alunni e %s ne tiene %d" % (classe, quanti, aula, posti),
                    lid)

    if aula and classe:
        suo = per_aula.get(aula, {}).get("plesso", "")
        if suo and plesso_classe.get(classe, suo) != suo:
            segnala("plesso", ora, giorno,
                    "la %s è in %s e %s è in %s"
                    % (classe, plesso_classe.get(classe, ""), aula, suo), lid, "media")
        if docente and suo:
            plesso_ora[(docente, giorno, ora)] = (suo, lid)

    anagrafe = per_cognome.get(docente, {})
    if docente and anagrafe:
        giorni_non = [s.strip() for s in str(anagrafe.get("giorniNon", "")).split(",") if s.strip()]
        if giorno in giorni_non:
            segnala("disponibilità", ora, giorno,
                    "%s non è in servizio di %s" % (docente, giorno), lid)
        if vero(anagrafe.get("nonPrimaOra", "")) and ora == "1":
            segnala("disponibilità", ora, giorno,
                    "%s non fa la prima ora" % docente, lid)

    if classe and disciplina and not comp:
        ore_collocate[(classe, disciplina)] = ore_collocate.get((classe, disciplina), 0) + 1

    progress(i + 1, totale, "controllo i vincoli")
    partial(result)

# Il cambio di plesso fra un'ora e la successiva: si vede solo guardando due ore
# vicine, quindi è un giro a parte.
progress(totale, totale, "confronto plessi, tetti e monte ore")
for (docente, giorno, ora), (suo, lid) in sorted(plesso_ora.items()):
    if ora not in ORE:
        continue
    dopo = ORE.index(ora) + 1
    if dopo >= len(ORE):
        continue
    altro = plesso_ora.get((docente, giorno, ORE[dopo]))
    if altro and altro[0] != suo:
        segnala("plesso", ora, giorno,
                "%s passa da %s a %s fra un'ora e l'altra" % (docente, suo, altro[0]), lid)

for (docente, giorno), quante in sorted(ore_giorno.items()):
    tetto = intero(per_cognome.get(docente, {}).get("maxOreGiorno", ""), 0)
    if tetto and quante > tetto:
        segnala("tetto", "", giorno,
                "%s ha %d ore di %s e il suo massimo è %d" % (docente, quante, giorno, tetto),
                "", "media")

# Una compresenza in una casella dove la classe non ha nessun titolare non è una
# compresenza: è un'ora di lezione tenuta da chi affianca e basta.
for l in lezioni:
    if not vero(l.get("compresenza", "")):
        continue
    if (l.get("classe", ""), l.get("giorno", ""), str(l.get("ora", ""))) not in visto_classe:
        segnala("compresenza", str(l.get("ora", "")), l.get("giorno", ""),
                "%s è in compresenza dove la %s non ha lezione"
                % (l.get("docente", ""), l.get("classe", "")), l.get("id", ""), "media")

# Il monte ore: quello che la cattedra promette contro quello che l'orario mantiene.
mancanti = 0
if not cattedre:
    print("Nessuna cattedra in uso: salto il confronto sul monte ore.")
for k in cattedre:
    if vero(k.get("compresenza", "")):
        continue
    classe = nome_classe.get(k.get("classe", ""), "")
    disciplina = nome_disciplina.get(k.get("disciplina", ""), "")
    docente = cognome.get(k.get("docente", ""), "")
    dovute = intero(k.get("ore", ""), 0)
    fatte = ore_collocate.get((classe, disciplina), 0)
    if fatte != dovute:
        mancanti += 1
        segnala("monte ore", "", "",
                "%s in %s: %d ore collocate su %d di cattedra (%s)"
                % (disciplina, classe, fatte, dovute, docente),
                "", "media" if fatte < dovute else "alta")

partial(result)
conflitti = len([r for r in result if r["tipo"] == "conflitto"])
gravi = len([r for r in result if r["gravita"] == "alta" and r["tipo"] != "divieto"])
print("Lezioni lette: %d." % len(lezioni))
print("Conflitti: %d. Segnalazioni gravi in tutto: %d. Monte ore fuori posto: %d."
      % (conflitti, gravi, mancanti))
print("Divieti generali riportati in griglia: %d." % len(vietate))
if gravi == 0 and mancanti == 0:
    print("L'orario si chiude: nessuna violazione grave e nessuna ora fuori posto.")
```
::/python

::if-empty{path="violazioni"}
Nessuna segnalazione — oppure il controllo non è ancora stato eseguito. Le due
cose si distinguono soltanto premendo il pulsante.
::/if-empty

::if-any{path="violazioni"}
**:count{path="violazioni"}** segnalazioni in tutto, divieti dichiarati compresi.
I filtri sopra le colonne *Tipo* e *Gravità* separano i conflitti dal resto.

::table{path="violazioni" search sort="tipo" filters="tipo,gravita" deletable page-size="20"}
::column{field="tipo" label="Tipo"}
::column{field="col" label="Giorno"}
::column{field="row" label="Ora" align="end"}
::column{field="why" label="Che cosa non va"}
::column{field="gravita" label="Gravità"}
::/table
::/if-any

::/page

::page{title="Laboratori e qualità" icon="data-check"}

# Laboratori e qualità

Un orario senza conflitti non è ancora un buon orario. Queste due misure sono
quello che si guarda dopo: quanto sono pieni i laboratori, e come sta la
giornata dei docenti. Sono due blocchi manuali, e vanno rieseguiti dopo ogni
generazione — le collezioni che scrivono sono loro e di nessun altro.

## Saturazione delle aule

Quante ore su quelle disponibili è occupata ogni stanza. È il numero che dice se
l'orario è fragile prima ancora che si rompa: sopra l'ottanta per cento un
laboratorio non ha più margine, e una qualsiasi variazione — una classe in più,
un'uscita didattica, un blocco spostato — non trova più dove andare.

::python{data="lezioni,aule,divieti" writes="saturazione" manual}
```python
GIORNI = ["lun", "mar", "mer", "gio", "ven", "sab"]
ORE = ["1", "2", "3", "4", "5", "6"]

def vero(v):
    return str(v).strip().lower() in ("true", "1", "yes", "on", "si", "sì")

# Le caselle davvero disponibili: quelle che un divieto generale toglie non sono
# capienza inutilizzata, sono capienza che non esiste.
chiuse = set()
for v in data["divieti"]:
    if not str(v.get("ambito", "")).strip():
        chiuse.add((v.get("giorno", ""), str(v.get("ora", ""))))
disponibili = len(GIORNI) * len(ORE) - len(chiuse)
disponibili = max(disponibili, 1)

# Una compresenza sta nella stessa stanza del titolare: contarla raddoppierebbe
# quell'ora, e una palestra risulterebbe piena il doppio di quanto è.
occupate = {}
for l in data["lezioni"]:
    if vero(l.get("compresenza", "")):
        continue
    aula = l.get("aula", "")
    if aula:
        occupate.setdefault(aula, set()).add((l.get("giorno", ""), str(l.get("ora", ""))))

result = []
for a in data["aule"]:
    nome = a.get("nome", "")
    ore = len(occupate.get(nome, ()))
    result.append({
        "id": "s-" + (a.get("id", "") or nome),
        "aula": nome,
        "tipo": a.get("tipo", ""),
        "plesso": a.get("plesso", ""),
        "capienza": a.get("capienza", ""),
        "ore": str(ore),
        "libere": str(disponibili - ore),
        "percentuale": str(round(ore * 100.0 / disponibili, 1)),
    })

result.sort(key=lambda r: -float(r["percentuale"]))
piene = [r for r in result if float(r["percentuale"]) >= 80.0]
print("%d aule su %d caselle disponibili a settimana." % (len(result), disponibili))
if piene:
    print("Sopra l'80%%: %s." % ", ".join("%s (%s%%)" % (r["aula"], r["percentuale"])
                                          for r in piene))
    print("Sono le stanze senza margine: un'aula in più di quel tipo è la sola cosa "
          "che cambi davvero le carte.")
else:
    print("Nessuna stanza è sopra l'80%: c'è margine per una variazione.")
```
::/python

::if-any{path="saturazione"}
::chart-bar{data="saturazione" x="aula" y="percentuale" height="260"}

::table{path="saturazione" sort="percentuale" dir="desc" filters="tipo,plesso"}
::column{field="aula" label="Aula"}
::column{field="tipo" label="Tipo"}
::column{field="plesso" label="Plesso"}
::column{field="capienza" label="Posti" align="end"}
::column{field="ore" label="Ore occupate" align="end"}
::column{field="libere" label="Ore libere" align="end"}
::column{field="percentuale" label="%" align="end"}
::/table
::/if-any

::if-empty{path="saturazione"}
Non ancora calcolata: premi il pulsante qui sopra dopo aver generato l'orario.
::/if-empty

## La giornata dei docenti

Le **ore buche** sono le ore vuote fra la prima e l'ultima di una giornata: sono
la misura che ogni collegio guarda per prima, e l'unica preferenza che si sente
tutti i giorni. Accanto ci sono i giorni di servizio, il giorno libero ottenuto o
no, e i cambi di plesso — che il generatore non produce mai da un'ora alla
successiva, ma un trascinamento a mano sì.

::python{data="lezioni,docenti,aule" writes="qualita-docenti" manual}
```python
GIORNI = ["lun", "mar", "mer", "gio", "ven", "sab"]
ORE = ["1", "2", "3", "4", "5", "6"]

plesso_aula = {a.get("nome", ""): a.get("plesso", "") for a in data["aule"]}

giornate = {}
for l in data["lezioni"]:
    doc, g, o = l.get("docente", ""), l.get("giorno", ""), str(l.get("ora", ""))
    if not doc or g not in GIORNI or o not in ORE:
        continue
    giornate.setdefault((doc, g), {})[ORE.index(o)] = plesso_aula.get(l.get("aula", ""), "")

result = []
for d in data["docenti"]:
    doc = d.get("cognome", "")
    ore = 0
    buche = 0
    giorni = 0
    cambi = 0
    picco = 0
    for g in GIORNI:
        dentro = giornate.get((doc, g))
        if not dentro:
            continue
        giorni += 1
        indici = sorted(dentro)
        ore += len(indici)
        picco = max(picco, len(indici))
        buche += (indici[-1] - indici[0] + 1) - len(indici)
        for i in range(len(indici) - 1):
            if indici[i + 1] == indici[i] + 1 and dentro[indici[i + 1]] != dentro[indici[i]]:
                cambi += 1
    chiesto = d.get("giornoLibero", "")
    if not chiesto:
        libero = "—"
    elif (doc, chiesto) in giornate:
        libero = "no"
    else:
        libero = "sì"
    result.append({
        "id": "q-" + (d.get("id", "") or doc),
        "docente": doc,
        "tipo": d.get("tipo", ""),
        "ore": str(ore),
        "cattedra": d.get("oreCattedra", ""),
        "giorni": str(giorni),
        "buche": str(buche),
        "picco": str(picco),
        "giornoLibero": libero,
        "cambiPlesso": str(cambi),
    })

result.sort(key=lambda r: (-int(r["buche"]), r["docente"]))
tot_buche = sum(int(r["buche"]) for r in result)
negati = [r["docente"] for r in result if r["giornoLibero"] == "no"]
print("Ore buche in tutta la settimana: %d, su %d docenti." % (tot_buche, len(result)))
print("Peggiori: %s." % ", ".join("%s (%s)" % (r["docente"], r["buche"])
                                  for r in result[:3] if int(r["buche"]) > 0)
      if tot_buche else "Nessun docente ha ore buche.")
if negati:
    print("Giorno libero non ottenuto: %s." % ", ".join(negati))
    print("Alza il peso del giorno libero e rigenera, oppure accettalo: è una "
          "preferenza, e le preferenze si perdono contro i vincoli duri.")
```
::/python

::if-any{path="qualita-docenti"}
::chart-bar{data="qualita-docenti" x="docente" y="buche" height="260"}

::table{path="qualita-docenti" search sort="buche" dir="desc" filters="tipo,giornoLibero"}
::column{field="docente" label="Docente"}
::column{field="tipo" label="Tipo"}
::column{field="ore" label="Ore in orario" align="end"}
::column{field="cattedra" label="Ore di cattedra" align="end"}
::column{field="giorni" label="Giorni" align="end"}
::column{field="buche" label="Ore buche" align="end"}
::column{field="picco" label="Max in un giorno" align="end"}
::column{field="giornoLibero" label="Giorno libero"}
::column{field="cambiPlesso" label="Cambi di plesso" align="end"}
::/table
::/if-any

::if-empty{path="qualita-docenti"}
Non ancora calcolata: premi il pulsante qui sopra dopo aver generato l'orario.
::/if-empty

## Come si legge tutto questo insieme

Un orario che si chiude con zero conflitti, nessuna stanza sopra l'ottanta per
cento e poche ore buche è un orario che regge anche alle variazioni di ottobre.
Se una delle tre cose non torna, le leve sono in ordine di efficacia: **aggiungere
un'aula** del tipo che satura, **togliere un vincolo duro** che nessuno aveva
davvero chiesto, e solo dopo **muovere i pesi** — che ridistribuiscono il disagio
senza toglierlo.

::/page

::page{title="Stampe" icon="print"}

# Stampe

Ogni pulsante stampa **una pagina per riga** della collezione che nomina:
imposta la chiave, lascia alle viste il tempo di rimettersi in pari, fotografa il
bersaglio e passa alla riga dopo, con l'interruzione di pagina fra l'una e
l'altra. La finestra di stampa salva anche in PDF, e alla fine la chiave torna al
valore che aveva.

Le tre stampe sono in **orizzontale**: una settimana è larga sei giorni e su una
pagina verticale diventa una colonna di frammenti.

Il bersaglio deve **dipendere dalla chiave**, altrimenti escono N pagine identiche
e niente lo segnala. Le sezioni qui sotto filtrano su quella chiave —
`classe=#classeSel` la prima, `docente=#docenteSel` la seconda — che sono le
stesse dei selettori della pagina **Orario**.

## Una pagina per classe

::columns{min="30rem" gap="m" id="stampaClasse"}
### Orario della classe :value[classeSel]{ref="#classeSel"}

IIS «Ada Lovelace» — anno scolastico 2026/2027

::timetable{path="lezioni" filter="classe=#classeSel" rows="ora" cols="giorno" row-values="1,2,3,4,5,6" row-labels="1ª,2ª,3ª,4ª,5ª,6ª" col-values="lun,mar,mer,gio,ven,sab" col-labels="Lunedì,Martedì,Mercoledì,Giovedì,Venerdì,Sabato" colour="disciplina"}
**{disciplina}**
{docente} · {aula}
::/timetable
::/columns

::print{target="stampaClasse" repeat="classi" key="classeSel" field="nome" landscape label="Stampa l'orario di ogni classe"}

## Una pagina per docente

::columns{min="30rem" gap="m" id="stampaDocente"}
### Orario del professore :value[docenteSel]{ref="#docenteSel"}

IIS «Ada Lovelace» — anno scolastico 2026/2027

::timetable{path="lezioni" filter="docente=#docenteSel" rows="ora" cols="giorno" row-values="1,2,3,4,5,6" row-labels="1ª,2ª,3ª,4ª,5ª,6ª" col-values="lun,mar,mer,gio,ven,sab" col-labels="Lunedì,Martedì,Mercoledì,Giovedì,Venerdì,Sabato" colour="classe"}
**{classe}**
{disciplina} · {aula}
::/timetable
::/columns

::print{target="stampaDocente" repeat="docenti" key="docenteSel" field="cognome" landscape label="Stampa l'orario di ogni docente"}

## Una pagina per aula

L'occupazione di ogni stanza, che è quello che si appende alla porta del
laboratorio. Stessa direttiva, stessa chiave della griglia in **Orario**.

::columns{min="30rem" gap="m" id="stampaAula"}
### Occupazione dell'aula :value[aulaSel]{ref="#aulaSel"}

IIS «Ada Lovelace» — anno scolastico 2026/2027

::timetable{path="lezioni" filter="aula=#aulaSel" rows="ora" cols="giorno" row-values="1,2,3,4,5,6" row-labels="1ª,2ª,3ª,4ª,5ª,6ª" col-values="lun,mar,mer,gio,ven,sab" col-labels="Lunedì,Martedì,Mercoledì,Giovedì,Venerdì,Sabato" colour="classe"}
**{classe}**
{disciplina} · {docente}
::/timetable
::/columns

::print{target="stampaAula" repeat="aule" key="aulaSel" field="nome" landscape label="Stampa l'occupazione di ogni aula"}

::/page
