crs-zone-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ """crs-zone-toolkit — analyse, recommandation et reprojection CRS (profils régionaux, V1 : Québec).
2
+
3
+ API publique (contrat : docs/ARCHITECTURE.md §4) :
4
+ from crs_zone_toolkit import analyze, apply, report
5
+ `analyze` (recommandation CRS), `apply` (exécution d'une décision) et
6
+ `report` (rapport HTML d'analyse) sont implémentées et exportées.
7
+ `generate_grid` (génération de grille MTM) reste un outil de développement
8
+ interne (docs/gridgen), pas encore exposé ici.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from crs_zone_toolkit.core.results import AnalysisResult, ApplyResult, Decision
17
+
18
+ if TYPE_CHECKING:
19
+ import geopandas as gpd
20
+
21
+ from crs_zone_toolkit.core.profile import RegionProfile
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ _FORMATS_GRILLE: dict[str, str] = {"geojson": "GeoJSON", "gpkg": "GPKG"}
26
+ FORMATS_GRILLE: tuple[str, ...] = tuple(sorted(_FORMATS_GRILLE))
27
+ """Formats d'écriture de la grille (SPEC §6 : geojson | gpkg — pas de shapefile)."""
28
+
29
+
30
+ def _prepare_source_layer(
31
+ layer: gpd.GeoDataFrame, assume_crs: str | int | None
32
+ ) -> gpd.GeoDataFrame:
33
+ """Assigne le CRS supposé à la couche si elle n'en déclare pas (dette J4 #1).
34
+
35
+ N'ASSIGNE pas de reprojection : pose seulement l'étiquette CRS quand la source
36
+ est muette et qu'un --assume-crs est fourni. Partagé par apply/report/CLI.
37
+ """
38
+ if layer.crs is None and assume_crs is not None:
39
+ from pyproj import CRS
40
+
41
+ return layer.set_crs(CRS.from_user_input(assume_crs))
42
+ return layer
43
+
44
+
45
+ def _charger_et_analyser(
46
+ source: Path | str,
47
+ *,
48
+ region: str = "qc",
49
+ assume_crs: str | int | None = None,
50
+ n_samples: int | None = None,
51
+ ) -> tuple[gpd.GeoDataFrame, AnalysisResult, RegionProfile, gpd.GeoDataFrame]:
52
+ """Composition partagée : lit et analyse la couche UNE fois (SPEC §6).
53
+
54
+ Ordre voulu : analyse AVANT assignation du CRS supposé, pour que
55
+ l'avertissement « CRS supposé » soit capté par le moteur (SPEC §4.2.2).
56
+ Renvoie la couche prête pour l'écriture (CRS assigné) + le résultat + le
57
+ profil + la grille, réutilisables par apply/report/CLI sans re-lecture.
58
+ """
59
+ import geopandas as gpd
60
+
61
+ from crs_zone_toolkit.core import analysis as _analysis
62
+ from crs_zone_toolkit.core import messages as _msg
63
+ from crs_zone_toolkit.core.errors import LayerReadError
64
+ from crs_zone_toolkit.regions.loader import load_grid, load_profile
65
+
66
+ source = Path(source)
67
+ profile = load_profile(region)
68
+ grid = load_grid(profile)
69
+ if not source.is_file():
70
+ raise LayerReadError(_msg.fichier_introuvable(str(source)))
71
+ try:
72
+ layer = gpd.read_file(source)
73
+ except Exception as exc: # lecture géospatiale en échec (SPEC §10, code 1)
74
+ raise LayerReadError(_msg.format_non_supporte(str(source))) from exc
75
+ result = _analysis.analyze(
76
+ layer, source.stem, profile=profile, grid=grid, assume_crs=assume_crs, n_samples=n_samples
77
+ )
78
+ layer = _prepare_source_layer(layer, assume_crs)
79
+ return layer, result, profile, grid
80
+
81
+
82
+ def analyze(
83
+ source: Path | str,
84
+ *,
85
+ region: str = "qc",
86
+ assume_crs: str | int | None = None,
87
+ n_samples: int | None = None,
88
+ ) -> AnalysisResult:
89
+ """Analyse une couche (contrat ARCHITECTURE §4)."""
90
+ _, result, _, _ = _charger_et_analyser(
91
+ source, region=region, assume_crs=assume_crs, n_samples=n_samples
92
+ )
93
+ return result
94
+
95
+
96
+ def apply(
97
+ source: Path | str,
98
+ decision: Decision,
99
+ *,
100
+ region: str = "qc",
101
+ out_dir: Path | str | None = None,
102
+ out_format: str = "gpkg",
103
+ overwrite: bool = False,
104
+ assume_crs: str | int | None = None,
105
+ ) -> ApplyResult:
106
+ """Exécute une décision sur une couche (contrat ARCHITECTURE §4)."""
107
+ from crs_zone_toolkit.core import apply as _apply
108
+
109
+ source = Path(source)
110
+ layer, result, profile, grid = _charger_et_analyser(
111
+ source, region=region, assume_crs=assume_crs
112
+ )
113
+ cible = Path(out_dir) if out_dir is not None else source.parent
114
+ return _apply.apply(
115
+ layer,
116
+ source.stem,
117
+ result,
118
+ decision,
119
+ profile=profile,
120
+ grid=grid,
121
+ out_dir=cible,
122
+ out_format=out_format,
123
+ overwrite=overwrite,
124
+ )
125
+
126
+
127
+ def _generer_grille(
128
+ *,
129
+ region: str = "qc",
130
+ out: Path | str,
131
+ out_format: str = "geojson",
132
+ clip: bool = True,
133
+ ) -> tuple[Path, int, tuple[str, ...]]:
134
+ """Compose loader + gridgen + écriture ; renvoie (chemin, n_entités, attributs)."""
135
+ if out_format not in _FORMATS_GRILLE: # DT-19 : même garde que apply (DT-06)
136
+ from crs_zone_toolkit.core import messages as _m
137
+
138
+ raise ValueError(_m.format_sortie_invalide(out_format, list(FORMATS_GRILLE)))
139
+
140
+ from crs_zone_toolkit.core.gridgen import _ATTRIBUTS, build_grid
141
+ from crs_zone_toolkit.regions.loader import load_boundary, load_profile
142
+
143
+ profile = load_profile(region)
144
+ boundary = load_boundary(profile)
145
+ grille = build_grid(profile, boundary, clip=clip)
146
+ out = Path(out)
147
+ if out.parent != Path():
148
+ out.parent.mkdir(parents=True, exist_ok=True)
149
+ grille.to_file(out, driver=_FORMATS_GRILLE[out_format])
150
+ return out, len(grille), _ATTRIBUTS
151
+
152
+
153
+ def report(
154
+ source: Path | str,
155
+ *,
156
+ region: str = "qc",
157
+ out_dir: Path | str | None = None,
158
+ assume_crs: str | int | None = None,
159
+ overwrite: bool = False,
160
+ ) -> Path:
161
+ """Génère le rapport HTML d'analyse d'une couche (contrat SPEC §7)."""
162
+ from datetime import UTC, datetime
163
+
164
+ from crs_zone_toolkit.core import report as _report
165
+
166
+ source = Path(source)
167
+ layer, result, profile, grid = _charger_et_analyser(
168
+ source, region=region, assume_crs=assume_crs
169
+ )
170
+ quand = datetime.now(UTC)
171
+ html = _report.render_html(
172
+ result, layer, profile=profile, grid=grid, generated_at=quand, fichier=source.name
173
+ )
174
+ cible = Path(out_dir) if out_dir is not None else source.parent
175
+ return _report._ecrire(html, source, out_dir=cible, overwrite=overwrite, generated_at=quand)
176
+
177
+
178
+ __all__ = ["analyze", "apply", "report", "AnalysisResult", "ApplyResult", "Decision"]
@@ -0,0 +1,220 @@
1
+ """Rendu Rich des écrans CLI (aucune logique métier, présentation seule).
2
+
3
+ Toutes les chaînes proviennent de core.messages ; ce module ne fait que les
4
+ disposer avec Rich. Les maquettes exactes sont dans docs/CLI_UX.md.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+ from rich.padding import Padding
13
+ from rich.rule import Rule
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ import crs_zone_toolkit
18
+ from crs_zone_toolkit.core import messages as msg
19
+ from crs_zone_toolkit.core.profile import RegionProfile
20
+ from crs_zone_toolkit.core.results import AnalysisResult, ApplyResult
21
+
22
+
23
+ def _ligne(console: Console, texte: str, *, tete: str = "", glyphe: str = "") -> None:
24
+ """Imprime une ligne dont les retours s'alignent SOUS le texte (N18, DT-29).
25
+
26
+ `tete` est le préfixe en clair (« ␣␣ », « ␣␣⚠␣ »…) ; `glyphe` est son
27
+ équivalent balisé Rich, appliqué à la seule première ligne. Le découpage
28
+ lui-même vit dans `messages.envelopper` : il est ainsi testable comme une
29
+ fonction pure, sans passer par une console (TEST_PLAN §5).
30
+ """
31
+ lignes = msg.envelopper(texte, largeur=console.width, tete=tete)
32
+ premiere = lignes[0]
33
+ console.print(f"{glyphe}{premiere[len(tete) :]}" if glyphe else premiere)
34
+ for suite in lignes[1:]:
35
+ if len(suite) <= console.width:
36
+ console.print(suite)
37
+ continue
38
+ # Jeton insécable plus long que la console (un chemin de fichier) :
39
+ # `envelopper` le laisse entier exprès : coupé, il n'est plus copiable.
40
+ # Rich le coupera donc quand même, mais `Padding` lui fait garder le
41
+ # retrait au lieu de le renvoyer en colonne 0, qui est tout le sujet de N18.
42
+ console.print(Padding(Text(suite.lstrip()), (0, 0, 0, len(tete))))
43
+
44
+
45
+ def message(console: Console, texte: str) -> None:
46
+ """Ligne d'information simple, enveloppée sans débordement (N18)."""
47
+ _ligne(console, texte)
48
+
49
+
50
+ def erreur(console: Console, texte: str) -> None:
51
+ """Ligne d'erreur « ✗ … », nommée par N18, qui l'avait relevée débordante."""
52
+ _ligne(console, texte, tete="✗ ", glyphe="[red]✗[/red] ")
53
+
54
+
55
+ def resume_grille(
56
+ console: Console, chemin: Path, n: int, attributs: tuple[str, ...], *, clip: bool
57
+ ) -> None:
58
+ """Écran de la commande grid (CLI_UX §7)."""
59
+ console.print(msg.grille_entete())
60
+ console.print(msg.grille_ligne_decoupe(clip))
61
+ console.print(f"[green]✓[/green] {msg.grille_ligne_ecrite(str(chemin), n, attributs)}")
62
+
63
+
64
+ def bandeau_assume_crs(console: Console, code: str) -> None:
65
+ """Bandeau permanent « CRS supposé » (CLI_UX §6.2, 2 lignes)."""
66
+ _ligne(console, msg.assume_crs_bandeau(code), tete="⚠ ", glyphe="[yellow]⚠[/yellow] ")
67
+ _ligne(console, msg.ASSUME_CRS_TRACE, tete=" ")
68
+
69
+
70
+ def resume_analyse(
71
+ console: Console,
72
+ result: AnalysisResult,
73
+ chemin_rapport: Path | None,
74
+ *,
75
+ couche: Path,
76
+ n_entites: int,
77
+ profile: RegionProfile,
78
+ crs_geographique: bool,
79
+ famille_cible: str,
80
+ abrege: bool = False,
81
+ suggerer_apply: bool = True,
82
+ ) -> None:
83
+ """Résumé terminal d'une analyse (CLI_UX §2/§3/§5).
84
+
85
+ `chemin_rapport` est optionnel : `apply` n'écrit aucun rapport HTML (seule `analyze`
86
+ en écrit un, cf. `cli.py`) ; quand il vaut `None`, la ligne « Rapport détaillé » est
87
+ omise (DT-20 n°5) ; la ligne « Pour appliquer » qui suit reste affichée.
88
+
89
+ `suggerer_apply` : la ligne « Pour appliquer : crszone apply … » ne s'adresse
90
+ qu'au lecteur d'une **analyse**. `apply` la passe à False : elle y proposait la
91
+ commande en cours (N2, DT-29).
92
+
93
+ `famille_cible` (« csrs »/« nad83 ») vient de l'appelant (`target_family(result.famille)`,
94
+ importé localement dans `cli.py`) : ce module ne doit charger ni `geopandas` ni `pyproj`
95
+ (finitions revue B (e)) ; seul le libellé est résolu ici, via `msg.FAMILLE_LIBELLE`.
96
+ """
97
+ reco = result.recommandation
98
+ entete = Table.grid(expand=True) # titre à gauche, version à droite (CLI_UX §2)
99
+ entete.add_column(justify="left")
100
+ entete.add_column(justify="right")
101
+ entete.add_row(
102
+ f"[bold]{msg.analyse_entete(profile.nom, profile.id)}[/bold]",
103
+ msg.analyse_version(crs_zone_toolkit.__version__),
104
+ )
105
+ console.print(entete)
106
+ console.print(Rule(style="dim"))
107
+ _ligne(console, msg.analyse_ligne_couche(couche.name, result.type_geometrie, n_entites))
108
+ crs = result.crs_entree
109
+ _ligne(
110
+ console,
111
+ msg.analyse_ligne_crs_declare(
112
+ crs.get("epsg"), str(crs.get("etiquette", "")), geographique=crs_geographique
113
+ ),
114
+ )
115
+ emprise = result.emprise
116
+ console.print(
117
+ msg.analyse_ligne_emprise(
118
+ emprise.lon_min, emprise.lat_min, emprise.lon_max, emprise.lat_max
119
+ )
120
+ )
121
+ if not abrege:
122
+ console.print()
123
+ console.print(msg.analyse_repartition_titre(result.type_geometrie))
124
+ mc_par_zone = {f.zone: f.meridien_central for f in profile.fuseaux}
125
+ if result.zones_traversees:
126
+ for ligne in msg.analyse_bloc_fuseaux(
127
+ [(zp.zone, zp.part * 100, mc_par_zone[zp.zone]) for zp in result.zones_traversees]
128
+ ):
129
+ console.print(ligne)
130
+ else: # 100 % hors profil : un titre sans ligne se lit comme un bug (DT-22)
131
+ console.print(f" {msg.analyse_repartition_vide()}")
132
+ console.print()
133
+ n_echantillons = int(result.parametres.get("n_echantillons_effectif", 0))
134
+ console.print(msg.analyse_distorsion_titre(n_echantillons))
135
+ seuil = float(result.parametres.get("distorsion_max_ppm", 0))
136
+ for ligne in msg.analyse_bloc_distorsion(result.distorsions, seuil):
137
+ console.print(ligne)
138
+ console.print()
139
+ famille_libelle = msg.FAMILLE_LIBELLE.get(famille_cible, msg.FAMILLE_LIBELLE_DEFAUT)
140
+ ligne_reco = msg.analyse_recommandation(
141
+ reco.action, reco.cible_libelle, reco.cible_epsg, famille_libelle
142
+ )
143
+ _ligne(console, ligne_reco, tete="→ ", glyphe="[cyan]→[/cyan] ")
144
+ _ligne(console, msg.analyse_motif(reco.motif), tete=" ")
145
+ # DT-26 : la marque porte sur ce qui BOUGE. Préservation → signe positif ;
146
+ # changement de famille (repli CSRS, ≈ 1 m au Québec) → ⚠. Le glyphe et la
147
+ # couleur se décident ici : `messages.py` reste sans balisage Rich.
148
+ ligne_datum = msg.analyse_ligne_datum(result.famille, reco.cible_epsg, action=reco.action)
149
+ marque = msg.marque_datum(result.famille, famille_cible)
150
+ glyphe = "[green]✓[/green]" if marque == msg.MARQUE_DATUM_PRESERVE else "[yellow]⚠[/yellow]"
151
+ # tête de 4 caractères : « ␣␣ » + glyphe + « ␣ » ; seule sa LARGEUR compte,
152
+ # le glyphe balisé la remplace sur la première ligne.
153
+ _ligne(console, ligne_datum, tete=" ", glyphe=f" {glyphe} ")
154
+ note_datum = msg.analyse_note_datum(result.famille)
155
+ if note_datum is not None: # conseil neutre : ni ⚠, ni ✓
156
+ _ligne(console, note_datum, tete=" ")
157
+ for av in result.avertissements:
158
+ _ligne(console, av, tete=" ⚠ ", glyphe=" [yellow]⚠[/yellow] ")
159
+ alt_split = next((alt for alt in reco.alternatives if alt.get("action") == "split"), None)
160
+ if alt_split is not None:
161
+ console.print()
162
+ n_sorties = len(alt_split.get("zones", []))
163
+ _ligne(console, msg.analyse_ligne_alternative_split(n_sorties), tete=" ")
164
+ console.print()
165
+ if chemin_rapport is not None:
166
+ _ligne(
167
+ console,
168
+ msg.analyse_rapport(chemin_rapport.name),
169
+ tete="✓ ",
170
+ glyphe="[green]✓[/green] ",
171
+ )
172
+ # N2 (DT-29) : `Pour appliquer : crszone apply <couche>` s'affichait DANS
173
+ # `apply` lui-même : l'outil proposait la commande en cours. Elle ne
174
+ # s'adresse qu'au lecteur d'une ANALYSE, et seulement s'il y a quelque
175
+ # chose à appliquer (DT-22 : jamais quand `action == "aucune"`).
176
+ if suggerer_apply and reco.action != "aucune":
177
+ _ligne(console, msg.analyse_pour_appliquer(couche.name), tete=" ")
178
+
179
+
180
+ def mode_auto(console: Console, result: AnalysisResult) -> None:
181
+ """Ligne « Mode --auto : … » entre le résumé abrégé et les sorties (CLI_UX §5)."""
182
+ reco = result.recommandation
183
+ console.print(msg.apply_mode_auto(reco.cible_libelle, reco.cible_epsg, action=reco.action))
184
+
185
+
186
+ def menu_options(console: Console, result: AnalysisResult) -> None:
187
+ """Menu de décision interactif d'apply (CLI_UX §4)."""
188
+ for ligne in msg.apply_menu(result):
189
+ console.print(ligne)
190
+
191
+
192
+ def rappel_hors_seuil(console: Console, valeur_ppm: float) -> None:
193
+ """Rappel affiché après le choix `[2]` si le fuseau dépasse le seuil (CLI_UX §4).
194
+
195
+ `valeur_ppm` : la valeur qui franchit réellement le seuil (DT-03), pas forcément
196
+ `max_ppm`, voir `messages.apply_hors_seuil`.
197
+ """
198
+ console.print(f" [yellow]⚠[/yellow] {msg.apply_hors_seuil(valeur_ppm)}")
199
+
200
+
201
+ def succes_apply(console: Console, apply_result: ApplyResult) -> None:
202
+ """Écran de succès d'apply (CLI_UX §4)."""
203
+ vert = "[green]✓[/green] "
204
+ for f in apply_result.fichiers:
205
+ _ligne(
206
+ console,
207
+ msg.apply_ligne_sortie(f.chemin, f.epsg, f.n_entites),
208
+ tete="✓ ",
209
+ glyphe=vert,
210
+ )
211
+ _ligne(
212
+ console,
213
+ msg.apply_ligne_journal(apply_result.journal),
214
+ tete="✓ ",
215
+ glyphe=vert,
216
+ )
217
+ for pipeline in apply_result.pipeline_proj:
218
+ _ligne(console, msg.apply_ligne_pipeline(pipeline))
219
+ for av in apply_result.avertissements:
220
+ _ligne(console, av, tete=" ⚠ ", glyphe=" [yellow]⚠[/yellow] ")