diff --git a/README.md b/README.md index bac047f..e171195 100644 --- a/README.md +++ b/README.md @@ -153,5 +153,44 @@ Use `@registerPostprocess(...)` to declare: - optional Python package dependencies with `required_deps` - required pipeline outputs with `required_pipelines` +### Simple Postprocess Structure + +```python +from postprocess.core.base import ( + BatchPostprocess, + PostprocessContext, + PostprocessResult, + registerPostprocess, +) + + +@registerPostprocess( + name="My Batch Summary", + description="Aggregate metrics across the generated batch outputs.", + required_pipelines=["Basic Stats"], +) +class MyBatchSummary(BatchPostprocess): + def run(self, context: PostprocessContext) -> PostprocessResult: + report_path = context.output_dir / "my_batch_summary.json" + report_path.write_text("{}", encoding="utf-8") + + return PostprocessResult( + summary="Generated my_batch_summary.json.", + generated_paths=[str(report_path)], + metadata={"file_count": len(context.processed_files)}, + ) +``` + +Inside a postprocess, you can: + +- read `context.output_dir` +- read `context.processed_files` +- read `context.selected_pipelines` +- read `context.input_path` +- read `context.zip_outputs` +- write extra artifacts into `context.output_dir` before optional zipping +- return a short `summary`, explicit `generated_paths`, and structured `metadata` + The included `Graphics Dashboard` postprocess shows the intended pattern: it consumes the `arterial_waveform_shape_metrics` output and generates a cohort dashboard plus PNG exports after the batch finishes. `Pipeline Metrics Manifest` is a lighter built-in example that writes a JSON inventory of the generated pipeline metric datasets for the batch. +`Postprocess Tutorial` is the minimal reference example: it writes a single JSON file showing every `PostprocessContext` field and the `PostprocessResult` output format. diff --git a/src/file_paths.py b/src/file_paths.py deleted file mode 100644 index 0579919..0000000 --- a/src/file_paths.py +++ /dev/null @@ -1,39 +0,0 @@ -from pathlib import Path - -def collect_holo_paths(): - - entree = input("Collez le chemin du dossier à scanner : ").strip() - - dossier_source = Path(entree.replace('"', '')) - - - if not dossier_source.is_dir(): - print(f" Erreur : Le chemin '{dossier_source}' n'est pas un dossier valide.") - return - - print(f" Analyse en cours de : {dossier_source}") - - - fichiers_trouves = list(dossier_source.rglob('*.holo')) - - - nom_resultat = "liste_chemins_holo.txt" - - try: - with open(nom_resultat, 'w', encoding='utf-8') as f: - for fichier in fichiers_trouves: - - f.write(f"{fichier.absolute()}\n") - - - print("-" * 40) - print(f" Opération réussie !") - print(f" {len(fichiers_trouves)} fichiers trouvés et listés.") - print(f" Fichier créé : {Path(nom_resultat).absolute()}") - print("-" * 40) - - except Exception as e: - print(f" Une erreur est survenue lors de l'écriture : {e}") - -if __name__ == "__main__": - collect_holo_paths() diff --git a/src/file_paths_h5.py b/src/file_paths_h5.py deleted file mode 100644 index f98217b..0000000 --- a/src/file_paths_h5.py +++ /dev/null @@ -1,90 +0,0 @@ -import shutil -from pathlib import Path -from datetime import datetime - -def collecteur_h5_final(): - print("============================================================") - print(" COLLECTEUR H5 - NOM D'ORIGINE CONSERVÉ") - print("============================================================\n") - - # 1. PARAMÈTRES - txt_input = input("1. Glissez votre fichier .txt ici : ").strip() - chemin_liste = Path(txt_input.replace('"', '')) - - if not chemin_liste.exists(): - print(f" Erreur : Le fichier '{chemin_liste}' est introuvable.") - return - - today_str = datetime.now().strftime("%d/%m/%Y") - date_input = input(f"2. Date de calcul [Défaut: {today_str}] : ").strip() or today_str - try: - date_cible = datetime.strptime(date_input, "%d/%m/%Y").date() - except ValueError: - print(" Format de date incorrect.") - return - - dest_input = input("3. Dossier de destination : ").strip() - dossier_dest = Path(dest_input.replace('"', '')) - - # 2. ANALYSE - print("\n Analyse des dossiers...") - - with open(chemin_liste, 'r', encoding='utf-8') as f: - chemins_sources = [ligne.strip() for ligne in f if ligne.strip()] - - fichiers_a_copier = [] - - for chemin in chemins_sources: - p = Path(chemin) - nom_base = p.stem - dossier_parent = p.parent - - # Ta structure : Nom_HD/eyeflow/Nom_EF/h5/nom_output.h5 - pattern = f"{nom_base}_HD_*/eyeflow/*_EF_*/h5/*.h5" - - for h5_file in dossier_parent.glob(pattern): - # Filtre par date - date_modif = datetime.fromtimestamp(h5_file.stat().st_mtime).date() - - if date_modif == date_cible: - fichiers_a_copier.append(h5_file) - - # 3. VÉRIFICATION AVANT COPIE - if not fichiers_a_copier: - print(f"\n Aucun fichier trouvé pour la date du {date_cible}.") - return - - print("\n" + "!" * 65) - print(f" VÉRIFICATION : {len(fichiers_a_copier)} FICHIERS À COPIER") - print("!" * 65) - - for i, h5_file in enumerate(fichiers_a_copier, 1): - taille_mo = round(h5_file.stat().st_size / (1024**2), 2) - print(f" [{i}] NOM RÉEL : {h5_file.name}") - print(f" TAILLE : {taille_mo} Mo") - print(f" SOURCE : {h5_file.parent}") - print("-" * 40) - - # 4. CONFIRMATION ET COPIE - confirmation = input("\n Voulez-vous copier ces fichiers sans changer leurs noms ? (oui/non) : ").lower().strip() - - if confirmation == 'oui': - dossier_dest.mkdir(parents=True, exist_ok=True) - print("\n Copie en cours...") - - for h5_file in fichiers_a_copier: - # ICI : On garde strictement le nom du fichier trouvé - cible = dossier_dest / h5_file.name - - try: - shutil.copy2(h5_file, cible) - print(f" Copié : {h5_file.name}") - except Exception as e: - print(f" Erreur sur {h5_file.name} : {e}") - - print(f"\n Succès ! Les fichiers originaux sont dans : {dossier_dest.absolute()}") - else: - print("\n Annulé.") - -if __name__ == "__main__": - collecteur_h5_final() \ No newline at end of file diff --git a/src/graphics.py b/src/graphics.py index 3670f86..4ce01f6 100644 --- a/src/graphics.py +++ b/src/graphics.py @@ -133,6 +133,10 @@ def select_support_beat(support, beat_idx): "harmonic_phases", "harmonic_energies", "harmonic_energies_weights", + "harmonic_energy_cumsum", + "harmonic_energy_cumsum_h", + "harmonic_energy_cumsum_interp", + "harmonic_energy_cumsum_h_interp", "delta_phi_all", }: out[k] = arr[beat_idx, :] @@ -790,32 +794,43 @@ def rectified(v): ax.set_xlabel("rectified time : t/T", fontsize=14) ax.set_ylabel(r"$v_b\: (mm/s)$", fontsize=14, labelpad=12) elif metric == "rho_h_90": - w_h = np.asarray(harmonic_energies_weights, dtype=float) - w_h = w_h[np.isfinite(w_h)] - H = len(w_h) + cumsum = np.asarray(support.get("harmonic_energy_cumsum", []), dtype=float) + cumsum_h = np.asarray(support.get("harmonic_energy_cumsum_h", []), dtype=float) - if H == 0: - info_box("Missing harmonic weights") - return + cumsum_interp = np.asarray( + support.get("harmonic_energy_cumsum_interp", []), dtype=float + ) + cumsum_h_interp = np.asarray( + support.get("harmonic_energy_cumsum_h_interp", []), dtype=float + ) - csum = np.cumsum(w_h) - xh = np.arange(1, H + 1) - rho = float(support["rho_h_90"]) - h90 = rho * H + h90 = float(support.get("h_90", np.nan)) + rho = float(support.get("rho_h_90", np.nan)) + mask_i = np.isfinite(cumsum_interp) & np.isfinite(cumsum_h_interp) + mask_d = np.isfinite(cumsum) & np.isfinite(cumsum_h) - ax.step(xh, csum, where="mid", color="#EC5241", linewidth=2) - ax.axhline(0.90, linestyle="--", color="black", linewidth=1) - ax.axvline(h90, linestyle="--", color="black", linewidth=1) + ax.plot( + cumsum_h_interp[mask_i], + cumsum_interp[mask_i], + color="#EC5241", + linewidth=2, + ) - info_box( - [ - rf"$\rho_{{h,90}} = {rho:.3f}$", - rf"$h_{{90}} = {h90:.2f}$", - ] + ax.plot( + cumsum_h[mask_d], + cumsum[mask_d], + "o", + color="black", + markersize=4, ) - ax.set_xlabel("Harmonic n (a.u.)", fontsize=14) - ax.set_ylabel(r"Energy weights $w_n$", fontsize=14, labelpad=12) - ax.set_ylim(0, 1.05) + + ax.axhline(0.90, linestyle="--", color="black", linewidth=1) + if np.isfinite(h90): + ax.axvline(h90, linestyle="--", color="black", linewidth=1) + ax.plot(h90, 0.90, "o", color="black", markersize=5) + + ax.set_xlabel("Harmonic index $h$ (a.u.)", fontsize=14) + ax.set_ylabel(r"$C(h)$", fontsize=14) elif metric == "mu_h": w_h = harmonic_energies_weights mu_h = float(support["mu_h"]) @@ -1200,7 +1215,6 @@ def rectified(v): elif metric == "E_slope": e_slope = float(support["E_slope"]) dvdt_norm = support["dvdt_norm"] - ax.plot(tau, sig, linewidth=3, color="#EC5241", label="signal") ax2 = ax.twinx() ax2.plot( @@ -1211,16 +1225,15 @@ def rectified(v): color="black", label=r"$\dot v^2$", ) - ax2.set_ylabel(r"$\frac{T^3}{(M_0 + \epsilon)^2} * \dot v^2$", fontsize=10) + ax2.set_ylabel(r"$\dot v^2$", fontsize=12) ax2.set_yticks([]) info_box([rf"$E_{{slope}}={e_slope:.4f}$"]) - ax.set_xlabel("rectified time : t/T", fontsize=12) - ax.set_ylabel(r"$v_b\: (mm/s)$", fontsize=12, labelpad=10) + ax.set_xlabel("rectified time : t/T", fontsize=14) + ax.set_ylabel(r"$v_b\: (mm/s)$", fontsize=14, labelpad=12) elif metric == "E_curv": e_curv = float(support["E_curv"]) d2vdt2_norm = support["d2vdt2_norm"] - ax.plot(tau, sig, linewidth=3, color="#EC5241", label="signal") ax2 = ax.twinx() ax2.plot( @@ -1232,10 +1245,10 @@ def rectified(v): label=r"$\ddot v^2$", ) ax2.set_yticks([]) - ax2.set_ylabel(r"$\frac{T^5}{(M_0 + \epsilon)^2} *\ddot v^2$", fontsize=10) - info_box([rf"$E_{{curv}}={e_curv:.0f}$"]) - ax.set_xlabel("rectified time : t/T", fontsize=12) - ax.set_ylabel(r"$v_b\: (mm/s)$", fontsize=12, labelpad=10) + ax2.set_ylabel(r"$\ddot v^2$", fontsize=12) + info_box([rf"$E_{{curv}}={e_curv:.4f}$"]) + ax.set_xlabel("rectified time : t/T", fontsize=14) + ax.set_ylabel(r"$v_b\: (mm/s)$", fontsize=14, labelpad=12) elif metric == "W50_over_T": w50 = float(support["W50_over_T"]) @@ -1432,7 +1445,7 @@ def export_selected_metric_pngs_bandlimited(all_results, zip_path, out_dir): ax_empty = fig.add_subplot(right[r, c]) ax_empty.axis("off") - png_path = os.path.join(out_dir, f"{metric}_bandlimited.png") + png_path = os.path.join(out_dir, f"{metric}_bandlimited.eps") fig.savefig(png_path) plt.close(fig) diff --git a/src/pipelines/arterial_waveform_shape_metrics.py b/src/pipelines/arterial_waveform_shape_metrics.py index a195e7f..5592e8e 100644 --- a/src/pipelines/arterial_waveform_shape_metrics.py +++ b/src/pipelines/arterial_waveform_shape_metrics.py @@ -15,7 +15,9 @@ class ArterialSegExample(ProcessPipeline): v_raw_segment_input = ( "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegment/value" ) - v_band_segment_input = "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" + v_band_segment_input = ( + "/Artery/VelocityPerBeat/Segments/VelocitySignalPerBeatPerSegmentBandLimited/value" + ) v_raw_global_input = "/Artery/VelocityPerBeat/VelocitySignalPerBeat/value" v_band_global_input = ( @@ -34,7 +36,7 @@ class ArterialSegExample(ProcessPipeline): H_LOW_MAX = 1 H_HIGH_MIN = 2 H_HIGH_MAX = 10 - + H_CUMSUM_INTERP_POINTS = 256 H_MAX = 10 H_PHASE_RESIDUAL = 10 @@ -281,6 +283,13 @@ def _rho_h_90_support_from_harmonics(self, V: np.ndarray) -> dict: "rho_h_90": np.nan, "h_90": np.nan, "harmonic_energy_cumsum": np.full((self.H_MAX,), np.nan, dtype=float), + "harmonic_energy_cumsum_h": np.full((self.H_MAX,), np.nan, dtype=float), + "harmonic_energy_cumsum_interp": np.full( + (self.H_CUMSUM_INTERP_POINTS,), np.nan, dtype=float + ), + "harmonic_energy_cumsum_h_interp": np.full( + (self.H_CUMSUM_INTERP_POINTS,), np.nan, dtype=float + ), } if V is None: @@ -299,12 +308,20 @@ def _rho_h_90_support_from_harmonics(self, V: np.ndarray) -> dict: w = power / s C = np.cumsum(w) + h = np.arange(1, H + 1, dtype=float) out["harmonic_energy_cumsum"][:H] = C + out["harmonic_energy_cumsum_h"][:H] = h C_full = np.concatenate(([0.0], C)) h_full = np.arange(0, H + 1, dtype=float) + h_interp = np.linspace(0.0, float(H), self.H_CUMSUM_INTERP_POINTS) + C_interp = np.interp(h_interp, h_full, C_full) + + out["harmonic_energy_cumsum_h_interp"][:] = h_interp + out["harmonic_energy_cumsum_interp"][:] = C_interp + h90 = float(np.interp(0.90, C_full, h_full)) out["h_90"] = h90 out["rho_h_90"] = float(h90 / H) @@ -771,7 +788,6 @@ def _compute_graphics_support_1d(self, v: np.ndarray, Tbeat: float) -> dict: vmin = float(np.nanmin(vv)) vmean = float(np.nanmean(vv)) - # Cumulative displacement geometry sampled on normalized phase d_full = np.concatenate( ([0.0], np.cumsum(np.where(np.isfinite(vv), vv, 0.0)) / m0_sum) ) @@ -783,10 +799,8 @@ def _compute_graphics_support_1d(self, v: np.ndarray, Tbeat: float) -> dict: dvdt = np.gradient(np.where(np.isfinite(vv), vv, 0.0), dt) d2vdt2 = np.gradient(dvdt, dt) - - dvdt_norm= ((Tbeat**3) / (m0 + self.eps) ** 2) * (dvdt**2) - d2vdt2_norm = ((Tbeat**5) / (m0 + self.eps) ** 2) * (d2vdt2**2) - + dvdt_norm = (Tbeat**3 / ((m0 + self.eps) ** 2)) * (dvdt**2) + d2vdt2_norm = (Tbeat**3 / ((m0 + self.eps) ** 2)) * (d2vdt2**2) hp = self._harmonic_pack(vv, Tbeat) V = hp["V"] vb = hp["vb"] @@ -805,8 +819,8 @@ def _compute_graphics_support_1d(self, v: np.ndarray, Tbeat: float) -> dict: E_high = np.nan if V is not None and H >= 0: - mags = np.abs(V[: H + 1]) # indices 0..H - power = mags**2 # |V_n|^2 + mags = np.abs(V[: H + 1]) + power = mags**2 harmonic_energies[: H + 1] = power harmonic_magnitudes[: H + 1] = mags @@ -824,11 +838,9 @@ def _compute_graphics_support_1d(self, v: np.ndarray, Tbeat: float) -> dict: E_low = float(np.nansum(power[1 : self.H_LOW_MAX + 1])) E_high = float(np.nansum(power[self.H_HIGH_MIN : self.H_HIGH_MAX + 1])) - # poids énergie : définis seulement sur n>=1 if np.isfinite(power_sum) and power_sum > 0: harmonic_energy_weights[0:H] = power_h / (power_sum + self.eps) - # poids amplitude : définis seulement sur n>=1 if np.isfinite(mag_sum) and mag_sum > 0: harmonic_weights[0:H] = mags_h / (mag_sum + self.eps) @@ -852,6 +864,13 @@ def _compute_graphics_support_1d(self, v: np.ndarray, Tbeat: float) -> dict: return { "harmonic_energy_cumsum": rho_support["harmonic_energy_cumsum"], + "harmonic_energy_cumsum_h": rho_support["harmonic_energy_cumsum_h"], + "harmonic_energy_cumsum_interp": rho_support[ + "harmonic_energy_cumsum_interp" + ], + "harmonic_energy_cumsum_h_interp": rho_support[ + "harmonic_energy_cumsum_h_interp" + ], "h_90": np.asarray(rho_support["h_90"], dtype=float), "H_MAX": np.asarray(self.H_MAX, dtype=int), "H_LOW_MAX": np.asarray(self.H_LOW_MAX, dtype=int), @@ -869,6 +888,8 @@ def _compute_graphics_support_1d(self, v: np.ndarray, Tbeat: float) -> dict: "vb": vb_out, "dvdt": np.asarray(dvdt, dtype=float), "d2vdt2": np.asarray(d2vdt2, dtype=float), + "dvdt_norm": np.asarray(dvdt_norm, dtype=float), + "d2vdt2_norm": np.asarray(d2vdt2_norm, dtype=float), "harmonic_magnitudes": harmonic_magnitudes, "harmonic_weights": harmonic_weights, "harmonic_energies": harmonic_energies, @@ -883,9 +904,6 @@ def _compute_graphics_support_1d(self, v: np.ndarray, Tbeat: float) -> dict: "late_window_start_idx": np.asarray(k0, dtype=int), "late_window_end_idx": np.asarray(k1, dtype=int), **{k: np.asarray(val, dtype=float) for k, val in metrics.items()}, - - "dvdt_norm" : np.asarray(dvdt_norm, dtype=float), - "d2vdt2_norm" : np.asarray(d2vdt2_norm, dtype=float), } def _compute_graphics_support_block( @@ -900,6 +918,13 @@ def _compute_graphics_support_block( h_phi = max(self.H_PHASE_RESIDUAL - 1, 0) out = { + "harmonic_energy_cumsum_h": np.full((n_beats, h_mag), np.nan, dtype=float), + "harmonic_energy_cumsum_interp": np.full( + (n_beats, self.H_CUMSUM_INTERP_POINTS), np.nan, dtype=float + ), + "harmonic_energy_cumsum_h_interp": np.full( + (n_beats, self.H_CUMSUM_INTERP_POINTS), np.nan, dtype=float + ), "harmonic_energy_cumsum": np.full((n_beats, h_mag), np.nan, dtype=float), "h_90": np.full((n_beats,), np.nan, dtype=float), "H_MAX": np.asarray(self.H_MAX, dtype=int), @@ -913,12 +938,14 @@ def _compute_graphics_support_block( "d0_star": np.full((n_t, n_beats), np.nan, dtype=float), "delta_dti_curve": np.full((n_t, n_beats), np.nan, dtype=float), "vb": np.full((n_t, n_beats), np.nan, dtype=float), - "dvdt": np.full((n_t, n_beats), np.nan, dtype=float), "m0": np.full((n_beats,), np.nan), "E_total": np.full((n_beats,), np.nan, dtype=float), "E_low": np.full((n_beats,), np.nan, dtype=float), "E_high": np.full((n_beats,), np.nan, dtype=float), + "dvdt": np.full((n_t, n_beats), np.nan, dtype=float), + "dvdt_norm": np.full((n_t, n_beats), np.nan, dtype=float), "d2vdt2": np.full((n_t, n_beats), np.nan, dtype=float), + "d2vdt2_norm": np.full((n_t, n_beats), np.nan, dtype=float), "harmonic_magnitudes": np.full((n_beats, h_mag + 1), np.nan, dtype=float), "harmonic_weights": np.full((n_beats, h_mag), np.nan, dtype=float), "harmonic_phases": np.full((n_beats, h_mag), np.nan, dtype=float), @@ -931,8 +958,6 @@ def _compute_graphics_support_block( "vmax": np.full((n_beats,), np.nan, dtype=float), "vmin": np.full((n_beats,), np.nan, dtype=float), "vmean": np.full((n_beats,), np.nan, dtype=float), - "dvdt_norm": np.full((n_t,n_beats), np.nan, dtype=float), - "d2vdt2_norm": np.full((n_t,n_beats), np.nan, dtype=float), } for k in self._metric_keys(): @@ -943,6 +968,13 @@ def _compute_graphics_support_block( v = v_global[:, beat_idx] s = self._compute_graphics_support_1d(v, Tbeat) out["harmonic_energy_cumsum"][beat_idx, :] = s["harmonic_energy_cumsum"] + out["harmonic_energy_cumsum_h"][beat_idx, :] = s["harmonic_energy_cumsum_h"] + out["harmonic_energy_cumsum_interp"][beat_idx, :] = s[ + "harmonic_energy_cumsum_interp" + ] + out["harmonic_energy_cumsum_h_interp"][beat_idx, :] = s[ + "harmonic_energy_cumsum_h_interp" + ] out["h_90"][beat_idx] = s["h_90"] out["E_total"][beat_idx] = s["E_total"] out["E_low"][beat_idx] = s["E_low"] @@ -956,6 +988,8 @@ def _compute_graphics_support_block( out["vb"][:, beat_idx] = s["vb"] out["dvdt"][:, beat_idx] = s["dvdt"] out["d2vdt2"][:, beat_idx] = s["d2vdt2"] + out["dvdt_norm"][:, beat_idx] = s["dvdt_norm"] + out["d2vdt2_norm"][:, beat_idx] = s["d2vdt2_norm"] out["m0"][beat_idx] = s["m0"] out["harmonic_magnitudes"][beat_idx, :] = s["harmonic_magnitudes"] out["harmonic_weights"][beat_idx, :] = s["harmonic_weights"] @@ -971,8 +1005,6 @@ def _compute_graphics_support_block( out["vend"][beat_idx] = s["vend"] out["late_window_start_idx"][beat_idx] = s["late_window_start_idx"] out["late_window_end_idx"][beat_idx] = s["late_window_end_idx"] - out["dvdt_norm"][:,beat_idx] = s["dvdt_norm"] - out["d2vdt2_norm"][:,beat_idx] = s["d2vdt2_norm"] for k in self._metric_keys(): out[k[0]][beat_idx] = s[k[0]] @@ -1261,10 +1293,11 @@ def _metric_keys() -> list[list]: def _compute_block_segment(self, v_block: np.ndarray, T: np.ndarray): """ v_block: (n_t, n_beats, n_branches, n_radii) + Returns: - per-segment arrays: (n_beats, n_segments) - per-branch arrays: (n_beats, n_branches) (median over radii) - global arrays: (n_beats,) (mean over all branches & radii) + per-segment arrays: (n_beats, n_branches, n_radii) + per-branch arrays: (n_beats, n_branches) (median over radii) + global arrays: (n_beats,) (median over all branch-radius values) """ if v_block.ndim != 4: raise ValueError( @@ -1272,10 +1305,9 @@ def _compute_block_segment(self, v_block: np.ndarray, T: np.ndarray): ) n_t, n_beats, n_branches, n_radii = v_block.shape - n_segments = n_branches * n_radii seg = { - k[0]: np.full((n_beats, n_segments), np.nan, dtype=float) + k[0]: np.full((n_beats, n_branches, n_radii), np.nan, dtype=float) for k in self._metric_keys() } br = { @@ -1298,25 +1330,25 @@ def _compute_block_segment(self, v_block: np.ndarray, T: np.ndarray): v = v_block[:, beat_idx, branch_idx, radius_idx] m = self._compute_metrics_1d(v, Tbeat) - seg_idx = branch_idx * n_radii + radius_idx for k in self._metric_keys(): - seg[k[0]][beat_idx, seg_idx] = m[k[0]] - br_vals[k[0]].append(m[k[0]]) - gl_vals[k[0]].append(m[k[0]]) + key = k[0] + seg[key][beat_idx, branch_idx, radius_idx] = m[key] + br_vals[key].append(m[key]) + gl_vals[key].append(m[key]) for k in self._metric_keys(): - br[k[0]][beat_idx, branch_idx] = self._safe_nanmedian( - np.asarray(br_vals[k[0]], dtype=float) + key = k[0] + br[key][beat_idx, branch_idx] = self._safe_nanmedian( + np.asarray(br_vals[key], dtype=float) ) for k in self._metric_keys(): - gl[k[0]][beat_idx] = self._safe_nanmean( - np.asarray(gl_vals[k[0]], dtype=float) + key = k[0] + gl[key][beat_idx] = self._safe_nanmedian( + np.asarray(gl_vals[key], dtype=float) ) - seg_order_note = ( - "seg_idx = branch_idx * n_radii + radius_idx (branch-major flattening)" - ) + seg_order_note = "segment arrays are stored as (beat, branch, radius)" return seg, br, gl, n_branches, n_radii, seg_order_note def _compute_block_global(self, v_global: np.ndarray, T: np.ndarray): @@ -1426,12 +1458,18 @@ def pack(prefix: str, d: dict, attrs_common: dict): pack( "by_segment/bandlimited_segment", seg_b, - {"segment_indexing": [seg_note]}, + { + "definition": ["per-segment metrics stored as (beat, branch, radius)"], + "segment_indexing": [seg_note], + }, ) pack( "by_segment/raw_segment", seg_r, - {"segment_indexing": [seg_note]}, + { + "definition": ["per-segment metrics stored as (beat, branch, radius)"], + "segment_indexing": [seg_note], + }, ) pack( @@ -1448,12 +1486,12 @@ def pack(prefix: str, d: dict, attrs_common: dict): pack( "by_segment/bandlimited_global", gl_b, - {"definition": ["mean over branches and radii"]}, + {"definition": ["median over all branch-radius segment values per beat"]}, ) pack( "by_segment/raw_global", gl_r, - {"definition": ["mean over branches and radii"]}, + {"definition": ["median over all branch-radius segment values per beat"]}, ) metrics["by_segment/params/ratio_R_VTI"] = np.asarray( @@ -1535,6 +1573,7 @@ def pack(prefix: str, d: dict, attrs_common: dict): metrics["global/params/H_PHASE_RESIDUAL"] = np.asarray( self.H_PHASE_RESIDUAL, dtype=int ) + graphics_raw = self._compute_graphics_support_block(v_raw_gl, T) graphics_band = self._compute_graphics_support_block(v_band_gl, T) for name, arr in graphics_raw.items(): @@ -1543,4 +1582,4 @@ def pack(prefix: str, d: dict, attrs_common: dict): for name, arr in graphics_band.items(): metrics[f"global/bandlimited/{name}"] = arr - return ProcessResult(metrics=metrics) + return ProcessResult(metrics=metrics) \ No newline at end of file diff --git a/src/postprocess/tutorial_postprocess.py b/src/postprocess/tutorial_postprocess.py new file mode 100644 index 0000000..8d3a298 --- /dev/null +++ b/src/postprocess/tutorial_postprocess.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json + +from .core.base import ( + BatchPostprocess, + PostprocessContext, + PostprocessResult, + registerPostprocess, +) + + +@registerPostprocess( + name="Postprocess Tutorial", + description=( + "Minimal tutorial showing the available PostprocessContext fields and " + "the PostprocessResult output format." + ), +) +class PostprocessTutorial(BatchPostprocess): + """ + Minimal postprocess example. + + It does not inspect HDF5 contents. It only shows: + - which fields are available on `context` + - which fields are returned through `PostprocessResult` + """ + + def run(self, context: PostprocessContext) -> PostprocessResult: + tutorial_path = context.output_dir / "postprocess_tutorial.json" + + result = PostprocessResult( + summary="Generated postprocess_tutorial.json.", + generated_paths=[str(tutorial_path)], + metadata={ + "processed_file_count": len(context.processed_files), + "selected_pipelines": list(context.selected_pipelines), + }, + ) + + payload = { + "postprocess_name": self.name, + "context_fields": { + "output_dir": str(context.output_dir), + "processed_files": [str(path) for path in context.processed_files], + "selected_pipelines": list(context.selected_pipelines), + "input_path": str(context.input_path), + "zip_outputs": context.zip_outputs, + }, + "result_format": { + "summary": result.summary, + "generated_paths": result.generated_paths, + "metadata": result.metadata, + }, + } + + tutorial_path.write_text( + json.dumps(payload, indent=2, sort_keys=True), + encoding="utf-8", + ) + return result diff --git a/test/test_postprocess_tutorial.py b/test/test_postprocess_tutorial.py new file mode 100644 index 0000000..2b06049 --- /dev/null +++ b/test/test_postprocess_tutorial.py @@ -0,0 +1,86 @@ +# ruff: noqa: E402 + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +SRC_DIR = Path(__file__).resolve().parents[1] / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from postprocess.core.base import PostprocessContext +from postprocess.tutorial_postprocess import PostprocessTutorial + + +class PostprocessTutorialTests(unittest.TestCase): + def test_tutorial_generates_minimal_json_report(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + output_dir = tmp_path / "outputs" + output_dir.mkdir() + result_file = output_dir / "sample_result.h5" + result_file.write_text("placeholder", encoding="utf-8") + + context = PostprocessContext( + output_dir=output_dir, + processed_files=(result_file,), + selected_pipelines=("Basic Stats",), + input_path=tmp_path / "input_folder", + zip_outputs=False, + ) + + result = PostprocessTutorial().run(context) + + json_path = output_dir / "postprocess_tutorial.json" + + self.assertEqual( + [str(json_path)], + result.generated_paths, + ) + self.assertEqual(1, result.metadata["processed_file_count"]) + self.assertTrue(json_path.exists()) + self.assertEqual( + ["Basic Stats"], + result.metadata["selected_pipelines"], + ) + + payload = json.loads(json_path.read_text(encoding="utf-8")) + self.assertEqual("Postprocess Tutorial", payload["postprocess_name"]) + self.assertEqual( + str(output_dir), + payload["context_fields"]["output_dir"], + ) + self.assertEqual( + [str(result_file)], + payload["context_fields"]["processed_files"], + ) + self.assertEqual( + ["Basic Stats"], + payload["context_fields"]["selected_pipelines"], + ) + self.assertEqual( + str(tmp_path / "input_folder"), + payload["context_fields"]["input_path"], + ) + self.assertFalse(payload["context_fields"]["zip_outputs"]) + self.assertEqual( + "Generated postprocess_tutorial.json.", + payload["result_format"]["summary"], + ) + self.assertEqual( + [str(json_path)], + payload["result_format"]["generated_paths"], + ) + self.assertEqual( + { + "processed_file_count": 1, + "selected_pipelines": ["Basic Stats"], + }, + payload["result_format"]["metadata"], + ) + + +if __name__ == "__main__": + unittest.main()