Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/CAPACITY_PLANNING_RUNBOOK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Capacity Planning with Historical Usage Trending

## Architecture

The usage dashboard now computes capacity forecasts from the same hourly usage samples used by the live dashboard:

1. Normalize the most recent usage samples into an estimated daily kWh load.
2. Compare the first half of the historical window with the most recent half to derive a growth trend.
3. Apply a configurable safety factor and reserve margin before projecting demand for 30 days.
4. Classify capacity risk as low, medium, high, or critical and expose the result in the dashboard.

The implementation is intentionally client-side and deterministic for the mock dashboard so the critical path stays under the 100 ms target. Production wiring should replace the mock samples with the metering API while preserving the pure `buildCapacityPlan` interface for testability.

## Alert Policy

| Risk | Condition | Action |
| --- | --- | --- |
| Low | < 75% usable capacity and no projected breach | Continue normal monitoring. |
| Medium | >= 75% usable capacity or breach within 30 days | Review provider capacity queue and open planning ticket. |
| High | >= 90% usable capacity or breach within 7 days | Page utility operations and prepare expansion/cutover plan. |
| Critical | >= 100% usable capacity | Start incident response and apply emergency throttling if required. |

## Monitoring and Dashboards

Track these fields from `CapacityPlan`:

- `currentDailyUsageKWh`
- `usableCapacityKWhPerDay`
- `utilizationPercent`
- `dailyGrowthRatePercent`
- `daysUntilCapacityBreach`
- `recommendedCapacityKWhPerDay`
- `riskLevel`

Recommended SLO panels:

- P99 dashboard forecast calculation latency, target < 100 ms.
- Capacity risk count by utility provider.
- Canary versus stable forecast delta after deployment.
- Alert delivery success rate, target 99.99% availability.

## Blue-Green and Canary Deployment

1. Deploy the new dashboard build to the green environment with forecast alerts disabled.
2. Mirror production meter samples into green and compare forecasts against blue for one hour.
3. Enable canary alerts for 5% of providers and verify alert volume matches expected risk distribution.
4. Increase to 25%, 50%, and 100% if canary forecast deltas stay below 5%.
5. Keep blue warm for rollback until one full daily cycle completes.

## Security Review Checklist

- Forecasts must use aggregate usage samples only; do not expose raw wallet secrets or meter keys.
- Alert payloads must omit precise customer location and device MAC addresses.
- Dashboard changes must pass dependency audit and standard repository review before production rollout.
- Treat capacity thresholds as configuration managed by operations, not user-provided input.
6 changes: 1 addition & 5 deletions usage-dashboard/next.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
appDir: true,
},
}
const nextConfig = {}

module.exports = nextConfig
5 changes: 1 addition & 4 deletions usage-dashboard/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
title: 'Utility-Protocol - Usage Dashboard',
Expand All @@ -16,7 +13,7 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<body>{children}</body>
</html>
)
}
15 changes: 13 additions & 2 deletions usage-dashboard/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,29 @@ import { Zap, DollarSign, TrendingUp, Activity } from 'lucide-react';
import StatsCard from '@/components/StatsCard';
import UsageChart from '@/components/UsageChart';
import MeterInfo from '@/components/MeterInfo';
import { UsageData, MeterData, DashboardStats } from '@/types';
import { UsageData, MeterData, DashboardStats, CapacityPlan } from '@/types';
import { generateMockUsageData, generateMockMeterData, calculateStats, updateUsageData } from '@/lib/mockData';
import { buildCapacityPlan } from '@/lib/capacityPlanning';
import CapacityPlanningPanel from '@/components/CapacityPlanningPanel';

export default function Home() {
const [usageData, setUsageData] = useState<UsageData[]>([]);
const [meterData, setMeterData] = useState<MeterData | null>(null);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [isRealTime, setIsRealTime] = useState(true);
const [capacityPlan, setCapacityPlan] = useState<CapacityPlan | null>(null);

// Initialize data
useEffect(() => {
const initialUsageData = generateMockUsageData();
const initialMeterData = generateMockMeterData();
const initialStats = calculateStats(initialUsageData);
const initialCapacityPlan = buildCapacityPlan(initialUsageData);

setUsageData(initialUsageData);
setMeterData(initialMeterData);
setStats(initialStats);
setCapacityPlan(initialCapacityPlan);
}, []);

// Real-time updates
Expand All @@ -33,14 +38,15 @@ export default function Home() {
setUsageData(prevData => {
const newData = updateUsageData(prevData);
setStats(calculateStats(newData));
setCapacityPlan(buildCapacityPlan(newData));
return newData;
});
}, 5000); // Update every 5 seconds

return () => clearInterval(interval);
}, [isRealTime]);

if (!stats || !meterData) {
if (!stats || !meterData || !capacityPlan) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
Expand Down Expand Up @@ -144,6 +150,11 @@ export default function Home() {
</div>
</div>

{/* Capacity planning forecast */}
<div className="mt-8">
<CapacityPlanningPanel plan={capacityPlan} />
</div>

{/* Additional Info Section */}
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="chart-container">
Expand Down
88 changes: 88 additions & 0 deletions usage-dashboard/src/components/CapacityPlanningPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'use client';

import { AlertTriangle, BarChart3, CheckCircle2, Gauge, TrendingUp } from 'lucide-react';
import { CapacityPlan } from '@/types';

interface CapacityPlanningPanelProps {
plan: CapacityPlan;
}

const riskStyles = {
low: 'bg-green-100 text-green-800 border-green-200',
medium: 'bg-yellow-100 text-yellow-800 border-yellow-200',
high: 'bg-orange-100 text-orange-800 border-orange-200',
critical: 'bg-red-100 text-red-800 border-red-200',
};

export default function CapacityPlanningPanel({ plan }: CapacityPlanningPanelProps) {
const breachText = plan.daysUntilCapacityBreach === null
? 'No breach projected in current trend'
: plan.daysUntilCapacityBreach === 0
? 'Capacity breach now'
: `${plan.daysUntilCapacityBreach} days until usable capacity breach`;

const lastProjection = plan.projected[plan.projected.length - 1];

return (
<section className="chart-container" aria-labelledby="capacity-planning-title">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-6">
<div>
<h2 id="capacity-planning-title" className="text-xl font-bold text-gray-900">
Capacity Planning Forecast
</h2>
<p className="text-sm text-gray-600">
Historical usage trend with reserve-margin-aware 30 day projection.
</p>
</div>
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-semibold capitalize ${riskStyles[plan.riskLevel]}`}>
{plan.riskLevel === 'low' ? <CheckCircle2 className="mr-2 h-4 w-4" /> : <AlertTriangle className="mr-2 h-4 w-4" />}
{plan.riskLevel} risk
</span>
</div>

<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="rounded-lg bg-blue-50 p-4">
<Gauge className="h-5 w-5 text-blue-600 mb-2" />
<p className="text-sm text-gray-600">Current Utilization</p>
<p className="text-2xl font-bold text-gray-900">{plan.utilizationPercent}%</p>
<p className="text-xs text-gray-500">of {plan.usableCapacityKWhPerDay} kWh/day usable</p>
</div>
<div className="rounded-lg bg-purple-50 p-4">
<TrendingUp className="h-5 w-5 text-purple-600 mb-2" />
<p className="text-sm text-gray-600">Daily Trend</p>
<p className="text-2xl font-bold text-gray-900">{plan.dailyGrowthRatePercent}%</p>
<p className="text-xs text-gray-500">safety-factor adjusted</p>
</div>
<div className="rounded-lg bg-orange-50 p-4">
<AlertTriangle className="h-5 w-5 text-orange-600 mb-2" />
<p className="text-sm text-gray-600">Breach Forecast</p>
<p className="text-lg font-bold text-gray-900">{breachText}</p>
</div>
<div className="rounded-lg bg-green-50 p-4">
<BarChart3 className="h-5 w-5 text-green-600 mb-2" />
<p className="text-sm text-gray-600">Recommended Capacity</p>
<p className="text-2xl font-bold text-gray-900">{plan.recommendedCapacityKWhPerDay}</p>
<p className="text-xs text-gray-500">kWh/day for target window</p>
</div>
</div>

<div className="rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between text-sm text-gray-600 mb-2">
<span>Today: {plan.currentDailyUsageKWh} kWh/day</span>
<span>Day {lastProjection.day}: {lastProjection.projectedKWh} kWh/day</span>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-gray-100">
<div
className="h-full rounded-full bg-primary-600 transition-all"
style={{ width: `${Math.min(lastProjection.utilizationPercent, 100)}%` }}
aria-label={`${lastProjection.utilizationPercent}% projected utilization`}
/>
</div>
<p className="mt-3 text-sm text-gray-600">
Alert policy: medium at 75% utilization or 30 day breach, high at 90% or 7 day breach,
critical at 100%. Use blue-green dashboard rollout and canary alerts before changing capacity limits.
</p>
</div>
</section>
);
}
81 changes: 81 additions & 0 deletions usage-dashboard/src/lib/capacityPlanning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { CapacityPlan, CapacityPlanningConfig, CapacityPlanningPoint, CapacityRiskLevel, UsageData } from '@/types';

const HOURS_PER_DAY = 24;
const DEFAULT_CONFIG: CapacityPlanningConfig = {
installedCapacityKWhPerDay: 18,
reserveMarginPercent: 20,
planningWindowDays: 30,
growthSafetyFactorPercent: 10,
};

function round(value: number, digits = 2): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}

function riskFor(utilizationPercent: number, daysUntilCapacityBreach: number | null): CapacityRiskLevel {
if (utilizationPercent >= 100 || daysUntilCapacityBreach === 0) return 'critical';
if (utilizationPercent >= 90 || (daysUntilCapacityBreach !== null && daysUntilCapacityBreach <= 7)) return 'high';
if (utilizationPercent >= 75 || (daysUntilCapacityBreach !== null && daysUntilCapacityBreach <= 30)) return 'medium';
return 'low';
}

export function buildCapacityPlan(
usageData: UsageData[],
config: Partial<CapacityPlanningConfig> = {},
): CapacityPlan {
const planningConfig = { ...DEFAULT_CONFIG, ...config };
const chronological = [...usageData].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);

const totalKWh = chronological.reduce((sum, point) => sum + point.kWh, 0);
const observedHours = Math.max(chronological.length, 1);
const currentDailyUsageKWh = (totalKWh / observedHours) * HOURS_PER_DAY;

const midpoint = Math.max(Math.floor(chronological.length / 2), 1);
const early = chronological.slice(0, midpoint);
const recent = chronological.slice(midpoint);
const earlyDaily = (early.reduce((sum, point) => sum + point.kWh, 0) / Math.max(early.length, 1)) * HOURS_PER_DAY;
const recentDaily = (recent.reduce((sum, point) => sum + point.kWh, 0) / Math.max(recent.length, 1)) * HOURS_PER_DAY;
const observedDays = observedHours / HOURS_PER_DAY;
const rawTrend = observedDays > 0 ? ((recentDaily - earlyDaily) / Math.max(earlyDaily, 0.001)) / observedDays : 0;
const dailyGrowthRate = Math.max(0, rawTrend) * (1 + planningConfig.growthSafetyFactorPercent / 100);

const usableCapacity = planningConfig.installedCapacityKWhPerDay * (1 - planningConfig.reserveMarginPercent / 100);
const utilizationPercent = (currentDailyUsageKWh / Math.max(usableCapacity, 0.001)) * 100;

let daysUntilCapacityBreach: number | null = null;
if (currentDailyUsageKWh >= usableCapacity) {
daysUntilCapacityBreach = 0;
} else if (dailyGrowthRate > 0) {
daysUntilCapacityBreach = Math.ceil(Math.log(usableCapacity / currentDailyUsageKWh) / Math.log(1 + dailyGrowthRate));
}

const projected: CapacityPlanningPoint[] = Array.from({ length: planningConfig.planningWindowDays + 1 }, (_, day) => {
const projectedKWh = currentDailyUsageKWh * (1 + dailyGrowthRate) ** day;
const utilization = (projectedKWh / Math.max(usableCapacity, 0.001)) * 100;
return {
day,
projectedKWh: round(projectedKWh),
utilizationPercent: round(utilization),
riskLevel: riskFor(utilization, daysUntilCapacityBreach !== null ? Math.max(daysUntilCapacityBreach - day, 0) : null),
};
});

const riskLevel = riskFor(utilizationPercent, daysUntilCapacityBreach);
const recommendedCapacityKWhPerDay = currentDailyUsageKWh * (1 + dailyGrowthRate) ** planningConfig.planningWindowDays
/ (1 - planningConfig.reserveMarginPercent / 100);

return {
currentDailyUsageKWh: round(currentDailyUsageKWh),
installedCapacityKWhPerDay: planningConfig.installedCapacityKWhPerDay,
usableCapacityKWhPerDay: round(usableCapacity),
utilizationPercent: round(utilizationPercent),
dailyGrowthRatePercent: round(dailyGrowthRate * 100, 2),
daysUntilCapacityBreach,
recommendedCapacityKWhPerDay: round(recommendedCapacityKWhPerDay),
riskLevel,
projected,
};
}
29 changes: 29 additions & 0 deletions usage-dashboard/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,32 @@ export interface DashboardStats {
averageDailyUsage: number;
averageDailySpend: number;
}


export type CapacityRiskLevel = 'low' | 'medium' | 'high' | 'critical';

export interface CapacityPlanningConfig {
installedCapacityKWhPerDay: number;
reserveMarginPercent: number;
planningWindowDays: number;
growthSafetyFactorPercent: number;
}

export interface CapacityPlanningPoint {
day: number;
projectedKWh: number;
utilizationPercent: number;
riskLevel: CapacityRiskLevel;
}

export interface CapacityPlan {
currentDailyUsageKWh: number;
installedCapacityKWhPerDay: number;
usableCapacityKWhPerDay: number;
utilizationPercent: number;
dailyGrowthRatePercent: number;
daysUntilCapacityBreach: number | null;
recommendedCapacityKWhPerDay: number;
riskLevel: CapacityRiskLevel;
projected: CapacityPlanningPoint[];
}
Loading