Source code for nlsq.diagnostics.health_report

"""Model Health Report aggregation and formatting.

This module provides the create_health_report() factory function for
creating aggregated health reports from component analyses, as well as
summary formatting and serialization utilities.

The health report aggregates results from:
- Identifiability analysis
- Gradient health monitoring
- Parameter sensitivity analysis (level=FULL)
- Custom diagnostic plugins

It computes an overall health score and status, collects all issues,
and provides human-readable summaries with recommendations.
"""

import time
import warnings

from nlsq.diagnostics.types import (
    DiagnosticLevel,
    DiagnosticsConfig,
    GradientHealthReport,
    HealthStatus,
    IdentifiabilityReport,
    IssueSeverity,
    ModelHealthIssue,
    ModelHealthReport,
    ParameterSensitivityReport,
    PluginResult,
    _format_model_health_summary,
    _model_health_to_dict,
)


[docs] def create_health_report( identifiability: IdentifiabilityReport | None = None, gradient_health: GradientHealthReport | None = None, sloppy_model: ParameterSensitivityReport | None = None, plugin_results: dict[str, PluginResult] | None = None, config: DiagnosticsConfig | None = None, ) -> ModelHealthReport: """Create aggregated health report from component reports. This factory function aggregates results from all diagnostic components into a unified ModelHealthReport with: - Overall status determination (HEALTHY/WARNING/CRITICAL) - Health score computation with weighted contributions - Issue aggregation sorted by severity and code - Optional warnings emission for critical issues - Optional verbose console output Parameters ---------- identifiability : IdentifiabilityReport, optional Identifiability analysis results. gradient_health : GradientHealthReport, optional Gradient health monitoring results. sloppy_model : ParameterSensitivityReport, optional Sloppy model analysis results. plugin_results : dict[str, PluginResult], optional Results from diagnostic plugins, keyed by plugin name. config : DiagnosticsConfig, optional Configuration for formatting and warnings. Returns ------- ModelHealthReport Complete aggregated report with status, score, and all issues. Examples -------- >>> from nlsq.diagnostics.health_report import create_health_report >>> report = create_health_report( ... identifiability=ident_report, ... gradient_health=grad_report, ... ) >>> print(report.status) HealthStatus.HEALTHY >>> print(report.health_score) 0.95 """ start_time = time.perf_counter() # Use default config if not provided if config is None: config = DiagnosticsConfig() # Initialize plugin results dict if None if plugin_results is None: plugin_results = {} # Collect all issues from all components all_issues = _collect_issues( identifiability=identifiability, gradient_health=gradient_health, sloppy_model=sloppy_model, plugin_results=plugin_results, ) # Sort issues by severity (CRITICAL > WARNING > INFO) then by code all_issues = _sort_issues(all_issues) # Determine overall status from issues status = _determine_status(all_issues, identifiability, gradient_health) # Compute health score health_score = _compute_health_score( identifiability=identifiability, gradient_health=gradient_health, sloppy_model=sloppy_model, config=config, ) # Compute total computation time from components computation_time_ms = _compute_total_time( identifiability=identifiability, gradient_health=gradient_health, sloppy_model=sloppy_model, plugin_results=plugin_results, ) # Add time for this function computation_time_ms += (time.perf_counter() - start_time) * 1000 # Create the report report = ModelHealthReport( identifiability=identifiability, gradient_health=gradient_health, sloppy_model=sloppy_model, plugin_results=plugin_results, status=status, health_score=health_score, all_issues=all_issues, config=config, computation_time_ms=computation_time_ms, ) # Emit warnings for critical issues if configured (FR-019) if config.emit_warnings: _emit_warnings(all_issues) # Print verbose output if configured (FR-020) if config.verbose: print(report.summary(verbose=True)) return report
def _collect_issues( identifiability: IdentifiabilityReport | None, gradient_health: GradientHealthReport | None, sloppy_model: ParameterSensitivityReport | None, plugin_results: dict[str, PluginResult], ) -> list[ModelHealthIssue]: """Collect all issues from all component reports. Parameters ---------- identifiability : IdentifiabilityReport | None Identifiability analysis results. gradient_health : GradientHealthReport | None Gradient health monitoring results. sloppy_model : ParameterSensitivityReport | None Sloppy model analysis results. plugin_results : dict[str, PluginResult] Results from diagnostic plugins. Returns ------- list[ModelHealthIssue] All collected issues (unsorted). """ issues: list[ModelHealthIssue] = [] # Collect from identifiability if identifiability is not None and identifiability.available: issues.extend(identifiability.issues) # Collect from gradient health if gradient_health is not None and gradient_health.available: issues.extend(gradient_health.issues) # Collect from sloppy model if sloppy_model is not None and sloppy_model.available: issues.extend(sloppy_model.issues) # Collect from plugins for plugin_result in plugin_results.values(): if plugin_result.available: issues.extend(plugin_result.issues) return issues def _sort_issues(issues: list[ModelHealthIssue]) -> list[ModelHealthIssue]: """Sort issues by severity (CRITICAL > WARNING > INFO) then by code. Parameters ---------- issues : list[ModelHealthIssue] Unsorted list of issues. Returns ------- list[ModelHealthIssue] Sorted list with CRITICAL first, then WARNING, then INFO. """ # Define severity order by name (lower value = higher priority) # Using names as keys avoids enum identity issues across import paths severity_order = { "CRITICAL": 0, "WARNING": 1, "INFO": 2, } return sorted( issues, key=lambda i: (severity_order.get(i.severity.name, 3), i.code) ) def _determine_status( all_issues: list[ModelHealthIssue], identifiability: IdentifiabilityReport | None, gradient_health: GradientHealthReport | None, ) -> HealthStatus: """Determine overall health status from issues. Logic (Contract B1): - If any CRITICAL severity issue exists: CRITICAL - Else if any WARNING severity issue exists: WARNING - Else if all components unavailable or None: WARNING - Else: HEALTHY Parameters ---------- all_issues : list[ModelHealthIssue] All aggregated issues. identifiability : IdentifiabilityReport | None Identifiability report for checking availability. gradient_health : GradientHealthReport | None Gradient health report for checking availability. Returns ------- HealthStatus Overall status. """ # Check for critical issues (use .name to avoid enum identity issues) if any(issue.severity.name == "CRITICAL" for issue in all_issues): return HealthStatus.CRITICAL # Check for warning issues (use .name to avoid enum identity issues) if any(issue.severity.name == "WARNING" for issue in all_issues): return HealthStatus.WARNING # Check if all components are unavailable or None (per contract error handling) ident_unavailable = identifiability is None or not identifiability.available grad_unavailable = gradient_health is None or not gradient_health.available if ident_unavailable and grad_unavailable: return HealthStatus.WARNING return HealthStatus.HEALTHY def _compute_health_score( identifiability: IdentifiabilityReport | None, gradient_health: GradientHealthReport | None, sloppy_model: ParameterSensitivityReport | None, config: DiagnosticsConfig, ) -> float: """Compute overall health score from component scores. Health score computation (Contract B2): - Start with base score = 1.0 - Identifiability contribution: - -0.4 if structurally unidentifiable - -0.2 if practically unidentifiable - -0.05 per highly correlated pair (max -0.2) - Gradient health contribution: - Use gradient_health.health_score * 0.3 weight - Sloppy model contribution (if level=FULL): - -0.1 if is_sloppy - Clamp to [0.0, 1.0] Parameters ---------- identifiability : IdentifiabilityReport | None Identifiability analysis results. gradient_health : GradientHealthReport | None Gradient health monitoring results. sloppy_model : ParameterSensitivityReport | None Sloppy model analysis results. config : DiagnosticsConfig Configuration for thresholds. Returns ------- float Health score in [0.0, 1.0]. """ score = 1.0 # Identifiability contribution if identifiability is not None and identifiability.available: # Check for structural unidentifiability (rank deficient) is_structurally_unidentifiable = ( identifiability.numerical_rank < identifiability.n_params ) # Check for practical unidentifiability (high condition number) is_practically_unidentifiable = ( not is_structurally_unidentifiable and identifiability.condition_number > config.condition_threshold ) if is_structurally_unidentifiable: score -= 0.4 elif is_practically_unidentifiable: score -= 0.2 # Deduction for correlated pairs: -0.05 per pair, max -0.2 n_correlated_pairs = len(identifiability.highly_correlated_pairs) correlation_deduction = 0.05 * min(n_correlated_pairs, 4) score -= correlation_deduction # Gradient health contribution (0.3 weight) if gradient_health is not None and gradient_health.available: # The gradient health contributes 0.3 to the total score # If gradient is perfect (1.0), no deduction # If gradient is poor (0.0), deduct 0.3 gradient_contribution = gradient_health.health_score * 0.3 # We have a 0.7 weight for base + identifiability, 0.3 for gradient # Recalculate: score = score * 0.7 + gradient_health.health_score * 0.3 score = score * 0.7 + gradient_contribution # Sloppy model contribution (level=FULL only) if ( sloppy_model is not None and sloppy_model.available and config.level == DiagnosticLevel.FULL ): if sloppy_model.is_sloppy: score -= 0.1 # Clamp to [0.0, 1.0] return max(0.0, min(1.0, score)) def _compute_total_time( identifiability: IdentifiabilityReport | None, gradient_health: GradientHealthReport | None, sloppy_model: ParameterSensitivityReport | None, plugin_results: dict[str, PluginResult], ) -> float: """Compute total computation time from all components. Parameters ---------- identifiability : IdentifiabilityReport | None Identifiability analysis results. gradient_health : GradientHealthReport | None Gradient health monitoring results. sloppy_model : ParameterSensitivityReport | None Sloppy model analysis results. plugin_results : dict[str, PluginResult] Results from diagnostic plugins. Returns ------- float Total computation time in milliseconds. """ total = 0.0 if identifiability is not None: total += identifiability.computation_time_ms if gradient_health is not None: total += gradient_health.computation_time_ms if sloppy_model is not None: total += sloppy_model.computation_time_ms for plugin_result in plugin_results.values(): total += plugin_result.computation_time_ms return total def _emit_warnings(issues: list[ModelHealthIssue]) -> None: """Emit Python warnings for critical issues (FR-019). Parameters ---------- issues : list[ModelHealthIssue] All aggregated issues. """ for issue in issues: if issue.severity == IssueSeverity.CRITICAL: warnings.warn( f"[{issue.code}] {issue.message}", UserWarning, stacklevel=4, # Point to user's code calling create_health_report ) # Backwards compatibility aliases for functions moved to types.py _format_summary = _format_model_health_summary _to_dict = _model_health_to_dict