Files
claude-skills-reference/project-management/senior-pm/scripts/project_health_dashboard.py
Leo 882ce5abd1 feat(pm): elevate scrum-master and senior-pm to POWERFUL tier
- scrum-master: add velocity_analyzer, sprint_health_scorer, retrospective_analyzer
- scrum-master: add references, assets, templates, rewrite SKILL.md
- senior-pm: add risk_matrix_analyzer, resource_capacity_planner, project_health_dashboard
- senior-pm: add references, assets, templates, rewrite SKILL.md
- All scripts: zero deps, dual output, type hints, tested against sample data
2026-02-15 20:36:56 +00:00

814 lines
32 KiB
Python

#!/usr/bin/env python3
"""
Project Health Dashboard
Aggregates project metrics across timeline, budget, scope, and quality dimensions.
Calculates composite health scores, generates RAG (Red/Amber/Green) status reports,
and identifies projects needing intervention for portfolio management.
Usage:
python project_health_dashboard.py portfolio_data.json
python project_health_dashboard.py portfolio_data.json --format json
"""
import argparse
import json
import statistics
import sys
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple, Union
# ---------------------------------------------------------------------------
# Health Assessment Configuration
# ---------------------------------------------------------------------------
HEALTH_DIMENSIONS = {
"timeline": {
"weight": 0.25,
"thresholds": {
"green": {"min": 0.0, "max": 0.05}, # ≤5% delay
"amber": {"min": 0.05, "max": 0.15}, # 5-15% delay
"red": {"min": 0.15, "max": 1.0} # >15% delay
}
},
"budget": {
"weight": 0.25,
"thresholds": {
"green": {"min": 0.0, "max": 0.05}, # ≤5% over budget
"amber": {"min": 0.05, "max": 0.15}, # 5-15% over budget
"red": {"min": 0.15, "max": 1.0} # >15% over budget
}
},
"scope": {
"weight": 0.20,
"thresholds": {
"green": {"min": 0.90, "max": 1.0}, # 90-100% scope delivered
"amber": {"min": 0.75, "max": 0.90}, # 75-90% scope delivered
"red": {"min": 0.0, "max": 0.75} # <75% scope delivered
}
},
"quality": {
"weight": 0.20,
"thresholds": {
"green": {"min": 0.95, "max": 1.0}, # ≤5% defect rate
"amber": {"min": 0.85, "max": 0.95}, # 5-15% defect rate
"red": {"min": 0.0, "max": 0.85} # >15% defect rate
}
},
"risk": {
"weight": 0.10,
"thresholds": {
"green": {"min": 0.0, "max": 15}, # Low risk score
"amber": {"min": 15, "max": 25}, # Medium risk score
"red": {"min": 25, "max": 100} # High risk score
}
}
}
PROJECT_STATUS_MAPPING = {
"planning": ["planning", "initiation", "chartered"],
"active": ["active", "in_progress", "execution", "development"],
"monitoring": ["monitoring", "testing", "review"],
"completed": ["completed", "delivered", "closed"],
"cancelled": ["cancelled", "terminated", "suspended"],
"on_hold": ["on_hold", "paused", "blocked"]
}
PRIORITY_WEIGHTS = {
"critical": 1.5,
"high": 1.2,
"medium": 1.0,
"low": 0.8
}
INTERVENTION_THRESHOLDS = {
"immediate": 30, # Health score ≤30
"urgent": 50, # Health score ≤50
"monitor": 70 # Health score ≤70
}
# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------
class ProjectMetrics:
"""Represents project health metrics and calculations."""
def __init__(self, data: Dict[str, Any]):
self.project_id: str = data.get("project_id", "")
self.project_name: str = data.get("project_name", "")
self.priority: str = data.get("priority", "medium").lower()
self.status: str = data.get("status", "planning").lower()
self.phase: str = data.get("phase", "planning")
# Timeline metrics
self.planned_start: str = data.get("planned_start", "")
self.actual_start: Optional[str] = data.get("actual_start")
self.planned_end: str = data.get("planned_end", "")
self.forecasted_end: str = data.get("forecasted_end", "")
self.completion_percentage: float = max(0, min(100, data.get("completion_percentage", 0))) / 100
# Budget metrics
self.planned_budget: float = data.get("planned_budget", 0)
self.spent_to_date: float = data.get("spent_to_date", 0)
self.forecasted_total_cost: float = data.get("forecasted_total_cost", 0)
# Scope metrics
self.planned_features: int = data.get("planned_features", 0)
self.completed_features: int = data.get("completed_features", 0)
self.descoped_features: int = data.get("descoped_features", 0)
self.added_features: int = data.get("added_features", 0)
# Quality metrics
self.total_defects: int = data.get("total_defects", 0)
self.resolved_defects: int = data.get("resolved_defects", 0)
self.critical_defects: int = data.get("critical_defects", 0)
self.test_coverage: float = max(0, min(1, data.get("test_coverage", 0)))
# Risk metrics
self.risk_score: float = data.get("risk_score", 0)
self.open_risks: int = data.get("open_risks", 0)
self.critical_risks: int = data.get("critical_risks", 0)
# Team metrics
self.team_size: int = data.get("team_size", 0)
self.team_utilization: float = data.get("team_utilization", 0)
self.team_satisfaction: Optional[float] = data.get("team_satisfaction")
# Stakeholder metrics
self.stakeholder_satisfaction: Optional[float] = data.get("stakeholder_satisfaction")
self.last_status_update: str = data.get("last_status_update", "")
# Calculate derived metrics
self._calculate_health_metrics()
self._normalize_status()
def _calculate_health_metrics(self):
"""Calculate normalized health metrics for each dimension."""
# Timeline health (0 = on time, 1 = severely delayed)
self.timeline_health = self._calculate_timeline_variance()
# Budget health (0 = on budget, 1 = severely over budget)
self.budget_health = self._calculate_budget_variance()
# Scope health (0 = no scope delivered, 1 = full scope delivered)
self.scope_health = self._calculate_scope_completion()
# Quality health (0 = poor quality, 1 = excellent quality)
self.quality_health = self._calculate_quality_score()
# Risk health (normalized risk score)
self.risk_health = min(self.risk_score, 100) # Cap at 100
def _calculate_timeline_variance(self) -> float:
"""Calculate timeline variance as percentage of planned duration."""
if not self.planned_start or not self.planned_end:
return 0.0
try:
planned_start = datetime.strptime(self.planned_start, "%Y-%m-%d")
planned_end = datetime.strptime(self.planned_end, "%Y-%m-%d")
planned_duration = (planned_end - planned_start).days
if planned_duration <= 0:
return 0.0
# Use forecasted end if available, otherwise current date for active projects
if self.forecasted_end:
forecast_date = datetime.strptime(self.forecasted_end, "%Y-%m-%d")
elif self.status in ["completed", "cancelled"]:
return 0.0 # Project is done
else:
forecast_date = datetime.now()
actual_duration = (forecast_date - planned_start).days
variance = max(0, actual_duration - planned_duration) / planned_duration
return min(variance, 1.0) # Cap at 100% delay
except (ValueError, ZeroDivisionError):
return 0.0
def _calculate_budget_variance(self) -> float:
"""Calculate budget variance as percentage over original budget."""
if self.planned_budget <= 0:
return 0.0
# Use forecasted total cost if available, otherwise spent to date
actual_cost = self.forecasted_total_cost or self.spent_to_date
variance = max(0, actual_cost - self.planned_budget) / self.planned_budget
return min(variance, 1.0) # Cap at 100% over budget
def _calculate_scope_completion(self) -> float:
"""Calculate scope completion percentage."""
if self.planned_features <= 0:
return 1.0 # No planned features, consider complete
# Account for scope changes
effective_planned = self.planned_features + self.added_features - self.descoped_features
if effective_planned <= 0:
return 1.0
return self.completed_features / effective_planned
def _calculate_quality_score(self) -> float:
"""Calculate quality score based on defects and test coverage."""
if self.total_defects == 0:
defect_score = 1.0
else:
resolution_rate = self.resolved_defects / self.total_defects
critical_penalty = self.critical_defects / max(self.total_defects, 1)
defect_score = resolution_rate * (1 - critical_penalty * 0.5)
# Combine defect score with test coverage
quality_score = (defect_score * 0.7) + (self.test_coverage * 0.3)
return max(0, min(1, quality_score))
def _normalize_status(self):
"""Normalize project status to standard categories."""
status_lower = self.status.lower()
for category, statuses in PROJECT_STATUS_MAPPING.items():
if status_lower in statuses:
self.normalized_status = category
return
self.normalized_status = "active" # Default
@property
def is_active(self) -> bool:
return self.normalized_status in ["planning", "active", "monitoring"]
@property
def requires_intervention(self) -> bool:
health_score = self.calculate_composite_health_score()
return health_score <= INTERVENTION_THRESHOLDS["urgent"] and self.is_active
class PortfolioHealthResult:
"""Complete portfolio health analysis results."""
def __init__(self):
self.summary: Dict[str, Any] = {}
self.project_scores: List[Dict[str, Any]] = []
self.dimension_analysis: Dict[str, Any] = {}
self.rag_status: Dict[str, Any] = {}
self.intervention_list: List[Dict[str, Any]] = []
self.portfolio_trends: Dict[str, Any] = {}
self.recommendations: List[str] = []
# ---------------------------------------------------------------------------
# Health Calculation Functions
# ---------------------------------------------------------------------------
def calculate_dimension_score(value: float, dimension: str, is_reverse: bool = False) -> int:
"""Calculate dimension score (0-100) based on thresholds."""
config = HEALTH_DIMENSIONS[dimension]
thresholds = config["thresholds"]
if not is_reverse:
# Lower values are better (timeline, budget, risk)
if value <= thresholds["green"]["max"]:
return 90 + int((1 - value / thresholds["green"]["max"]) * 10)
elif value <= thresholds["amber"]["max"]:
range_size = thresholds["amber"]["max"] - thresholds["amber"]["min"]
position = (value - thresholds["amber"]["min"]) / range_size
return 60 + int((1 - position) * 30)
else:
# Red zone - score decreases with higher values
excess = min(value - thresholds["red"]["min"], 1.0)
return max(10, 60 - int(excess * 50))
else:
# Higher values are better (scope, quality)
if value >= thresholds["green"]["min"]:
range_size = thresholds["green"]["max"] - thresholds["green"]["min"]
position = (value - thresholds["green"]["min"]) / range_size if range_size > 0 else 1
return 90 + int(position * 10)
elif value >= thresholds["amber"]["min"]:
range_size = thresholds["amber"]["max"] - thresholds["amber"]["min"]
position = (value - thresholds["amber"]["min"]) / range_size
return 60 + int(position * 30)
else:
# Red zone
if thresholds["red"]["max"] > 0:
position = value / thresholds["red"]["max"]
return max(10, int(position * 60))
else:
return 10
def calculate_project_health_score(project: ProjectMetrics) -> Dict[str, Any]:
"""Calculate comprehensive health score for a project."""
# Calculate individual dimension scores
timeline_score = calculate_dimension_score(project.timeline_health, "timeline")
budget_score = calculate_dimension_score(project.budget_health, "budget")
scope_score = calculate_dimension_score(project.scope_health, "scope", is_reverse=True)
quality_score = calculate_dimension_score(project.quality_health, "quality", is_reverse=True)
risk_score = calculate_dimension_score(project.risk_health, "risk")
# Calculate weighted composite score
dimensions = {
"timeline": {"score": timeline_score, "weight": HEALTH_DIMENSIONS["timeline"]["weight"]},
"budget": {"score": budget_score, "weight": HEALTH_DIMENSIONS["budget"]["weight"]},
"scope": {"score": scope_score, "weight": HEALTH_DIMENSIONS["scope"]["weight"]},
"quality": {"score": quality_score, "weight": HEALTH_DIMENSIONS["quality"]["weight"]},
"risk": {"score": risk_score, "weight": HEALTH_DIMENSIONS["risk"]["weight"]}
}
composite_score = sum(
dim_data["score"] * dim_data["weight"]
for dim_data in dimensions.values()
)
# Apply priority weighting
priority_weight = PRIORITY_WEIGHTS.get(project.priority, 1.0)
adjusted_score = composite_score * priority_weight
# Determine RAG status
if composite_score >= 80:
rag_status = "green"
elif composite_score >= 60:
rag_status = "amber"
else:
rag_status = "red"
# Determine intervention level
if composite_score <= INTERVENTION_THRESHOLDS["immediate"]:
intervention_level = "immediate"
elif composite_score <= INTERVENTION_THRESHOLDS["urgent"]:
intervention_level = "urgent"
elif composite_score <= INTERVENTION_THRESHOLDS["monitor"]:
intervention_level = "monitor"
else:
intervention_level = "none"
return {
"project_id": project.project_id,
"project_name": project.project_name,
"composite_score": composite_score,
"adjusted_score": adjusted_score,
"rag_status": rag_status,
"intervention_level": intervention_level,
"dimension_scores": dimensions,
"priority": project.priority,
"status": project.status,
"completion_percentage": project.completion_percentage
}
def analyze_portfolio_dimensions(project_scores: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Analyze portfolio performance across health dimensions."""
dimension_analysis = {}
for dimension in HEALTH_DIMENSIONS.keys():
scores = [
project["dimension_scores"][dimension]["score"]
for project in project_scores
]
if scores:
dimension_analysis[dimension] = {
"average_score": statistics.mean(scores),
"median_score": statistics.median(scores),
"min_score": min(scores),
"max_score": max(scores),
"std_deviation": statistics.stdev(scores) if len(scores) > 1 else 0,
"projects_below_60": len([s for s in scores if s < 60]),
"projects_above_80": len([s for s in scores if s >= 80])
}
# Identify weakest and strongest dimensions
avg_scores = {dim: data["average_score"] for dim, data in dimension_analysis.items()}
weakest_dimension = min(avg_scores.keys(), key=lambda k: avg_scores[k])
strongest_dimension = max(avg_scores.keys(), key=lambda k: avg_scores[k])
return {
"dimension_statistics": dimension_analysis,
"weakest_dimension": weakest_dimension,
"strongest_dimension": strongest_dimension,
"dimension_rankings": sorted(avg_scores.items(), key=lambda x: x[1], reverse=True)
}
def generate_rag_status_summary(project_scores: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Generate RAG status summary for portfolio."""
rag_counts = {"green": 0, "amber": 0, "red": 0}
# Count by RAG status
for project in project_scores:
rag_status = project["rag_status"]
rag_counts[rag_status] += 1
total_projects = len(project_scores)
# Calculate percentages
rag_percentages = {
status: (count / max(total_projects, 1)) * 100
for status, count in rag_counts.items()
}
# Categorize projects by status
green_projects = [p for p in project_scores if p["rag_status"] == "green"]
amber_projects = [p for p in project_scores if p["rag_status"] == "amber"]
red_projects = [p for p in project_scores if p["rag_status"] == "red"]
# Calculate portfolio health grade
if rag_percentages["red"] > 30:
portfolio_grade = "critical"
elif rag_percentages["red"] > 15 or rag_percentages["amber"] > 50:
portfolio_grade = "concerning"
elif rag_percentages["green"] > 60:
portfolio_grade = "healthy"
else:
portfolio_grade = "moderate"
return {
"rag_counts": rag_counts,
"rag_percentages": rag_percentages,
"portfolio_grade": portfolio_grade,
"green_projects": [{"id": p["project_id"], "name": p["project_name"], "score": p["composite_score"]} for p in green_projects],
"amber_projects": [{"id": p["project_id"], "name": p["project_name"], "score": p["composite_score"]} for p in amber_projects],
"red_projects": [{"id": p["project_id"], "name": p["project_name"], "score": p["composite_score"]} for p in red_projects]
}
def identify_intervention_priorities(project_scores: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Identify projects requiring intervention, prioritized by urgency and impact."""
intervention_projects = [
p for p in project_scores
if p["intervention_level"] in ["immediate", "urgent", "monitor"]
]
# Sort by intervention level and then by adjusted score (priority-weighted)
intervention_priority = {"immediate": 3, "urgent": 2, "monitor": 1}
intervention_projects.sort(
key=lambda p: (
intervention_priority[p["intervention_level"]],
-p["adjusted_score"] # Lower scores need more urgent attention
),
reverse=True
)
# Add recommended actions based on weakest dimensions
for project in intervention_projects:
project["recommended_actions"] = _generate_project_recommendations(project)
project["risk_factors"] = _identify_risk_factors(project)
return intervention_projects
def _generate_project_recommendations(project: Dict[str, Any]) -> List[str]:
"""Generate specific recommendations based on project's weak dimensions."""
recommendations = []
dimension_scores = project["dimension_scores"]
# Timeline recommendations
if dimension_scores["timeline"]["score"] < 60:
recommendations.append("Conduct timeline recovery analysis and implement fast-tracking or crashing strategies")
# Budget recommendations
if dimension_scores["budget"]["score"] < 60:
recommendations.append("Implement cost control measures and review budget forecasts")
# Scope recommendations
if dimension_scores["scope"]["score"] < 60:
recommendations.append("Review scope management and consider feature prioritization or descoping")
# Quality recommendations
if dimension_scores["quality"]["score"] < 60:
recommendations.append("Increase testing coverage and implement quality improvement processes")
# Risk recommendations
if dimension_scores["risk"]["score"] < 60:
recommendations.append("Escalate critical risks and implement additional risk mitigation measures")
# Overall health recommendations
if project["composite_score"] < 40:
recommendations.append("Consider project restructuring or emergency stakeholder review")
return recommendations
def _identify_risk_factors(project: Dict[str, Any]) -> List[str]:
"""Identify specific risk factors for a project."""
risk_factors = []
if project["composite_score"] < 30:
risk_factors.append("Critical project failure risk")
if project["intervention_level"] == "immediate":
risk_factors.append("Requires immediate management attention")
dimension_scores = project["dimension_scores"]
poor_dimensions = [
dim for dim, data in dimension_scores.items()
if data["score"] < 50
]
if len(poor_dimensions) > 2:
risk_factors.append(f"Multiple failing dimensions: {', '.join(poor_dimensions)}")
return risk_factors
def generate_portfolio_recommendations(analysis_results: Dict[str, Any]) -> List[str]:
"""Generate portfolio-level recommendations."""
recommendations = []
# RAG status recommendations
rag_status = analysis_results.get("rag_status", {})
red_percentage = rag_status.get("rag_percentages", {}).get("red", 0)
amber_percentage = rag_status.get("rag_percentages", {}).get("amber", 0)
if red_percentage > 30:
recommendations.append("URGENT: 30%+ projects are in red status. Consider portfolio restructuring or resource reallocation.")
elif red_percentage > 15:
recommendations.append("HIGH: Significant number of projects in red status require immediate attention.")
if amber_percentage > 50:
recommendations.append("MEDIUM: Over half of portfolio projects need monitoring and support.")
# Dimension-based recommendations
dimension_analysis = analysis_results.get("dimension_analysis", {})
weakest_dimension = dimension_analysis.get("weakest_dimension", "")
if weakest_dimension:
recommendations.append(f"Focus improvement efforts on {weakest_dimension} - weakest portfolio dimension.")
# Intervention recommendations
intervention_list = analysis_results.get("intervention_list", [])
immediate_count = len([p for p in intervention_list if p["intervention_level"] == "immediate"])
urgent_count = len([p for p in intervention_list if p["intervention_level"] == "urgent"])
if immediate_count > 0:
recommendations.append(f"CRITICAL: {immediate_count} projects require immediate intervention within 48 hours.")
if urgent_count > 3:
recommendations.append(f"Capacity alert: {urgent_count} projects need urgent attention - consider resource reallocation.")
# Portfolio health recommendations
portfolio_grade = rag_status.get("portfolio_grade", "")
if portfolio_grade == "critical":
recommendations.append("Portfolio health is critical. Recommend executive review and strategic realignment.")
elif portfolio_grade == "concerning":
recommendations.append("Portfolio health needs improvement. Implement enhanced monitoring and support.")
return recommendations
# ---------------------------------------------------------------------------
# Main Analysis Function
# ---------------------------------------------------------------------------
def analyze_portfolio_health(data: Dict[str, Any]) -> PortfolioHealthResult:
"""Perform comprehensive portfolio health analysis."""
result = PortfolioHealthResult()
try:
# Parse project data
project_records = data.get("projects", [])
projects = [ProjectMetrics(record) for record in project_records]
if not projects:
raise ValueError("No project data found")
# Calculate health scores for each project
project_scores = [calculate_project_health_score(project) for project in projects]
result.project_scores = project_scores
# Filter active projects for portfolio analysis
active_scores = [score for i, score in enumerate(project_scores) if projects[i].is_active]
# Portfolio summary
if active_scores:
composite_scores = [score["composite_score"] for score in active_scores]
result.summary = {
"total_projects": len(projects),
"active_projects": len(active_scores),
"portfolio_average_score": statistics.mean(composite_scores),
"portfolio_median_score": statistics.median(composite_scores),
"projects_needing_attention": len([s for s in active_scores if s["composite_score"] < 70]),
"critical_projects": len([s for s in active_scores if s["composite_score"] < 40])
}
else:
result.summary = {
"total_projects": len(projects),
"active_projects": 0,
"portfolio_average_score": 0,
"message": "No active projects found"
}
if active_scores:
# Dimension analysis
result.dimension_analysis = analyze_portfolio_dimensions(active_scores)
# RAG status analysis
result.rag_status = generate_rag_status_summary(active_scores)
# Intervention priorities
result.intervention_list = identify_intervention_priorities(active_scores)
# Generate recommendations
analysis_data = {
"rag_status": result.rag_status,
"dimension_analysis": result.dimension_analysis,
"intervention_list": result.intervention_list
}
result.recommendations = generate_portfolio_recommendations(analysis_data)
except Exception as e:
result.summary = {"error": str(e)}
return result
# ---------------------------------------------------------------------------
# Output Formatting
# ---------------------------------------------------------------------------
def format_text_output(result: PortfolioHealthResult) -> str:
"""Format analysis results as readable text report."""
lines = []
lines.append("="*60)
lines.append("PROJECT HEALTH DASHBOARD")
lines.append("="*60)
lines.append("")
if "error" in result.summary:
lines.append(f"ERROR: {result.summary['error']}")
return "\n".join(lines)
# Executive Summary
summary = result.summary
lines.append("PORTFOLIO OVERVIEW")
lines.append("-"*30)
lines.append(f"Total Projects: {summary['total_projects']} ({summary.get('active_projects', 0)} active)")
if "portfolio_average_score" in summary:
lines.append(f"Portfolio Health Score: {summary['portfolio_average_score']:.1f}/100")
lines.append(f"Projects Needing Attention: {summary.get('projects_needing_attention', 0)}")
lines.append(f"Critical Projects: {summary.get('critical_projects', 0)}")
if "message" in summary:
lines.append(f"Status: {summary['message']}")
lines.append("")
# RAG Status Summary
rag_status = result.rag_status
if rag_status:
lines.append("RAG STATUS SUMMARY")
lines.append("-"*30)
rag_counts = rag_status.get("rag_counts", {})
rag_percentages = rag_status.get("rag_percentages", {})
lines.append(f"🟢 Green: {rag_counts.get('green', 0)} ({rag_percentages.get('green', 0):.1f}%)")
lines.append(f"🟡 Amber: {rag_counts.get('amber', 0)} ({rag_percentages.get('amber', 0):.1f}%)")
lines.append(f"🔴 Red: {rag_counts.get('red', 0)} ({rag_percentages.get('red', 0):.1f}%)")
lines.append(f"Portfolio Grade: {rag_status.get('portfolio_grade', 'N/A').title()}")
lines.append("")
# Dimension Analysis
dimension_analysis = result.dimension_analysis
if dimension_analysis:
lines.append("HEALTH DIMENSION ANALYSIS")
lines.append("-"*30)
dimension_stats = dimension_analysis.get("dimension_statistics", {})
for dimension, stats in dimension_stats.items():
lines.append(f"{dimension.title()}: {stats['average_score']:.1f} avg "
f"({stats['projects_below_60']} below 60, {stats['projects_above_80']} above 80)")
lines.append(f"Strongest: {dimension_analysis.get('strongest_dimension', '').title()}")
lines.append(f"Weakest: {dimension_analysis.get('weakest_dimension', '').title()}")
lines.append("")
# Critical Projects Needing Intervention
intervention_list = result.intervention_list
if intervention_list:
lines.append("PROJECTS REQUIRING INTERVENTION")
lines.append("-"*30)
immediate_projects = [p for p in intervention_list if p["intervention_level"] == "immediate"]
urgent_projects = [p for p in intervention_list if p["intervention_level"] == "urgent"]
if immediate_projects:
lines.append("🚨 IMMEDIATE ACTION REQUIRED:")
for project in immediate_projects[:5]:
lines.append(f"{project['project_name']} (Score: {project['composite_score']:.0f})")
if project.get("recommended_actions"):
lines.append(f"{project['recommended_actions'][0]}")
lines.append("")
if urgent_projects:
lines.append("⚠️ URGENT ATTENTION NEEDED:")
for project in urgent_projects[:5]:
lines.append(f"{project['project_name']} (Score: {project['composite_score']:.0f})")
lines.append("")
# Top Performing Projects
if result.project_scores:
top_projects = sorted(result.project_scores, key=lambda p: p["composite_score"], reverse=True)[:5]
lines.append("TOP PERFORMING PROJECTS")
lines.append("-"*30)
for project in top_projects:
status_emoji = {"green": "🟢", "amber": "🟡", "red": "🔴"}.get(project["rag_status"], "")
lines.append(f"{status_emoji} {project['project_name']}: {project['composite_score']:.0f}/100")
lines.append("")
# Recommendations
if result.recommendations:
lines.append("PORTFOLIO RECOMMENDATIONS")
lines.append("-"*30)
for i, rec in enumerate(result.recommendations, 1):
lines.append(f"{i}. {rec}")
return "\n".join(lines)
def format_json_output(result: PortfolioHealthResult) -> Dict[str, Any]:
"""Format analysis results as JSON."""
return {
"summary": result.summary,
"project_scores": result.project_scores,
"dimension_analysis": result.dimension_analysis,
"rag_status": result.rag_status,
"intervention_list": result.intervention_list,
"portfolio_trends": result.portfolio_trends,
"recommendations": result.recommendations
}
# ---------------------------------------------------------------------------
# ProjectMetrics Helper Method
# ---------------------------------------------------------------------------
def _calculate_composite_health_score(self) -> float:
"""Helper method to calculate composite health score."""
health_calc = calculate_project_health_score(self)
return health_calc["composite_score"]
# Add the method to the class
ProjectMetrics.calculate_composite_health_score = lambda self: calculate_project_health_score(self)["composite_score"]
# ---------------------------------------------------------------------------
# CLI Interface
# ---------------------------------------------------------------------------
def main() -> int:
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="Analyze project portfolio health across multiple dimensions"
)
parser.add_argument(
"data_file",
help="JSON file containing project portfolio data"
)
parser.add_argument(
"--format",
choices=["text", "json"],
default="text",
help="Output format (default: text)"
)
args = parser.parse_args()
try:
# Load and validate data
with open(args.data_file, 'r') as f:
data = json.load(f)
# Perform analysis
result = analyze_portfolio_health(data)
# Output results
if args.format == "json":
output = format_json_output(result)
print(json.dumps(output, indent=2))
else:
output = format_text_output(result)
print(output)
return 0
except FileNotFoundError:
print(f"Error: File '{args.data_file}' not found", file=sys.stderr)
return 1
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in '{args.data_file}': {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())