diff --git a/CLAUDE.md b/CLAUDE.md index 8770517..fb590d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,13 +18,16 @@ source .venv/bin/activate # Installare le dipendenze pip install -r requirements.txt -# Generare il dataset (scrive dataset/run_XXXX.csv + dataset/metadata.csv) -python simulate.py - -# Visualizzare il primo run -python plot_csv.py +# Punto di ingresso unico: `python main.py` elenca le azioni disponibili +python main.py mesh # disegna la sola mesh a elementi shell +python main.py simula # genera dataset/run_XXXX.csv + dataset/metadata.csv +python main.py grafico # grafici del primo run +python main.py anima # animazione 2D della sezione +python main.py anima3d # animazione 3D isometrica del barattolo ``` +Ogni modulo resta eseguibile anche direttamente (`python simulate.py`, `python plot_csv.py`, ...). + Attivare sempre il venv (`source .venv/bin/activate`) prima di eseguire qualsiasi comando Python. Non sono configurati test o linter. @@ -37,10 +40,14 @@ Generatore di dataset per misurazioni termiche pseudo-realistiche di una fascett **Flusso dei dati:** -1. `config.py` — tutti i parametri configurabili (dizionari SIMULAZIONE, FASCETTA, ARIA, SORGENTE, SENSORE, RANDOMIZZAZIONE) +0. `main.py` — dispatcher da riga di comando che seleziona l'azione (mesh, simula, grafico, anima, anima3d) +1. `config.py` — tutti i parametri configurabili (dizionari SIMULAZIONE, FASCETTA, MESH, ARIA, SORGENTE, SENSORE, RANDOMIZZAZIONE) 2. `materials.py` — dizionario MATERIALI con proprietà termofisiche ed elettriche per materiale 3. `simulate.py` — motore principale: genera N run randomizzati, scrive i CSV, scrive `metadata.csv` 4. `plot_csv.py` — visualizzazione autonoma per un singolo run +5. `mesh.py` — mesh a elementi shell quadrangolari della superficie media del cilindro (base per la futura analisi FEM), visualizzata da `plot_mesh.py` + +**Mesh shell (`mesh.py`):** griglia strutturata `n_elementi_x × n_elementi_circonferenza` di quadrilateri a 4 nodi sulla superficie media (raggio = (diametro − spessore)/2); lo spessore non è discretizzato, è un attributo della shell. La mesh è chiusa lungo la circonferenza (nessun nodo duplicato sulla cucitura) e usa lo stesso sistema di coordinate globale di `plot_animazione_3d.py`: x = asse, y = R·sin(theta), z = R·cos(theta), con theta = 0 sul piano del sensore. **Pipeline fisica dentro `simula_singolo()` in [simulate.py](simulate.py):** diff --git a/config.py b/config.py index 31c1a0f..9d72336 100644 --- a/config.py +++ b/config.py @@ -80,6 +80,15 @@ FASCETTA = { "materiale": "banda_stagnata", } +MESH = { + # Numero di elementi shell lungo l'asse x (lunghezza della fascetta). + "n_elementi_x": 40, + + # Numero di elementi shell lungo la circonferenza. + # La mesh è chiusa su se stessa: non c'è una riga di nodi duplicata. + "n_elementi_circonferenza": 48, +} + ARIA = { # Temperatura dell'aria ambiente. "temperatura_ambiente_C": 25.0, diff --git a/main.py b/main.py new file mode 100644 index 0000000..2ca39c6 --- /dev/null +++ b/main.py @@ -0,0 +1,77 @@ +# Punto di ingresso unico del progetto: seleziona da riga di comando quale +# azione eseguire. +# +# python main.py mesh # disegna la sola mesh a elementi shell +# python main.py simula # genera il dataset di run +# python main.py grafico # grafici del primo run generato +# python main.py anima # animazione 2D della sezione +# python main.py anima3d # animazione 3D isometrica del barattolo +# +# Senza argomenti stampa l'elenco delle azioni disponibili. + +import argparse +import sys + + +def _azione_mesh() -> None: + import plot_mesh + + plot_mesh.main() + + +def _azione_simula() -> None: + import simulate + + simulate.main() + + +def _azione_grafico() -> None: + import plot_csv + + plot_csv.main() + + +def _azione_anima() -> None: + import plot_animazione + + plot_animazione.main() + + +def _azione_anima3d() -> None: + import plot_animazione_3d + + plot_animazione_3d.main() + + +# Chiave da riga di comando -> (funzione, descrizione mostrata nell'help). +AZIONI = { + "mesh": (_azione_mesh, "Disegna la mesh a elementi shell della fascetta"), + "simula": (_azione_simula, "Genera il dataset di run nella cartella di output"), + "grafico": (_azione_grafico, "Grafici temperatura e flusso del primo run"), + "anima": (_azione_anima, "Animazione 2D del campo di temperatura nella sezione"), + "anima3d": (_azione_anima3d, "Animazione 3D isometrica del barattolo"), +} + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Simulatore termico della fascetta riscaldata a induzione.", + formatter_class=argparse.RawTextHelpFormatter, + ) + parser.add_argument( + "azione", + nargs="?", + choices=list(AZIONI), + help="\n".join(f"{nome}: {descrizione}" for nome, (_, descrizione) in AZIONI.items()), + ) + argomenti = parser.parse_args() + + if argomenti.azione is None: + parser.print_help() + sys.exit(1) + + AZIONI[argomenti.azione][0]() + + +if __name__ == "__main__": + main() diff --git a/mesh.py b/mesh.py new file mode 100644 index 0000000..08dfc36 --- /dev/null +++ b/mesh.py @@ -0,0 +1,114 @@ +# Generazione della mesh a elementi shell quadrangolari della fascetta. +# +# La fascetta è modellata come la superficie media di un cilindro: gli +# elementi sono quadrilateri a 4 nodi disposti su una griglia strutturata +# n_elementi_x × n_elementi_circonferenza. Lo spessore della parete non è +# discretizzato, è un attributo degli elementi shell. +# +# Sistema di coordinate globale (coerente con plot_animazione_3d.py): +# - x = asse del cilindro, da 0 a lunghezza +# - y, z = piano della sezione circolare, con y = R·sin(theta), z = R·cos(theta) +# - theta = 0 corrisponde al piano del sensore (y = 0), cresce in verso +# antiorario nel piano y-z +# +# La mesh è chiusa lungo la circonferenza: l'ultima colonna di elementi +# richiude sulla prima riga di nodi, senza nodi duplicati sulla cucitura. + +import numpy as np + +from config import FASCETTA, MESH + + +def raggio_medio_m(fascetta: dict = FASCETTA) -> float: + # Raggio della superficie media della parete: il diametro configurato è + # quello esterno, la shell sta a metà dello spessore. + diametro_m = fascetta["diametro_mm"] / 1000.0 + spessore_m = fascetta["spessore_mm"] / 1000.0 + return (diametro_m - spessore_m) / 2.0 + + +def genera_mesh(fascetta: dict = FASCETTA, mesh: dict = MESH) -> dict: + """Costruisce la mesh shell quadrangolare della fascetta. + + Restituisce un dizionario con: + - nodi: array (n_nodi, 3) con le coordinate globali [m] + - elementi: array (n_elementi, 4) con gli indici dei nodi, in verso + antiorario visto dall'esterno del cilindro + - indice_nodo: array (n_x + 1, n_theta) che mappa (i, j) sull'indice + globale del nodo + - x_nodi_m, theta_nodi_rad: coordinate parametriche della griglia + - spessore_m, raggio_m, lunghezza_m: geometria della shell + """ + n_x = int(mesh["n_elementi_x"]) + n_theta = int(mesh["n_elementi_circonferenza"]) + if n_x < 1 or n_theta < 3: + raise ValueError( + "Servono almeno 1 elemento lungo x e 3 lungo la circonferenza." + ) + + lunghezza_m = fascetta["lunghezza_mm"] / 1000.0 + spessore_m = fascetta["spessore_mm"] / 1000.0 + raggio_m = raggio_medio_m(fascetta) + + # Lungo x la griglia è aperta (n_x + 1 file di nodi), lungo theta è + # chiusa (n_theta file, l'ultima si ricongiunge alla prima). + x_nodi_m = np.linspace(0.0, lunghezza_m, n_x + 1) + theta_nodi_rad = np.linspace(0.0, 2.0 * np.pi, n_theta, endpoint=False) + + X, Theta = np.meshgrid(x_nodi_m, theta_nodi_rad, indexing="ij") + nodi = np.column_stack( + ( + X.ravel(), + (raggio_m * np.sin(Theta)).ravel(), + (raggio_m * np.cos(Theta)).ravel(), + ) + ) + + indice_nodo = np.arange((n_x + 1) * n_theta).reshape(n_x + 1, n_theta) + + i = np.arange(n_x)[:, None] + j = np.arange(n_theta)[None, :] + j_succ = (j + 1) % n_theta + elementi = np.stack( + ( + np.broadcast_to(indice_nodo[i, j], (n_x, n_theta)), + np.broadcast_to(indice_nodo[i + 1, j], (n_x, n_theta)), + np.broadcast_to(indice_nodo[i + 1, j_succ], (n_x, n_theta)), + np.broadcast_to(indice_nodo[i, j_succ], (n_x, n_theta)), + ), + axis=-1, + ).reshape(-1, 4) + + return { + "nodi": nodi, + "elementi": elementi, + "indice_nodo": indice_nodo, + "x_nodi_m": x_nodi_m, + "theta_nodi_rad": theta_nodi_rad, + "n_elementi_x": n_x, + "n_elementi_circonferenza": n_theta, + "spessore_m": spessore_m, + "raggio_m": raggio_m, + "lunghezza_m": lunghezza_m, + } + + +def riepilogo_mesh(mesh_dati: dict) -> str: + n_nodi = len(mesh_dati["nodi"]) + n_elementi = len(mesh_dati["elementi"]) + passo_x_mm = 1000.0 * mesh_dati["lunghezza_m"] / mesh_dati["n_elementi_x"] + passo_circ_mm = ( + 1000.0 + * 2.0 + * np.pi + * mesh_dati["raggio_m"] + / mesh_dati["n_elementi_circonferenza"] + ) + return ( + f"Mesh shell: {n_elementi} elementi quadrangolari, {n_nodi} nodi\n" + f" griglia: {mesh_dati['n_elementi_x']} (x) × " + f"{mesh_dati['n_elementi_circonferenza']} (circonferenza)\n" + f" passo elemento: {passo_x_mm:.2f} mm (x), {passo_circ_mm:.2f} mm (circonferenza)\n" + f" raggio medio: {1000.0 * mesh_dati['raggio_m']:.2f} mm, " + f"spessore shell: {1000.0 * mesh_dati['spessore_m']:.3f} mm" + ) diff --git a/plot_mesh.py b/plot_mesh.py new file mode 100644 index 0000000..6c9201c --- /dev/null +++ b/plot_mesh.py @@ -0,0 +1,59 @@ +# Visualizzazione della sola mesh a elementi shell della fascetta, in vista +# isometrica: facce chiare con i bordi degli elementi in evidenza. + +from pathlib import Path + +import matplotlib +import matplotlib.pyplot as plt +from mpl_toolkits.mplot3d.art3d import Poly3DCollection + +from mesh import genera_mesh, riepilogo_mesh + + +def main() -> None: + dati = genera_mesh() + print(riepilogo_mesh(dati)) + + nodi = dati["nodi"] + facce = nodi[dati["elementi"]] + + raggio_m = dati["raggio_m"] + lunghezza_m = dati["lunghezza_m"] + + fig = plt.figure(figsize=(9, 7)) + ax = fig.add_subplot(projection="3d") + ax.view_init(elev=35.264, azim=45) + ax.set_box_aspect((lunghezza_m, 2 * raggio_m, 2 * raggio_m)) + ax.set_axis_off() + + collezione = Poly3DCollection( + facce, + facecolors="#d9e3f0", + edgecolors="#2b3a55", + linewidths=0.4, + shade=True, + lightsource=matplotlib.colors.LightSource(azdeg=315, altdeg=45), + ) + ax.add_collection3d(collezione) + + ax.set_xlim(0.0, lunghezza_m) + ax.set_ylim(-raggio_m, raggio_m) + ax.set_zlim(-raggio_m, raggio_m) + ax.set_title( + f"Mesh shell: {dati['n_elementi_x']} × " + f"{dati['n_elementi_circonferenza']} elementi quadrangolari" + ) + + if matplotlib.get_backend().lower() == "agg": + cartella = Path("dataset") + cartella.mkdir(parents=True, exist_ok=True) + percorso = cartella / "mesh.png" + fig.savefig(percorso, dpi=150) + print(f"Backend non interattivo: immagine salvata in {percorso}") + return + + plt.show() + + +if __name__ == "__main__": + main()