- 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
814 lines
32 KiB
Python
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()) |