"""Lognormal 拟合派生灌注指标的纯算法单元测试(照搬旧项目 test_perfusion_metrics.py)。""" from dataclasses import replace import numpy as np import pytest from cadx.ceus import ( CeusConfig, LognormalFitResult, LognormalParameters, compute_lognormal_perfusion_metrics, evaluate_lognormal, fit_lognormal_curve, ) from conftest import load_ceus_section # 配置不设默认值:旧测试靠默认值构造的配置,改为读 config/ceus.yaml 的取值。 _CONFIG = CeusConfig.model_validate(load_ceus_section()) @pytest.fixture(scope="module") def complete_fit() -> LognormalFitResult: times = np.linspace(0.0, 60.0, 601) parameters = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0) fit = fit_lognormal_curve( times, evaluate_lognormal(times, parameters), baseline=parameters.baseline, baseline_noise=0.2, baseline_end_index=20, duplicate_mask=np.zeros(times.size, dtype=bool), sample_mask=None, cfg=_CONFIG.tic_analysis.fitting, anchor_cfg=_CONFIG.tic_analysis.anchor_frames, ) assert fit.success is True assert fit.d4_time_s is not None return fit def test_complete_bolus_metrics_match_curve_integrals( complete_fit: LognormalFitResult, ) -> None: metrics = compute_lognormal_perfusion_metrics(complete_fit) assert metrics is not None assert complete_fit.parameters is not None assert complete_fit.d2_time_s is not None assert complete_fit.d3_time_s is not None assert complete_fit.d4_time_s is not None assert metrics.ti_s is not None assert metrics.to_s is not None assert metrics.mtt_s is not None assert metrics.wi_auc_au_s is not None assert metrics.wo_auc_au_s is not None assert metrics.wiwo_auc_au_s is not None assert metrics.rt_s is not None assert metrics.ft_s is not None assert metrics.mttl_s is not None assert metrics.wipi_au is not None assert metrics.wor_au_per_s is not None assert ( complete_fit.fit_start_s <= metrics.ti_s < complete_fit.d2_time_s < metrics.ttp_s < complete_fit.d4_time_s < metrics.to_s <= complete_fit.fit_end_s ) assert metrics.pe_au == pytest.approx( float( evaluate_lognormal(np.asarray([metrics.ttp_s]), complete_fit.parameters)[0] - complete_fit.parameters.baseline ) ) assert metrics.ttp_s == pytest.approx(complete_fit.d3_time_s) assert metrics.wir_au_per_s > 0.0 assert metrics.wor_au_per_s < 0.0 assert metrics.rt_s == pytest.approx(metrics.ttp_s - metrics.ti_s) assert metrics.ft_s == pytest.approx(metrics.to_s - metrics.ttp_s) assert metrics.wiwo_auc_au_s == pytest.approx(metrics.wi_auc_au_s + metrics.wo_auc_au_s) assert metrics.wipi_au == pytest.approx(metrics.wi_auc_au_s / metrics.rt_s) assert metrics.mttl_s == pytest.approx(metrics.mtt_s - metrics.ti_s) assert metrics.ti_s < metrics.mtt_s < metrics.to_s grid = np.linspace(metrics.ti_s, metrics.to_s, 100_001) pulse = evaluate_lognormal(grid, complete_fit.parameters) - (complete_fit.parameters.baseline) split = int(np.searchsorted(grid, metrics.ttp_s)) wi_grid = np.concatenate((grid[:split], np.asarray([metrics.ttp_s]))) wo_grid = np.concatenate((np.asarray([metrics.ttp_s]), grid[split:])) wi_pulse = evaluate_lognormal(wi_grid, complete_fit.parameters) - ( complete_fit.parameters.baseline ) wo_pulse = evaluate_lognormal(wo_grid, complete_fit.parameters) - ( complete_fit.parameters.baseline ) assert metrics.wi_auc_au_s == pytest.approx(np.trapezoid(wi_pulse, wi_grid), rel=1e-7) assert metrics.wo_auc_au_s == pytest.approx(np.trapezoid(wo_pulse, wo_grid), rel=1e-7) assert metrics.mtt_s == pytest.approx( np.trapezoid(grid * pulse, grid) / np.trapezoid(pulse, grid), rel=1e-7 ) def test_missing_washout_keeps_only_wash_in_metrics( complete_fit: LognormalFitResult, ) -> None: metrics = compute_lognormal_perfusion_metrics(replace(complete_fit, d4_time_s=None)) assert metrics is not None assert metrics.pe_au > 0.0 assert metrics.ttp_s > 0.0 assert metrics.wir_au_per_s > 0.0 assert metrics.wi_auc_au_s is not None assert metrics.rt_s is not None assert metrics.wipi_au is not None assert metrics.wor_au_per_s is None assert metrics.wo_auc_au_s is None assert metrics.wiwo_auc_au_s is None assert metrics.ft_s is None assert metrics.mttl_s is None assert metrics.to_s is None assert metrics.mtt_s is None def test_failed_fit_has_no_metrics(complete_fit: LognormalFitResult) -> None: assert compute_lognormal_perfusion_metrics(replace(complete_fit, success=False)) is None def test_tangent_intersections_outside_observation_gate_dependents( complete_fit: LognormalFitResult, ) -> None: complete = compute_lognormal_perfusion_metrics(complete_fit) assert complete is not None assert complete.ti_s is not None assert complete.to_s is not None missing_wash_in = compute_lognormal_perfusion_metrics( replace(complete_fit, fit_start_s=complete.ti_s + 0.01) ) assert missing_wash_in is not None assert missing_wash_in.ti_s is None assert missing_wash_in.rt_s is None assert missing_wash_in.wi_auc_au_s is None assert missing_wash_in.wipi_au is None assert missing_wash_in.wo_auc_au_s is not None assert missing_wash_in.wiwo_auc_au_s is None assert missing_wash_in.mttl_s is None missing_wash_out = compute_lognormal_perfusion_metrics( replace(complete_fit, fit_end_s=complete.to_s - 0.01) ) assert missing_wash_out is not None assert missing_wash_out.wor_au_per_s is not None assert missing_wash_out.to_s is None assert missing_wash_out.ft_s is None assert missing_wash_out.wo_auc_au_s is None assert missing_wash_out.wiwo_auc_au_s is None assert missing_wash_out.mttl_s is None