diff --git a/.gitignore b/.gitignore
index d6984c2..329aee3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,7 @@ build/
.env
secrets.h
*.key
+
+# Environnement Python
+.venv/
+__pycache__/
diff --git a/schemas/generer.py b/schemas/generer.py
new file mode 100644
index 0000000..9bf2c07
--- /dev/null
+++ b/schemas/generer.py
@@ -0,0 +1,47 @@
+"""Génère les schémas (SVG + PNG) de tous les modules, ou de ceux passés en argument.
+
+Usage : python schemas/generer.py [m03 ...]
+"""
+import importlib
+import sys
+from pathlib import Path
+
+import matplotlib.pyplot as plt
+
+ICI = Path(__file__).resolve().parent
+sys.path.insert(0, str(ICI))
+
+DOSSIER_SVG = ICI / "svg"
+DOSSIER_PNG = ICI / "png"
+
+
+def modules_figures(filtres=()):
+ """Renvoie {préfixe 'mXX': [Figure, ...]} pour chaque fichier mXX_*.py."""
+ resultat = {}
+ for fichier in sorted(ICI.glob("m[0-9][0-9]_*.py")):
+ prefixe = fichier.stem[:3]
+ if filtres and prefixe not in filtres:
+ continue
+ resultat[prefixe] = importlib.import_module(fichier.stem).FIGURES
+ return resultat
+
+
+def enregistrer(fig, id_):
+ if hasattr(fig, "fig"): # dessin schemdraw (backend matplotlib)
+ fig = fig.fig
+ fig.savefig(DOSSIER_SVG / f"{id_}.svg")
+ fig.savefig(DOSSIER_PNG / f"{id_}.png", dpi=200)
+ plt.close(fig)
+
+
+def main():
+ DOSSIER_SVG.mkdir(exist_ok=True)
+ DOSSIER_PNG.mkdir(exist_ok=True)
+ for prefixe, figures in modules_figures(sys.argv[1:]).items():
+ for f in figures:
+ enregistrer(f.dessiner(), f.id)
+ print(f"{f.id} : OK")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/schemas/m03_pwm_adc.py b/schemas/m03_pwm_adc.py
new file mode 100644
index 0000000..7799da0
--- /dev/null
+++ b/schemas/m03_pwm_adc.py
@@ -0,0 +1,153 @@
+"""Module 3 — Signaux analogiques : PWM & ADC."""
+import numpy as np
+import matplotlib.pyplot as plt
+import schemdraw
+import schemdraw.elements as elm
+
+from style import BLEU, ORANGE, GRIS, ENCRE, VERT, CM, Figure
+
+schemdraw.use("matplotlib")
+
+
+def _signal_carre(rapport, periodes=3, points=2000):
+ t = np.linspace(0, periodes, points, endpoint=False)
+ return t, np.where((t % 1) < rapport, 3.3, 0.0)
+
+
+def pwm_rapport_cyclique():
+ fig, axes = plt.subplots(3, 1, figsize=(16 * CM, 11 * CM), sharex=True)
+ for ax, rapport in zip(axes, (0.25, 0.50, 0.75)):
+ t, v = _signal_carre(rapport)
+ moyenne = 3.3 * rapport
+ ax.fill_between(t, v, step=None, color=BLEU, alpha=0.12)
+ ax.plot(t, v, color=BLEU)
+ ax.axhline(moyenne, color=ORANGE, ls="--", lw=1.8)
+ ax.text(3.04, moyenne, f"moyenne\n{round(moyenne + 1e-9, 2):.2f} V".replace(".", ","),
+ color=ORANGE, va="center", fontsize=9, fontweight="bold")
+ ax.set_ylim(-0.3, 4.1)
+ ax.set_yticks([0, 3.3], ["0 V", "3,3 V"])
+ ax.set_title(f"Rapport cyclique {int(rapport * 100)} % → analogWrite(pin, {round(rapport * 255)})",
+ loc="left", fontsize=10, pad=16 if rapport == 0.25 else 6)
+ ax.grid(axis="x")
+ # Cotes T et t_on sur le premier graphe
+ ax = axes[0]
+ ax.annotate("", xy=(1, 3.75), xytext=(2, 3.75),
+ arrowprops=dict(arrowstyle="<->", color=ENCRE, lw=1))
+ ax.text(1.5, 3.85, "période T", ha="center", va="bottom", fontsize=8.5, color=ENCRE)
+ ax.annotate("", xy=(0, 3.75), xytext=(0.25, 3.75),
+ arrowprops=dict(arrowstyle="<->", color=ENCRE, lw=1))
+ ax.text(0.125, 3.85, "t$_{on}$", ha="center", va="bottom", fontsize=8.5, color=ENCRE)
+ axes[-1].set_xticks([0, 1, 2, 3], ["0", "T", "2T", "3T"])
+ axes[-1].set_xlabel("temps (ex. T = 0,2 ms à 5 kHz)")
+ fig.tight_layout()
+ return fig
+
+
+def servo_50hz():
+ fig, axes = plt.subplots(3, 1, figsize=(16 * CM, 9.5 * CM), sharex=True)
+ cas = ((1.0, "0°"), (1.5, "90°"), (2.0, "180°"))
+ t = np.linspace(0, 40, 4000, endpoint=False)
+ for ax, (largeur, angle) in zip(axes, cas):
+ v = np.where((t % 20) < largeur, 3.3, 0.0)
+ ax.plot(t, v, color=BLEU, lw=1.8)
+ ax.fill_between(t, v, color=BLEU, alpha=0.12)
+ ax.set_ylim(-0.3, 4.3)
+ ax.set_yticks([0, 3.3], ["0 V", "3,3 V"])
+ ax.text(22.8, 2.0, f"impulsion {str(largeur).replace('.', ',')} ms → bras à {angle}",
+ fontsize=9, color=ENCRE, va="center")
+ ax.grid(axis="x")
+ ax = axes[0]
+ ax.annotate("", xy=(0, 3.85), xytext=(20, 3.85),
+ arrowprops=dict(arrowstyle="<->", color=ORANGE, lw=1.2))
+ ax.text(10, 3.95, "période = 20 ms (50 Hz)", ha="center", va="bottom",
+ fontsize=9, color=ORANGE, fontweight="bold")
+ axes[-1].set_xticks([0, 5, 10, 15, 20, 25, 30, 35, 40])
+ axes[-1].set_xlabel("temps (ms)")
+ fig.tight_layout()
+ return fig
+
+
+def montage_variateur():
+ d = schemdraw.Drawing(show=False)
+ d.config(fontsize=11, unit=2.5, color=ENCRE)
+ ic = elm.Ic(
+ pins=[
+ # schemdraw empile les broches de gauche de bas en haut
+ elm.IcPin(name="GND", side="left", anchorname="GNDL"),
+ elm.IcPin(name="GPIO1 (ADC1)", side="left", anchorname="ADC"),
+ elm.IcPin(name="3V3", side="left", anchorname="V33"),
+ elm.IcPin(name="GPIO4 (PWM)", side="right", anchorname="PWM", slot="1/3"),
+ ],
+ size=(6.5, 4.2), pinfontsize=10, lblsize=13,
+ ).label("ESP32-S3", loc="top", fontsize=14).color(BLEU)
+ d += ic
+ # Potentiomètre 10 kΩ entre 3V3 et GND, curseur vers GPIO1
+ haut = ic.V33
+ bas = ic.GNDL
+ d += elm.Line().at(haut).left().length(2.2)
+ p_haut = d.here
+ d += elm.Line().at(bas).left().length(2.2)
+ p_bas = d.here
+ d += (pot := elm.Potentiometer().at(p_haut).down().toy(p_bas).label("10 kΩ", loc="top").color(VERT))
+ d += elm.Wire("-").at(pot.tap).to((ic.ADC[0], pot.tap[1])).color(VERT)
+ d += elm.Ground().at(p_bas)
+ # Sortie PWM : résistance 220 Ω puis LED vers GND
+ d += elm.Line().at(ic.PWM).right().length(1)
+ d += elm.Resistor().right().label("220 Ω").color(ORANGE)
+ d += elm.LED().down().label("LED", loc="bottom").color(ORANGE)
+ d += elm.Ground()
+ return d.draw(show=False)
+
+
+def adc_quantification():
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16 * CM, 7.5 * CM),
+ gridspec_kw={"width_ratios": [1, 1.25]})
+ # (a) principe de la quantification, illustré sur 3 bits
+ v = np.linspace(0, 3.3, 1000)
+ niveaux = 8
+ code = np.minimum(np.floor(v / 3.3 * niveaux), niveaux - 1)
+ ax1.plot(v, v / 3.3 * (niveaux - 1), color=GRIS, ls=":", lw=1.3, label="tension réelle")
+ ax1.step(v, code, where="post", color=BLEU, label="valeur numérique")
+ ax1.set_xlim(0, 3.3)
+ ax1.set_yticks(range(niveaux))
+ ax1.set_xlabel("tension d'entrée (V)")
+ ax1.set_ylabel("valeur lue")
+ ax1.set_title("(a) Principe : la tension est\ndécoupée en paliers", loc="left", fontsize=10)
+ ax1.text(2.35, 0.6, "illustré avec 8 paliers (3 bits)\nl'ESP32-S3 en a 4096 (12 bits)",
+ fontsize=7.5, color=GRIS, ha="center")
+ ax1.legend(fontsize=7.5, loc="upper left", frameon=False)
+ # (b) chaîne de conversion utilisée dans l'exercice
+ ax2.plot([0, 3.3], [0, 4095], color=BLEU)
+ ax2.set_xlim(0, 3.3)
+ ax2.set_ylim(0, 4095)
+ ax2.set_yticks([0, 1024, 2048, 3072, 4095])
+ ax2.set_xlabel("tension d'entrée (V)")
+ ax2.set_ylabel("analogRead()", color=BLEU)
+ sec = ax2.secondary_yaxis("right", functions=(lambda x: x * 255 / 4095, lambda y: y * 4095 / 255))
+ sec.set_ylabel("map() → PWM", color=ORANGE)
+ sec.set_yticks([0, 85, 170, 255])
+ sec.tick_params(colors=ORANGE)
+ sec.spines["right"].set_visible(True)
+ sec.spines["right"].set_color(ORANGE)
+ ax2.plot([1.65, 1.65, 3.3], [0, 2048, 2048], color=ORANGE, ls="--", lw=1.2)
+ ax2.plot(1.65, 2048, "o", color=ORANGE)
+ ax2.text(1.75, 1150, "1,65 V → 2048\nmap() → 127", color=ORANGE, fontsize=8.5, fontweight="bold")
+ ax2.set_title("(b) Lecture puis mise à l'échelle", loc="left", fontsize=10)
+ fig.tight_layout()
+ return fig
+
+
+FIGURES = [
+ Figure("m03-pwm-rapport-cyclique",
+ "Le rapport cyclique fixe la tension moyenne perçue par la charge (LED, moteur)",
+ "1. Qu'est-ce que le PWM ?", pwm_rapport_cyclique),
+ Figure("m03-servo-50hz",
+ "Commande d'un servomoteur : la largeur de l'impulsion (1 à 2 ms) fixe l'angle",
+ "2.2 Approche explicite : API LEDC (contrôle fin)", servo_50hz, largeur_cm=15),
+ Figure("m03-montage-variateur",
+ "Câblage de l'exercice : potentiomètre sur GPIO1 (ADC1), LED sur GPIO4 (PWM)",
+ "3. Exercice : variateur de luminosité avec potentiomètre", montage_variateur, largeur_cm=14),
+ Figure("m03-adc-quantification",
+ "Conversion analogique-numérique puis mise à l'échelle avec map()",
+ "4. Le convertisseur analogique-numérique (ADC)", adc_quantification),
+]
diff --git a/schemas/png/m03-adc-quantification.png b/schemas/png/m03-adc-quantification.png
new file mode 100644
index 0000000..cf8274b
Binary files /dev/null and b/schemas/png/m03-adc-quantification.png differ
diff --git a/schemas/png/m03-montage-variateur.png b/schemas/png/m03-montage-variateur.png
new file mode 100644
index 0000000..4594067
Binary files /dev/null and b/schemas/png/m03-montage-variateur.png differ
diff --git a/schemas/png/m03-pwm-rapport-cyclique.png b/schemas/png/m03-pwm-rapport-cyclique.png
new file mode 100644
index 0000000..cebeab8
Binary files /dev/null and b/schemas/png/m03-pwm-rapport-cyclique.png differ
diff --git a/schemas/png/m03-servo-50hz.png b/schemas/png/m03-servo-50hz.png
new file mode 100644
index 0000000..56f362f
Binary files /dev/null and b/schemas/png/m03-servo-50hz.png differ
diff --git a/schemas/requirements.txt b/schemas/requirements.txt
new file mode 100644
index 0000000..2381c86
--- /dev/null
+++ b/schemas/requirements.txt
@@ -0,0 +1,3 @@
+schemdraw>=0.23
+matplotlib>=3.11
+lxml>=6.1
diff --git a/schemas/style.py b/schemas/style.py
new file mode 100644
index 0000000..e2200bb
--- /dev/null
+++ b/schemas/style.py
@@ -0,0 +1,51 @@
+"""Charte graphique commune à tous les schémas de la formation."""
+from dataclasses import dataclass
+from typing import Callable
+
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt # noqa: E402
+
+# Palette
+BLEU = "#1f5fa8" # signal principal
+ORANGE = "#e07a1f" # valeur moyenne, mise en évidence
+VERT = "#2e8b57"
+ROUGE = "#c0392b"
+GRIS = "#6b7280"
+GRIS_CLAIR = "#e5e7eb"
+ENCRE = "#1f2937" # texte
+
+CM = 1 / 2.54 # conversion cm -> pouces pour figsize
+
+plt.rcParams.update({
+ "font.family": "DejaVu Sans",
+ "font.size": 10,
+ "axes.edgecolor": GRIS,
+ "axes.labelcolor": ENCRE,
+ "axes.titlesize": 11,
+ "axes.titleweight": "bold",
+ "axes.titlecolor": ENCRE,
+ "axes.spines.top": False,
+ "axes.spines.right": False,
+ "axes.grid": True,
+ "grid.color": GRIS_CLAIR,
+ "grid.linewidth": 0.8,
+ "xtick.color": GRIS,
+ "ytick.color": GRIS,
+ "lines.linewidth": 2,
+ "svg.fonttype": "path", # texte vectorisé : rendu identique partout
+ "savefig.bbox": "tight",
+ "savefig.pad_inches": 0.1,
+ "savefig.facecolor": "white",
+})
+
+
+@dataclass
+class Figure:
+ """Déclaration d'une figure à générer puis insérer dans un support."""
+ id: str # ex. "m03-pwm-duty" ; le préfixe mXX donne le module
+ legende: str # texte de la légende (sans « Figure X.Y — »)
+ ancre: str # texte exact du titre de section concerné
+ dessiner: Callable # renvoie une figure matplotlib ou un dessin schemdraw
+ largeur_cm: float = 16.0
diff --git a/schemas/svg/m03-adc-quantification.svg b/schemas/svg/m03-adc-quantification.svg
new file mode 100644
index 0000000..e932dcc
--- /dev/null
+++ b/schemas/svg/m03-adc-quantification.svg
@@ -0,0 +1,2635 @@
+
+
+
diff --git a/schemas/svg/m03-montage-variateur.svg b/schemas/svg/m03-montage-variateur.svg
new file mode 100644
index 0000000..db7dd37
--- /dev/null
+++ b/schemas/svg/m03-montage-variateur.svg
@@ -0,0 +1,720 @@
+
+
+
diff --git a/schemas/svg/m03-pwm-rapport-cyclique.svg b/schemas/svg/m03-pwm-rapport-cyclique.svg
new file mode 100644
index 0000000..2bb124b
--- /dev/null
+++ b/schemas/svg/m03-pwm-rapport-cyclique.svg
@@ -0,0 +1,14076 @@
+
+
+
diff --git a/schemas/svg/m03-servo-50hz.svg b/schemas/svg/m03-servo-50hz.svg
new file mode 100644
index 0000000..c90cb28
--- /dev/null
+++ b/schemas/svg/m03-servo-50hz.svg
@@ -0,0 +1,25726 @@
+
+
+