diff --git a/src/__tests__/financial.test.ts b/src/__tests__/financial.test.ts new file mode 100644 index 0000000..e2cb236 --- /dev/null +++ b/src/__tests__/financial.test.ts @@ -0,0 +1,317 @@ +import request from "supertest"; +import express, { Express } from "express"; +import { + createDefaultFinancialInput, + calculateCostBenefit, + calculatePaybackPeriod, + calculateNPV, + performSensitivityAnalysis, + compareROI, +} from "../lib/financial"; +import financialRouter from "../routes/financial"; +import { errorHandler } from "../middleware/errors"; + +function buildApp(): Express { + const app = express(); + app.use(express.json()); + app.use("/api/financial", financialRouter); + app.use(errorHandler); + return app; +} + +const BASE_INPUT = createDefaultFinancialInput(500, 80); + +describe("createDefaultFinancialInput", () => { + it("creates sensible defaults for a 500 kW system", () => { + const input = createDefaultFinancialInput(500, 80); + expect(input.system_capacity_kw).toBe(500); + expect(input.installation_cost).toBe(500_000); + expect(input.annual_maintenance_cost).toBe(7500); + expect(input.annual_energy_output_kwh).toBe(500 * 8760 * 0.20); + expect(input.electricity_price_per_kwh).toBe(0.10); + expect(input.degradation_rate).toBe(0.005); + expect(input.discount_rate).toBe(0.07); + expect(input.project_lifetime_years).toBe(25); + expect(input.tax_incentives).toBe(150_000); + expect(input.salvage_value).toBe(50_000); + }); + + it("merges partial overrides", () => { + const input = createDefaultFinancialInput(500, 80, { + installation_cost: 600_000, + discount_rate: 0.08, + }); + expect(input.installation_cost).toBe(600_000); + expect(input.discount_rate).toBe(0.08); + expect(input.system_capacity_kw).toBe(500); + }); +}); + +describe("calculateCostBenefit", () => { + it("returns positive net benefit for a viable project", () => { + const result = calculateCostBenefit(BASE_INPUT); + expect(result.total_installation_cost).toBeGreaterThan(0); + expect(result.total_revenue).toBeGreaterThan(0); + expect(result.net_benefit).toBeGreaterThan(0); + expect(result.benefit_cost_ratio).toBeGreaterThan(1); + expect(result.cash_flows).toHaveLength(26); + }); + + it("year 0 cash flow is negative installation cost minus incentives", () => { + const result = calculateCostBenefit(BASE_INPUT); + expect(result.cash_flows[0].year).toBe(0); + expect(result.cash_flows[0].net_cash_flow).toBeLessThan(0); + }); + + it("final year includes salvage value", () => { + const result = calculateCostBenefit(BASE_INPUT); + const finalCF = result.cash_flows[result.cash_flows.length - 1]; + expect(finalCF.year).toBe(25); + expect(finalCF.net_cash_flow).toBeGreaterThan(0); + }); +}); + +describe("calculatePaybackPeriod", () => { + it("returns a positive payback period for viable project", () => { + const result = calculatePaybackPeriod(BASE_INPUT); + expect(result.payback_years).toBeGreaterThan(0); + expect(result.simple_payback_years).toBeGreaterThan(0); + expect(result.reaches_payback).toBe(true); + expect(result.cumulative_cash_flow).toHaveLength(26); + }); + + it("discounted payback is longer than simple payback", () => { + const result = calculatePaybackPeriod(BASE_INPUT); + expect(result.discounted_payback_years).toBeGreaterThanOrEqual(result.simple_payback_years); + }); + + it("reaches_payback is false for very high cost projects", () => { + const expensive = createDefaultFinancialInput(500, 80, { + installation_cost: 50_000_000, + electricity_price_per_kwh: 0.01, + }); + const result = calculatePaybackPeriod(expensive); + expect(result.reaches_payback).toBe(false); + }); +}); + +describe("calculateNPV", () => { + it("returns positive NPV for viable project", () => { + const result = calculateNPV(BASE_INPUT); + expect(result.npv).toBeGreaterThan(0); + expect(result.irr).toBeGreaterThan(0); + expect(result.profitability_index).toBeGreaterThan(1); + }); + + it("NPV is lower with higher discount rate", () => { + const lowRate = calculateNPV(createDefaultFinancialInput(500, 80, { discount_rate: 0.05 })); + const highRate = calculateNPV(createDefaultFinancialInput(500, 80, { discount_rate: 0.15 })); + expect(lowRate.npv).toBeGreaterThan(highRate.npv); + }); + + it("negative NPV for uneconomic project", () => { + const bad = createDefaultFinancialInput(500, 80, { + installation_cost: 10_000_000, + electricity_price_per_kwh: 0.01, + }); + const result = calculateNPV(bad); + expect(result.npv).toBeLessThan(0); + }); +}); + +describe("performSensitivityAnalysis", () => { + it("returns base case and sensitivity points", () => { + const result = performSensitivityAnalysis(BASE_INPUT); + expect(result.base_case.npv).toBeGreaterThan(0); + expect(result.base_case.payback_years).toBeGreaterThan(0); + expect(result.sensitivities.length).toBeGreaterThan(0); + }); + + it("higher installation cost reduces NPV", () => { + const result = performSensitivityAnalysis(BASE_INPUT); + const costUp20 = result.sensitivities.find((s) => s.label === "Installation Cost +20%"); + const costDown20 = result.sensitivities.find((s) => s.label === "Installation Cost -20%"); + expect(costUp20).toBeDefined(); + expect(costDown20).toBeDefined(); + expect(costUp20!.npv).toBeLessThan(costDown20!.npv); + }); + + it("higher electricity price increases NPV", () => { + const result = performSensitivityAnalysis(BASE_INPUT); + const priceUp20 = result.sensitivities.find((s) => s.label === "Electricity Price +20%"); + const priceDown20 = result.sensitivities.find((s) => s.label === "Electricity Price -20%"); + expect(priceUp20).toBeDefined(); + expect(priceDown20).toBeDefined(); + expect(priceUp20!.npv).toBeGreaterThan(priceDown20!.npv); + }); + + it("each parameter type is present", () => { + const result = performSensitivityAnalysis(BASE_INPUT); + const parameters = new Set(result.sensitivities.map((s) => s.parameter)); + expect(parameters.has("Installation Cost")).toBe(true); + expect(parameters.has("Electricity Price")).toBe(true); + expect(parameters.has("Discount Rate")).toBe(true); + expect(parameters.has("Degradation Rate")).toBe(true); + expect(parameters.has("Energy Output")).toBe(true); + }); +}); + +describe("compareROI", () => { + it("returns comparison across projects with rankings", () => { + const project1 = createDefaultFinancialInput(500, 80); + const project2 = createDefaultFinancialInput(1000, 75); + const project3 = createDefaultFinancialInput(200, 90); + + const result = compareROI([ + { project_id: 1, input: project1 }, + { project_id: 2, input: project2 }, + { project_id: 3, input: project3 }, + ]); + + expect(result.comparison).toHaveLength(3); + expect(result.rankings.by_roi).toHaveLength(3); + expect(result.rankings.by_npv).toHaveLength(3); + expect(result.rankings.by_irr).toHaveLength(3); + expect(result.rankings.by_payback).toHaveLength(3); + }); + + it("ranks by ROI descending", () => { + const result = compareROI([ + { project_id: 1, input: createDefaultFinancialInput(500, 80, { installation_cost: 100_000 }) }, + { project_id: 2, input: createDefaultFinancialInput(500, 80, { installation_cost: 1_000_000 }) }, + ]); + expect(result.rankings.by_roi[0].project_id).toBe(1); + expect(result.rankings.by_roi[0].roi_pct).toBeGreaterThan(result.rankings.by_roi[1].roi_pct); + }); + + it("ranks by payback ascending", () => { + const result = compareROI([ + { project_id: 1, input: createDefaultFinancialInput(500, 80, { installation_cost: 100_000 }) }, + { project_id: 2, input: createDefaultFinancialInput(500, 80, { installation_cost: 1_000_000 }) }, + ]); + expect(result.rankings.by_payback[0].payback_years).toBeLessThanOrEqual( + result.rankings.by_payback[1].payback_years, + ); + }); +}); + +describe("financial API routes", () => { + let app: Express; + + beforeEach(() => { + app = buildApp(); + }); + + it("GET /api/financial/cost-benefit/:id — returns cost/benefit analysis", async () => { + const res = await request(app) + .get("/api/financial/cost-benefit/1") + .expect(200); + expect(res.body).toHaveProperty("total_installation_cost"); + expect(res.body).toHaveProperty("total_revenue"); + expect(res.body).toHaveProperty("net_benefit"); + expect(res.body).toHaveProperty("benefit_cost_ratio"); + expect(res.body).toHaveProperty("cash_flows"); + expect(res.body.cash_flows).toHaveLength(26); + }); + + it("GET /api/financial/cost-benefit/:id — 400 for invalid id", async () => { + const res = await request(app) + .get("/api/financial/cost-benefit/abc") + .expect(400); + expect(res.body.error).toBe("bad_request"); + }); + + it("GET /api/financial/payback/:id — returns payback period", async () => { + const res = await request(app) + .get("/api/financial/payback/1") + .expect(200); + expect(res.body).toHaveProperty("payback_years"); + expect(res.body).toHaveProperty("simple_payback_years"); + expect(res.body).toHaveProperty("discounted_payback_years"); + expect(res.body).toHaveProperty("cumulative_cash_flow"); + expect(res.body).toHaveProperty("reaches_payback"); + expect(res.body.payback_years).toBeGreaterThan(0); + }); + + it("GET /api/financial/npv/:id — returns NPV calculation", async () => { + const res = await request(app) + .get("/api/financial/npv/1") + .expect(200); + expect(res.body).toHaveProperty("npv"); + expect(res.body).toHaveProperty("irr"); + expect(res.body).toHaveProperty("profitability_index"); + expect(res.body).toHaveProperty("discounted_cash_flows"); + expect(res.body.npv).toBeGreaterThan(0); + }); + + it("GET /api/financial/sensitivity/:id — returns sensitivity analysis", async () => { + const res = await request(app) + .get("/api/financial/sensitivity/1") + .expect(200); + expect(res.body).toHaveProperty("base_case"); + expect(res.body).toHaveProperty("sensitivities"); + expect(res.body.base_case).toHaveProperty("npv"); + expect(res.body.sensitivities.length).toBeGreaterThan(0); + expect(res.body.sensitivities[0]).toHaveProperty("parameter"); + expect(res.body.sensitivities[0]).toHaveProperty("change"); + expect(res.body.sensitivities[0]).toHaveProperty("npv"); + expect(res.body.sensitivities[0]).toHaveProperty("payback_years"); + }); + + it("GET /api/financial/roi-comparison — compares ROI across projects", async () => { + const res = await request(app) + .get("/api/financial/roi-comparison?ids=1,2,3") + .expect(200); + expect(res.body).toHaveProperty("comparison"); + expect(res.body).toHaveProperty("rankings"); + expect(res.body.comparison).toHaveLength(3); + expect(res.body.rankings).toHaveProperty("by_roi"); + expect(res.body.rankings).toHaveProperty("by_npv"); + expect(res.body.rankings).toHaveProperty("by_irr"); + expect(res.body.rankings).toHaveProperty("by_payback"); + }); + + it("GET /api/financial/roi-comparison — 400 for missing ids", async () => { + const res = await request(app) + .get("/api/financial/roi-comparison") + .expect(400); + expect(res.body.error).toBe("bad_request"); + }); + + it("GET /api/financial/roi-comparison — 400 for invalid id", async () => { + const res = await request(app) + .get("/api/financial/roi-comparison?ids=abc") + .expect(400); + expect(res.body.error).toBe("bad_request"); + }); + + it("accepts optional query parameters to override defaults", async () => { + const res = await request(app) + .get("/api/financial/npv/1?installation_cost=800000&discount_rate=0.05") + .expect(200); + expect(res.body).toHaveProperty("npv"); + expect(res.body.npv).toBeGreaterThan(0); + }); + + it("GET /api/financial/payback/:id — accepts parameter overrides", async () => { + const res = await request(app) + .get("/api/financial/payback/1?installation_cost=10000000&electricity_price_per_kwh=0.05") + .expect(200); + expect(res.body.reaches_payback).toBe(false); + }); + + it("GET /api/financial/sensitivity/:id — overrides affect base case", async () => { + const res = await request(app) + .get("/api/financial/sensitivity/1?installation_cost=100000") + .expect(200); + expect(res.body.base_case.npv).toBeGreaterThan(0); + }); + + it("GET /api/financial/roi-comparison — 400 for too many ids", async () => { + const ids = Array.from({ length: 21 }, (_, i) => i + 1).join(","); + const res = await request(app) + .get(`/api/financial/roi-comparison?ids=${ids}`) + .expect(400); + expect(res.body.error).toBe("bad_request"); + }); +}); diff --git a/src/__tests__/forecast.test.ts b/src/__tests__/forecast.test.ts new file mode 100644 index 0000000..9f7250c --- /dev/null +++ b/src/__tests__/forecast.test.ts @@ -0,0 +1,345 @@ +import request from "supertest"; +import express, { Express } from "express"; +import { + getHistoricalSolarData, + getHistoricalSatelliteData, + generateHistory, + forecastProject, + forecastWeatherAdjusted, + analyzeSeasonalPatterns, + evaluateForecastAccuracy, + forecastToCsv, + seasonalPatternsToCsv, + accuracyToCsv, + getValidMethods, + isMethodValid, +} from "../lib/forecast"; +import forecastRouter from "../routes/forecast"; +import { errorHandler } from "../middleware/errors"; + +function buildApp(): Express { + const app = express(); + app.use(express.json()); + app.use("/api/forecast", forecastRouter); + app.use(errorHandler); + return app; +} + +describe("getHistoricalSolarData", () => { + it("returns deterministic data for same project and timestamp", () => { + const ts = 1_700_000_000_000; + const a = getHistoricalSolarData(1, ts); + const b = getHistoricalSolarData(1, ts); + expect(a).toEqual(b); + }); + + it("returns different data for different timestamps", () => { + const a = getHistoricalSolarData(1, 1_700_000_000_000); + const b = getHistoricalSolarData(1, 1_700_003_600_000); + expect(a.power_output_kw).not.toBe(b.power_output_kw); + }); + + it("power_output_kw is in valid range", () => { + const data = getHistoricalSolarData(1, Date.now()); + expect(data.power_output_kw).toBeGreaterThan(0); + expect(data.power_output_kw).toBeLessThanOrEqual(1000); + expect(data.efficiency_pct).toBeGreaterThanOrEqual(40); + expect(data.efficiency_pct).toBeLessThanOrEqual(98); + }); +}); + +describe("getHistoricalSatelliteData", () => { + it("returns deterministic data", () => { + const ts = 1_700_000_000_000; + expect(getHistoricalSatelliteData(1, ts)).toEqual(getHistoricalSatelliteData(1, ts)); + }); + + it("forest_density_pct is in valid range", () => { + const data = getHistoricalSatelliteData(1, Date.now()); + expect(data.forest_density_pct).toBeGreaterThanOrEqual(0); + expect(data.forest_density_pct).toBeLessThanOrEqual(100); + expect(data.ndvi_score).toBeGreaterThanOrEqual(0); + expect(data.ndvi_score).toBeLessThanOrEqual(1); + }); +}); + +describe("generateHistory", () => { + it("returns correct number of history points", () => { + const history = generateHistory(1, "power_output_kw", 48); + expect(history).toHaveLength(48); + expect(history[0].timestamp).toBeLessThan(history[history.length - 1].timestamp); + }); + + it("all values are within range", () => { + const history = generateHistory(1, "efficiency_pct", 24); + for (const point of history) { + expect(point.value).toBeGreaterThanOrEqual(40); + expect(point.value).toBeLessThanOrEqual(98); + } + }); +}); + +describe("forecastProject", () => { + it("returns correct number of forecast points", () => { + const result = forecastProject(1, "power_output_kw", 24); + expect(result.project_id).toBe(1); + expect(result.field).toBe("power_output_kw"); + expect(result.horizon).toBe(24); + expect(result.forecasts).toHaveLength(24); + }); + + it("forecast points are in the future", () => { + const now = Date.now(); + const result = forecastProject(1, "power_output_kw", 12); + for (const fp of result.forecasts) { + expect(fp.timestamp).toBeGreaterThan(now - 3600_000); + } + }); + + it("all methods produce valid forecasts", () => { + for (const method of getValidMethods()) { + const result = forecastProject(1, "efficiency_pct", 6, method as any); + expect(result.forecasts).toHaveLength(6); + expect(result.method).toBe(method); + } + }); + + it("naive method repeats last value", () => { + const result = forecastProject(1, "power_output_kw", 5, "naive"); + const vals = result.forecasts.map((f) => f.value); + expect(new Set(vals).size).toBe(1); + }); + + it("linear_trend produces non-constant forecasts", () => { + const result = forecastProject(1, "power_output_kw", 10, "linear_trend", 168); + const vals = result.forecasts.map((f) => f.value); + expect(new Set(vals).size).toBeGreaterThan(1); + }); +}); + +describe("forecastWeatherAdjusted", () => { + it("returns weather-adjusted forecast points", () => { + const result = forecastWeatherAdjusted(1, 24); + expect(result.method).toBe("weather_adjusted"); + expect(result.field).toBe("power_output_kw"); + expect(result.forecasts).toHaveLength(24); + }); + + it("all forecast values are non-negative", () => { + const result = forecastWeatherAdjusted(1, 48); + for (const fp of result.forecasts) { + expect(fp.value).toBeGreaterThanOrEqual(0); + } + }); +}); + +describe("analyzeSeasonalPatterns", () => { + it("returns hourly and monthly patterns", () => { + const result = analyzeSeasonalPatterns(1, "power_output_kw"); + expect(result.hourly.period).toBe("hourly"); + expect(result.hourly.patterns).toHaveLength(24); + expect(result.monthly.period).toBe("monthly"); + expect(result.monthly.patterns).toHaveLength(12); + expect(result.hourly.strength).toBeGreaterThanOrEqual(0); + expect(result.hourly.strength).toBeLessThanOrEqual(1); + }); + + it("seasonal patterns have counts that sum to total points", () => { + const result = analyzeSeasonalPatterns(1, "efficiency_pct", 168); + const total = result.hourly.patterns.reduce((s, p) => s + p.count, 0); + expect(total).toBe(168); + }); +}); + +describe("evaluateForecastAccuracy", () => { + it("returns accuracy metrics with comparisons", () => { + const result = evaluateForecastAccuracy(1, "power_output_kw", "naive", 6, 48); + expect(result.metrics.sample_count).toBeGreaterThan(0); + expect(result.metrics.mae).toBeGreaterThanOrEqual(0); + expect(result.metrics.rmse).toBeGreaterThanOrEqual(0); + expect(result.comparisons.length).toBeGreaterThan(0); + }); + + it("exponential_smoothing has lower error than naive", () => { + const naive = evaluateForecastAccuracy(1, "power_output_kw", "naive", 12, 72); + const es = evaluateForecastAccuracy(1, "power_output_kw", "exponential_smoothing", 12, 72); + expect(es.metrics.mae).toBeLessThanOrEqual(naive.metrics.mae * 2 + 1); + }); + + it("returns empty for insufficient history", () => { + const result = evaluateForecastAccuracy(1, "power_output_kw", "naive", 100, 4); + expect(result.metrics.sample_count).toBe(0); + }); +}); + +describe("getValidMethods / isMethodValid", () => { + it("returns all method names", () => { + const methods = getValidMethods(); + expect(methods).toContain("naive"); + expect(methods).toContain("moving_average"); + expect(methods).toContain("exponential_smoothing"); + expect(methods).toContain("linear_trend"); + expect(methods).toContain("seasonal_naive"); + expect(methods).toContain("seasonal_decomposition"); + }); + + it("validates methods correctly", () => { + expect(isMethodValid("naive")).toBe(true); + expect(isMethodValid("invalid")).toBe(false); + }); +}); + +describe("CSV export", () => { + it("forecastToCsv includes header and rows", () => { + const result = forecastProject(1, "power_output_kw", 4, "naive"); + const csv = forecastToCsv(result); + expect(csv).toContain("project_id,field,method,horizon,timestamp,forecast_value"); + expect(csv.split("\n")).toHaveLength(6); + }); + + it("seasonalPatternsToCsv includes all pattern types", () => { + const patterns = analyzeSeasonalPatterns(1, "power_output_kw", 24); + const csv = seasonalPatternsToCsv(1, patterns); + expect(csv).toContain("type,label,avg_value,count,strength"); + expect(csv).toContain("hourly"); + expect(csv).toContain("monthly"); + }); + + it("accuracyToCsv includes metrics summary", () => { + const result = evaluateForecastAccuracy(1, "power_output_kw", "naive", 4, 48); + const csv = accuracyToCsv(result); + expect(csv).toContain("MAE,RMSE,MAPE,Bias,SampleCount"); + expect(csv).toContain("project_id,field,method"); + }); +}); + +describe("forecast API routes", () => { + let app: Express; + + beforeEach(() => { + app = buildApp(); + }); + + it("GET /api/forecast/1 — returns forecast", async () => { + const res = await request(app) + .get("/api/forecast/1") + .expect(200); + expect(res.body.project_id).toBe(1); + expect(res.body.forecasts).toHaveLength(24); + expect(res.body.method).toBe("exponential_smoothing"); + }); + + it("GET /api/forecast/1?horizon=6&method=naive — respects params", async () => { + const res = await request(app) + .get("/api/forecast/1?horizon=6&method=naive") + .expect(200); + expect(res.body.forecasts).toHaveLength(6); + expect(res.body.method).toBe("naive"); + }); + + it("GET /api/forecast/1 — 400 for invalid method", async () => { + const res = await request(app) + .get("/api/forecast/1?method=invalid") + .expect(400); + expect(res.body.error).toBe("bad_request"); + }); + + it("GET /api/forecast/1 — 400 for invalid id", async () => { + const res = await request(app) + .get("/api/forecast/abc") + .expect(400); + expect(res.body.error).toBe("bad_request"); + }); + + it("GET /api/forecast/weather-adjusted/1 — returns weather-adjusted forecast", async () => { + const res = await request(app) + .get("/api/forecast/weather-adjusted/1") + .expect(200); + expect(res.body.method).toBe("weather_adjusted"); + expect(res.body.forecasts).toHaveLength(24); + }); + + it("GET /api/forecast/weather-adjusted/1?horizon=6 — respects horizon", async () => { + const res = await request(app) + .get("/api/forecast/weather-adjusted/1?horizon=6") + .expect(200); + expect(res.body.forecasts).toHaveLength(6); + }); + + it("GET /api/forecast/seasonal/1 — returns seasonal patterns", async () => { + const res = await request(app) + .get("/api/forecast/seasonal/1") + .expect(200); + expect(res.body).toHaveProperty("hourly"); + expect(res.body).toHaveProperty("monthly"); + expect(res.body.hourly.patterns).toHaveLength(24); + expect(res.body.monthly.patterns).toHaveLength(12); + }); + + it("GET /api/forecast/seasonal/1?field=efficiency_pct — respects field", async () => { + const res = await request(app) + .get("/api/forecast/seasonal/1?field=efficiency_pct") + .expect(200); + expect(res.body.field).toBe("efficiency_pct"); + }); + + it("GET /api/forecast/accuracy/1 — returns accuracy metrics", async () => { + const res = await request(app) + .get("/api/forecast/accuracy/1") + .expect(200); + expect(res.body).toHaveProperty("metrics"); + expect(res.body).toHaveProperty("comparisons"); + expect(res.body.metrics).toHaveProperty("mae"); + expect(res.body.metrics).toHaveProperty("rmse"); + expect(res.body.metrics).toHaveProperty("mape"); + expect(res.body.metrics).toHaveProperty("bias"); + }); + + it("GET /api/forecast/accuracy/1?method=linear_trend — respects method", async () => { + const res = await request(app) + .get("/api/forecast/accuracy/1?method=linear_trend") + .expect(200); + expect(res.body.method).toBe("linear_trend"); + }); + + it("GET /api/forecast/methods/available — lists methods", async () => { + const res = await request(app) + .get("/api/forecast/methods/available") + .expect(200); + expect(res.body.methods).toBeInstanceOf(Array); + expect(res.body.methods).toContain("naive"); + expect(res.body.methods).toContain("exponential_smoothing"); + }); + + it("GET /api/forecast/1 — returns CSV format", async () => { + const res = await request(app) + .get("/api/forecast/1?format=csv") + .expect(200); + expect(res.headers["content-type"]).toMatch(/text\/csv/); + expect(res.text).toContain("forecast_value"); + }); + + it("GET /api/forecast/seasonal/1 — returns CSV format", async () => { + const res = await request(app) + .get("/api/forecast/seasonal/1?format=csv") + .expect(200); + expect(res.headers["content-type"]).toMatch(/text\/csv/); + expect(res.text).toContain("type,label,avg_value,count,strength"); + }); + + it("GET /api/forecast/accuracy/1 — returns CSV format", async () => { + const res = await request(app) + .get("/api/forecast/accuracy/1?format=csv") + .expect(200); + expect(res.headers["content-type"]).toMatch(/text\/csv/); + expect(res.text).toContain("MAE"); + }); + + it("GET /api/forecast/weather-adjusted/1 — returns CSV format", async () => { + const res = await request(app) + .get("/api/forecast/weather-adjusted/1?format=csv") + .expect(200); + expect(res.headers["content-type"]).toMatch(/text\/csv/); + expect(res.text).toContain("forecast_value"); + }); +}); diff --git a/src/__tests__/maintenance-tracking.test.ts b/src/__tests__/maintenance-tracking.test.ts new file mode 100644 index 0000000..2f3cdf4 --- /dev/null +++ b/src/__tests__/maintenance-tracking.test.ts @@ -0,0 +1,447 @@ +import request from "supertest"; +import express, { Express } from "express"; +import { + createTask, + getTask, + listTasks, + updateTask, + completeTask, + deleteTask, + getCalendar, + getCalendarView, + getMaintenanceHistory, + recordManualMaintenance, + getCompletionStats, + tasksToCsv, + historyToCsv, + clearAllData, +} from "../lib/maintenance-tracking"; +import maintenanceRouter from "../routes/maintenance"; +import { errorHandler } from "../middleware/errors"; + +function buildApp(): Express { + const app = express(); + app.use(express.json()); + app.use("/api/maintenance", maintenanceRouter); + app.use(errorHandler); + return app; +} + +const validTask = { + project_id: 1, + title: "Panel Cleaning - Project 1", + description: "Routine cleaning of solar panels to remove dust and debris", + action_type: "cleaning", + priority: "medium", + scheduled_date: "2026-07-15", + assigned_to: "Field Team A", + estimated_cost: 800, +}; + +describe("createTask", () => { + beforeEach(clearAllData); + + it("creates a task with generated id and default fields", () => { + const task = createTask(validTask); + expect(task.id).toMatch(/^mt_/); + expect(task.project_id).toBe(1); + expect(task.title).toBe("Panel Cleaning - Project 1"); + expect(task.status).toBe("scheduled"); + expect(task.completed_date).toBeNull(); + expect(task.actual_cost).toBeNull(); + expect(task.notes).toBe(""); + expect(task.created_at).toBeTruthy(); + expect(task.updated_at).toBeTruthy(); + }); + + it("rejects invalid input", () => { + expect(() => createTask({} as any)).toThrow(); + expect(() => createTask({ ...validTask, project_id: "abc" } as any)).toThrow(); + expect(() => createTask({ ...validTask, priority: "urgent" } as any)).toThrow(); + expect(() => createTask({ ...validTask, estimated_cost: -5 } as any)).toThrow(); + expect(() => createTask({ ...validTask, scheduled_date: "invalid" } as any)).toThrow(); + expect(() => createTask({ ...validTask, title: "" } as any)).toThrow(); + }); +}); + +describe("listTasks / getTask", () => { + beforeEach(clearAllData); + + it("lists all tasks", () => { + createTask(validTask); + createTask({ ...validTask, project_id: 2, title: "Inspection - Project 2" }); + const tasks = listTasks(); + expect(tasks).toHaveLength(2); + }); + + it("filters by project_id", () => { + createTask(validTask); + createTask({ ...validTask, project_id: 2, title: "Inspection - Project 2" }); + const tasks = listTasks({ project_id: 2 }); + expect(tasks).toHaveLength(1); + expect(tasks[0].project_id).toBe(2); + }); + + it("filters by status", () => { + const t = createTask(validTask); + completeTask(t.id, 750); + createTask({ ...validTask, title: "Inspection" }); + + const completed = listTasks({ status: "completed" }); + const scheduled = listTasks({ status: "scheduled" }); + expect(completed).toHaveLength(1); + expect(scheduled).toHaveLength(1); + }); + + it("filters by date range", () => { + createTask(validTask); + createTask({ ...validTask, title: "Inspection", scheduled_date: "2026-08-01" }); + + const tasks = listTasks({ from_date: "2026-08-01", to_date: "2026-08-31" }); + expect(tasks).toHaveLength(1); + }); + + it("getTask returns undefined for unknown id", () => { + expect(getTask("nonexistent")).toBeUndefined(); + }); + + it("getTask returns the task", () => { + const task = createTask(validTask); + expect(getTask(task.id)).toEqual(task); + }); +}); + +describe("updateTask", () => { + beforeEach(clearAllData); + + it("patches task fields", () => { + const task = createTask(validTask); + const updated = updateTask(task.id, { title: "Updated Title", priority: "high" }); + expect(updated.title).toBe("Updated Title"); + expect(updated.priority).toBe("high"); + expect(updated.updated_at).toBeTruthy(); + }); + + it("throws for unknown task", () => { + expect(() => updateTask("nonexistent", { title: "x" })).toThrow("Task not found"); + }); +}); + +describe("completeTask", () => { + beforeEach(clearAllData); + + it("marks task as completed and creates history record", () => { + const task = createTask(validTask); + const { task: updated, record } = completeTask(task.id, 750, "Completed on time", 68.5, 72.3); + + expect(updated.status).toBe("completed"); + expect(updated.completed_date).toBeTruthy(); + expect(updated.actual_cost).toBe(750); + + expect(record.task_id).toBe(task.id); + expect(record.project_id).toBe(1); + expect(record.efficiency_before).toBe(68.5); + expect(record.efficiency_after).toBe(72.3); + expect(record.effectiveness_pct).toBeCloseTo(5.55, 1); + }); + + it("throws for already completed task", () => { + const task = createTask(validTask); + completeTask(task.id); + expect(() => completeTask(task.id)).toThrow("already completed"); + }); +}); + +describe("deleteTask", () => { + beforeEach(clearAllData); + + it("removes the task", () => { + const task = createTask(validTask); + expect(deleteTask(task.id)).toBe(true); + expect(getTask(task.id)).toBeUndefined(); + }); + + it("returns false for unknown id", () => { + expect(deleteTask("nonexistent")).toBe(false); + }); +}); + +describe("getCalendar", () => { + beforeEach(clearAllData); + + it("groups tasks by date", () => { + createTask(validTask); + createTask({ ...validTask, title: "Task 2", scheduled_date: "2026-07-15" }); + createTask({ ...validTask, title: "Task 3", scheduled_date: "2026-08-01" }); + + const entries = getCalendar("2026-07-01", "2026-08-31"); + expect(entries).toHaveLength(2); // 2 distinct dates + expect(entries[0].tasks).toHaveLength(2); // July 15 has 2 tasks + expect(entries[1].tasks).toHaveLength(1); // Aug 1 has 1 + }); +}); + +describe("getCalendarView", () => { + beforeEach(clearAllData); + + it("returns monthly view", () => { + createTask({ ...validTask, scheduled_date: "2026-07-10" }); + createTask({ ...validTask, title: "B", scheduled_date: "2026-07-20" }); + + const entries = getCalendarView("monthly", "2026-07-01"); + expect(entries.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe("recordManualMaintenance / getMaintenanceHistory", () => { + beforeEach(clearAllData); + + it("records a manual maintenance event", () => { + const record = recordManualMaintenance(1, "repair", "Fixed inverter", 2000, 65, 78); + expect(record.id).toMatch(/^mh_/); + expect(record.task_id).toBeNull(); + expect(record.effectiveness_pct).toBe(20); + }); + + it("returns history for a project", () => { + recordManualMaintenance(1, "cleaning", "Cleaned panels", 500); + recordManualMaintenance(1, "repair", "Fixed wiring", 1200); + recordManualMaintenance(2, "inspection", "Routine check", 300); + + const history = getMaintenanceHistory(1); + expect(history).toHaveLength(2); + expect(getMaintenanceHistory(2)).toHaveLength(1); + expect(getMaintenanceHistory(99)).toHaveLength(0); + }); +}); + +describe("getCompletionStats", () => { + beforeEach(clearAllData); + + it("returns correct stats", () => { + const t1 = createTask(validTask); + const t2 = createTask({ ...validTask, title: "B" }); + createTask({ ...validTask, title: "C" }); + + completeTask(t1.id, 500); + completeTask(t2.id, 600); + + const stats = getCompletionStats(); + expect(stats.total).toBe(3); + expect(stats.completed).toBe(2); + expect(stats.scheduled).toBe(1); + expect(stats.completion_rate).toBeCloseTo(66.67, 1); + }); +}); + +describe("CSV export", () => { + beforeEach(clearAllData); + + it("tasksToCsv includes header", () => { + createTask(validTask); + const tasks = listTasks(); + const csv = tasksToCsv(tasks); + expect(csv).toContain("id,project_id,title,action_type,priority,status,scheduled_date"); + expect(csv.split("\n").filter(Boolean)).toHaveLength(2); // header + 1 row + }); + + it("historyToCsv includes records", () => { + recordManualMaintenance(1, "cleaning", "Cleaned", 500, 70, 75); + const records = getMaintenanceHistory(1); + const csv = historyToCsv(records); + expect(csv).toContain("effectiveness_pct"); + expect(csv).toContain("7.14"); + }); +}); + +describe("maintenance tracking API routes", () => { + let app: Express; + + beforeEach(() => { + clearAllData(); + app = buildApp(); + }); + + it("POST /api/maintenance/tasks — creates a task", async () => { + const res = await request(app) + .post("/api/maintenance/tasks") + .send(validTask) + .expect(201); + expect(res.body.id).toMatch(/^mt_/); + expect(res.body.status).toBe("scheduled"); + expect(res.body.project_id).toBe(1); + }); + + it("POST /api/maintenance/tasks — rejects invalid input", async () => { + await request(app) + .post("/api/maintenance/tasks") + .send({ project_id: "bad" }) + .expect(400); + }); + + it("GET /api/maintenance/tasks — lists tasks", async () => { + await request(app).post("/api/maintenance/tasks").send(validTask); + await request(app).post("/api/maintenance/tasks").send({ ...validTask, title: "Task 2" }); + + const res = await request(app) + .get("/api/maintenance/tasks") + .expect(200); + expect(res.body.tasks).toHaveLength(2); + expect(res.body.count).toBe(2); + }); + + it("GET /api/maintenance/tasks — filters by status", async () => { + const create = await request(app).post("/api/maintenance/tasks").send(validTask); + await request(app).post(`/api/maintenance/tasks/${create.body.id}/complete`).send({}); + + const res = await request(app) + .get("/api/maintenance/tasks?status=completed") + .expect(200); + expect(res.body.tasks).toHaveLength(1); + expect(res.body.tasks[0].status).toBe("completed"); + }); + + it("GET /api/maintenance/tasks?format=csv — returns CSV", async () => { + await request(app).post("/api/maintenance/tasks").send(validTask); + const res = await request(app) + .get("/api/maintenance/tasks?format=csv") + .expect(200); + expect(res.headers["content-type"]).toMatch(/text\/csv/); + expect(res.text).toContain("action_type,priority"); + }); + + it("POST /api/maintenance/tasks/generate/:id — generates tasks from recommendation", async () => { + const res = await request(app) + .post("/api/maintenance/tasks/generate/1") + .expect(201); + expect(res.body.tasks).toBeInstanceOf(Array); + expect(res.body.count).toBeGreaterThan(0); + expect(res.body.tasks[0].id).toMatch(/^mt_/); + }); + + it("GET /api/maintenance/tasks/:taskId — returns a single task", async () => { + const create = await request(app).post("/api/maintenance/tasks").send(validTask); + const res = await request(app) + .get(`/api/maintenance/tasks/${create.body.id}`) + .expect(200); + expect(res.body.title).toBe("Panel Cleaning - Project 1"); + }); + + it("GET /api/maintenance/tasks/:taskId — 404 for unknown id", async () => { + await request(app) + .get("/api/maintenance/tasks/nonexistent") + .expect(404); + }); + + it("PATCH /api/maintenance/tasks/:taskId — updates a task", async () => { + const create = await request(app).post("/api/maintenance/tasks").send(validTask); + const res = await request(app) + .patch(`/api/maintenance/tasks/${create.body.id}`) + .send({ priority: "high", assigned_to: "Team B" }) + .expect(200); + expect(res.body.priority).toBe("high"); + expect(res.body.assigned_to).toBe("Team B"); + }); + + it("POST /api/maintenance/tasks/:taskId/complete — completes a task", async () => { + const create = await request(app).post("/api/maintenance/tasks").send(validTask); + const res = await request(app) + .post(`/api/maintenance/tasks/${create.body.id}/complete`) + .send({ actual_cost: 750, notes: "Done", efficiency_before: 70, efficiency_after: 75 }) + .expect(200); + expect(res.body.task.status).toBe("completed"); + expect(res.body.record).toBeDefined(); + expect(res.body.record.effectiveness_pct).toBeCloseTo(7.14, 1); + }); + + it("DELETE /api/maintenance/tasks/:taskId — removes a task", async () => { + const create = await request(app).post("/api/maintenance/tasks").send(validTask); + await request(app) + .delete(`/api/maintenance/tasks/${create.body.id}`) + .expect(200) + .expect({ removed: true }); + }); + + it("DELETE /api/maintenance/tasks/:taskId — 404 for unknown", async () => { + await request(app) + .delete("/api/maintenance/tasks/nonexistent") + .expect(404); + }); + + it("GET /api/maintenance/calendar — returns calendar entries", async () => { + await request(app).post("/api/maintenance/tasks").send(validTask); + await request(app).post("/api/maintenance/tasks").send({ ...validTask, title: "B", scheduled_date: "2026-07-20" }); + + const res = await request(app) + .get("/api/maintenance/calendar?view=monthly&date=2026-07-01") + .expect(200); + expect(res.body.view).toBe("monthly"); + expect(res.body.entries).toBeInstanceOf(Array); + expect(res.body.count).toBe(2); + }); + + it("GET /api/maintenance/calendar — rejects invalid view", async () => { + await request(app) + .get("/api/maintenance/calendar?view=yearly") + .expect(400); + }); + + it("GET /api/maintenance/calendar/range — returns tasks in date range", async () => { + await request(app).post("/api/maintenance/tasks").send(validTask); + const res = await request(app) + .get("/api/maintenance/calendar/range?from=2026-07-01&to=2026-07-31") + .expect(200); + expect(res.body.entries).toBeInstanceOf(Array); + expect(res.body.from).toBe("2026-07-01"); + }); + + it("GET /api/maintenance/calendar/range — 400 without params", async () => { + await request(app) + .get("/api/maintenance/calendar/range") + .expect(400); + }); + + it("GET /api/maintenance/history/:id — returns maintenance history", async () => { + const res = await request(app) + .get("/api/maintenance/history/1") + .expect(200); + expect(res.body.project_id).toBe(1); + expect(res.body.records).toBeInstanceOf(Array); + }); + + it("GET /api/maintenance/history/:id?format=csv — returns CSV", async () => { + const res = await request(app) + .get("/api/maintenance/history/1?format=csv") + .expect(200); + expect(res.headers["content-type"]).toMatch(/text\/csv/); + expect(res.text).toContain("effectiveness_pct"); + }); + + it("POST /api/maintenance/history/:id — records manual maintenance", async () => { + const res = await request(app) + .post("/api/maintenance/history/1") + .send({ action_type: "repair", description: "Fixed inverter", cost: 2000, efficiency_before: 65, efficiency_after: 80 }) + .expect(201); + expect(res.body.id).toMatch(/^mh_/); + expect(res.body.effectiveness_pct).toBeCloseTo(23.08, 1); + }); + + it("POST /api/maintenance/history/:id — rejects missing fields", async () => { + await request(app) + .post("/api/maintenance/history/1") + .send({ cost: 500 }) + .expect(400); + }); + + it("GET /api/maintenance/stats — returns completion stats", async () => { + const create = await request(app).post("/api/maintenance/tasks").send(validTask); + await request(app).post(`/api/maintenance/tasks/${create.body.id}/complete`).send({}); + + const res = await request(app) + .get("/api/maintenance/stats") + .expect(200); + expect(res.body.total).toBe(1); + expect(res.body.completed).toBe(1); + expect(res.body.completion_rate).toBe(100); + }); +}); diff --git a/src/__tests__/maintenance.test.ts b/src/__tests__/maintenance.test.ts new file mode 100644 index 0000000..b81a2d9 --- /dev/null +++ b/src/__tests__/maintenance.test.ts @@ -0,0 +1,285 @@ +import request from "supertest"; +import express, { Express } from "express"; +import { + analyzeEfficiencyTrend, + predictFailure, + recommendMaintenance, + generateSchedule, + generateFullReport, + recommendationToCsv, + scheduleToCsv, +} from "../lib/maintenance"; +import { setPanelConfig } from "../lib/panels"; +import maintenanceRouter from "../routes/maintenance"; +import { errorHandler } from "../middleware/errors"; + +function buildApp(): Express { + const app = express(); + app.use(express.json()); + app.use("/api/maintenance", maintenanceRouter); + app.use(errorHandler); + return app; +} + +describe("analyzeEfficiencyTrend", () => { + it("returns trend analysis with monthly and weekly averages", () => { + const result = analyzeEfficiencyTrend(1, 168); + expect(result.project_id).toBe(1); + expect(result.trend.direction).toMatch(/improving|declining|stable/); + expect(result.trend.degradation_rate_pct_per_year).toBeGreaterThanOrEqual(0); + expect(result.trend.r_squared).toBeGreaterThanOrEqual(0); + expect(result.trend.r_squared).toBeLessThanOrEqual(1); + expect(result.trend.sample_count).toBe(168); + expect(result.monthly_averages.length).toBeGreaterThan(0); + expect(result.weekly_averages.length).toBeGreaterThan(0); + }); + + it("returns consistent results for same project", () => { + const a = analyzeEfficiencyTrend(1, 168); + const b = analyzeEfficiencyTrend(1, 168); + expect(a.trend.slope).toBe(b.trend.slope); + expect(a.trend.degradation_rate_pct_per_year).toBe(b.trend.degradation_rate_pct_per_year); + }); + + it("different projects have different trends", () => { + const a = analyzeEfficiencyTrend(1, 168); + const b = analyzeEfficiencyTrend(2, 168); + expect(a.trend.baseline_avg).not.toBe(b.trend.baseline_avg); + }); +}); + +describe("predictFailure", () => { + it("returns failure prediction with valid fields", () => { + const result = predictFailure(1, 720); + expect(result.project_id).toBe(1); + expect(result.current_efficiency).toBeGreaterThan(0); + expect(result.critical_threshold).toBeGreaterThan(0); + expect(result.severity).toMatch(/none|low|medium|high|critical/); + expect(result.confidence).toBeGreaterThanOrEqual(0); + expect(result.confidence).toBeLessThanOrEqual(1); + expect(result.trend_quality).toBeGreaterThanOrEqual(0); + expect(result.panel_type).toBeTruthy(); + }); + + it("uses panel config threshold if available", () => { + setPanelConfig(999, { + panel_type: "thin-film", + efficiency_rating: 18, + capacity_kw: 500, + orientation: "S", + tilt_angle: 30, + shading_factor: 0.1, + }); + const result = predictFailure(999, 720); + expect(result.critical_threshold).toBe(60); + expect(result.panel_type).toBe("thin-film"); + }); + + it("returns critical severity when efficiency is very low", () => { + const result = predictFailure(999, 720); + const analysis = analyzeEfficiencyTrend(999, 720); + const avg = analysis.trend.current_avg; + if (avg < 60) { + expect(result.severity).toBe("critical"); + } + }); +}); + +describe("recommendMaintenance", () => { + it("returns recommendations with actions", () => { + setPanelConfig(100, { + panel_type: "monocrystalline", + efficiency_rating: 20, + capacity_kw: 500, + orientation: "S", + tilt_angle: 30, + shading_factor: 0, + }); + const result = recommendMaintenance(100, 720); + expect(result.project_id).toBe(100); + expect(result.panel_type).toBe("monocrystalline"); + expect(result.current_efficiency).toBeGreaterThan(0); + expect(result.overall_health).toMatch(/good|fair|poor|critical/); + expect(result.actions.length).toBeGreaterThan(0); + expect(result.summary).toBeTruthy(); + }); + + it("includes cleaning action for high shading factor", () => { + setPanelConfig(101, { + panel_type: "bifacial", + efficiency_rating: 22, + capacity_kw: 500, + orientation: "S", + tilt_angle: 25, + shading_factor: 0.5, + }); + const result = recommendMaintenance(101, 720); + const cleaningAction = result.actions.find((a) => a.type === "cleaning"); + expect(cleaningAction).toBeDefined(); + expect(cleaningAction!.priority).toBe("high"); + }); + + it("returns actions sorted by priority", () => { + const result = recommendMaintenance(1, 720); + const order = { critical: 0, high: 1, medium: 2, low: 3 }; + for (let i = 1; i < result.actions.length; i++) { + expect(order[result.actions[i - 1].priority]).toBeLessThanOrEqual(order[result.actions[i].priority]); + } + }); + + it("all actions have non-negative cost", () => { + const result = recommendMaintenance(1, 720); + for (const action of result.actions) { + expect(action.estimated_cost).toBeGreaterThan(0); + expect(action.urgency_hours).toBeGreaterThan(0); + } + }); +}); + +describe("generateSchedule", () => { + it("returns a dated schedule with entries", () => { + const result = generateSchedule(1, 720); + expect(result.project_id).toBe(1); + expect(result.generated_at).toBeTruthy(); + expect(result.schedule.length).toBeGreaterThan(0); + }); + + it("each schedule entry has date, actions, and priority", () => { + const result = generateSchedule(1, 720); + for (const entry of result.schedule) { + expect(entry.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(entry.actions.length).toBeGreaterThan(0); + expect(entry.priority).toMatch(/low|medium|high|critical/); + } + }); +}); + +describe("generateFullReport", () => { + it("returns all report sections", () => { + const result = generateFullReport(1, 720); + expect(result.project_id).toBe(1); + expect(result.generated_at).toBeTruthy(); + expect(result.trend_analysis).toBeDefined(); + expect(result.failure_prediction).toBeDefined(); + expect(result.recommendation).toBeDefined(); + expect(result.schedule).toBeDefined(); + expect(result.trend_analysis.trend.sample_count).toBe(720); + }); +}); + +describe("CSV export", () => { + it("recommendationToCsv includes header and action rows", () => { + const recommendation = recommendMaintenance(1, 168); + const csv = recommendationToCsv(recommendation); + expect(csv).toContain("project_id,panel_type,current_efficiency"); + expect(csv.trim().split("\n").length).toBe(recommendation.actions.length + 1); + }); + + it("scheduleToCsv includes header and rows", () => { + const schedule = generateSchedule(1, 168); + const csv = scheduleToCsv(schedule); + expect(csv).toContain("project_id,date,priority,action_type"); + expect(csv.split("\n").length).toBeGreaterThan(1); + expect(csv.trim()).toMatch(/\d{4}-\d{2}-\d{2}/); + }); +}); + +describe("maintenance API routes", () => { + let app: Express; + + beforeEach(() => { + app = buildApp(); + }); + + it("GET /api/maintenance/1/trend — returns efficiency trend", async () => { + const res = await request(app) + .get("/api/maintenance/1/trend") + .expect(200); + expect(res.body.project_id).toBe(1); + expect(res.body.trend).toBeDefined(); + expect(res.body.trend.direction).toMatch(/improving|declining|stable/); + expect(res.body.monthly_averages).toBeInstanceOf(Array); + expect(res.body.weekly_averages).toBeInstanceOf(Array); + }); + + it("GET /api/maintenance/1/trend?history_hours=48 — respects history_hours", async () => { + const res = await request(app) + .get("/api/maintenance/1/trend?history_hours=48") + .expect(200); + expect(res.body.trend.sample_count).toBe(48); + }); + + it("GET /api/maintenance/1/failure-prediction — returns failure prediction", async () => { + const res = await request(app) + .get("/api/maintenance/1/failure-prediction") + .expect(200); + expect(res.body.project_id).toBe(1); + expect(res.body.current_efficiency).toBeGreaterThan(0); + expect(res.body.critical_threshold).toBeGreaterThan(0); + expect(res.body.severity).toMatch(/none|low|medium|high|critical/); + expect(res.body.confidence).toBeGreaterThanOrEqual(0); + }); + + it("GET /api/maintenance/1/failure-prediction?format=csv — returns CSV", async () => { + const res = await request(app) + .get("/api/maintenance/1/failure-prediction?format=csv") + .expect(200); + expect(res.headers["content-type"]).toMatch(/text\/csv/); + expect(res.text).toContain("current_efficiency"); + }); + + it("GET /api/maintenance/1/recommendation — returns maintenance recommendation", async () => { + const res = await request(app) + .get("/api/maintenance/1/recommendation") + .expect(200); + expect(res.body.project_id).toBe(1); + expect(res.body.overall_health).toMatch(/good|fair|poor|critical/); + expect(res.body.actions).toBeInstanceOf(Array); + expect(res.body.actions.length).toBeGreaterThan(0); + expect(res.body.summary).toBeTruthy(); + }); + + it("GET /api/maintenance/1/recommendation?format=csv — returns CSV", async () => { + const res = await request(app) + .get("/api/maintenance/1/recommendation?format=csv") + .expect(200); + expect(res.headers["content-type"]).toMatch(/text\/csv/); + expect(res.text).toContain("action_type,priority"); + }); + + it("GET /api/maintenance/1/schedule — returns maintenance schedule", async () => { + const res = await request(app) + .get("/api/maintenance/1/schedule") + .expect(200); + expect(res.body.project_id).toBe(1); + expect(res.body.schedule).toBeInstanceOf(Array); + expect(res.body.schedule.length).toBeGreaterThan(0); + expect(res.body.schedule[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("GET /api/maintenance/1/schedule?format=csv — returns CSV", async () => { + const res = await request(app) + .get("/api/maintenance/1/schedule?format=csv") + .expect(200); + expect(res.headers["content-type"]).toMatch(/text\/csv/); + expect(res.text).toContain("action_type,description"); + }); + + it("GET /api/maintenance/1/full-report — returns all sections", async () => { + const res = await request(app) + .get("/api/maintenance/1/full-report") + .expect(200); + expect(res.body.project_id).toBe(1); + expect(res.body.trend_analysis).toBeDefined(); + expect(res.body.failure_prediction).toBeDefined(); + expect(res.body.recommendation).toBeDefined(); + expect(res.body.schedule).toBeDefined(); + }); + + it("GET /api/maintenance/abc/trend — 400 for invalid id", async () => { + const res = await request(app) + .get("/api/maintenance/abc/trend") + .expect(400); + expect(res.body.error).toBe("bad_request"); + }); +}); diff --git a/src/index.ts b/src/index.ts index e54d805..68dfc3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,9 @@ import satelliteSourcesRouter from "./routes/satellite-sources"; import aggregateRouter from "./routes/aggregate"; import comparisonRouter from "./routes/comparison"; import benchmarkingRouter from "./routes/benchmarking"; +import financialRouter from "./routes/financial"; +import forecastRouter from "./routes/forecast"; +import maintenanceRouter from "./routes/maintenance"; import { getSolarData, getSatelliteData } from "./routes/iot"; import { computeScores } from "./lib/scoring"; import { updateImpactScore, getTotalProjects } from "./lib/registry"; @@ -77,6 +80,9 @@ v1.use("/chains", adminLimiter, chainsRouter); v1.use("/satellite-sources", adminLimiter, satelliteSourcesRouter); v1.use("/comparison", publicLimiter, comparisonRouter); v1.use("/benchmarking", publicLimiter, benchmarkingRouter); +v1.use("/financial", publicLimiter, financialRouter); +v1.use("/forecast", publicLimiter, forecastRouter); +v1.use("/maintenance", publicLimiter, maintenanceRouter); app.use("/v1", v1); @@ -98,6 +104,9 @@ app.use("/api/dashboard", publicLimiter, dashboardRouter); app.use("/api/email", adminLimiter, emailRouter); app.use("/api/comparison", publicLimiter, comparisonRouter); app.use("/api/benchmarking", publicLimiter, benchmarkingRouter); +app.use("/api/financial", publicLimiter, financialRouter); +app.use("/api/forecast", publicLimiter, forecastRouter); +app.use("/api/maintenance", publicLimiter, maintenanceRouter); // JSON 404 for anything unmatched, then the structured error handler. app.use(notFoundHandler); diff --git a/src/lib/financial.ts b/src/lib/financial.ts new file mode 100644 index 0000000..3305826 --- /dev/null +++ b/src/lib/financial.ts @@ -0,0 +1,454 @@ +export interface FinancialInput { + system_capacity_kw: number; + installation_cost: number; + annual_maintenance_cost: number; + annual_energy_output_kwh: number; + electricity_price_per_kwh: number; + degradation_rate: number; + discount_rate: number; + inflation_rate: number; + project_lifetime_years: number; + tax_incentives: number; + salvage_value: number; + capacity_factor: number; +} + +export interface DiscountedCashFlow { + year: number; + energy_output_kwh: number; + revenue: number; + maintenance_cost: number; + net_cash_flow: number; + discounted_cash_flow: number; + cumulative_discounted_cash_flow: number; +} + +export interface CostBenefitResult { + total_installation_cost: number; + total_maintenance_cost: number; + total_operating_cost: number; + total_cost: number; + total_revenue: number; + tax_incentives: number; + salvage_value: number; + net_benefit: number; + benefit_cost_ratio: number; + cash_flows: DiscountedCashFlow[]; +} + +export interface PaybackPeriodResult { + payback_years: number; + simple_payback_years: number; + discounted_payback_years: number; + cumulative_cash_flow: { year: number; cumulative_net: number }[]; + reaches_payback: boolean; +} + +export interface NPVResult { + npv: number; + irr: number; + profitability_index: number; + total_present_value_benefits: number; + total_present_value_costs: number; + discounted_cash_flows: DiscountedCashFlow[]; +} + +export interface SensitivityPoint { + label: string; + parameter: string; + change: string; + multiplier: number; + npv: number; + payback_years: number; + irr: number; +} + +export interface SensitivityResult { + base_case: { + npv: number; + payback_years: number; + irr: number; + }; + sensitivities: SensitivityPoint[]; +} + +export interface ProjectROI { + project_id: number; + roi_pct: number; + npv: number; + irr: number; + payback_years: number; + benefit_cost_ratio: number; +} + +export interface ROIComparisonResult { + comparison: ProjectROI[]; + rankings: { + by_roi: ProjectROI[]; + by_npv: ProjectROI[]; + by_irr: ProjectROI[]; + by_payback: ProjectROI[]; + }; +} + +function round(n: number, decimals = 2): number { + const factor = Math.pow(10, decimals); + return Math.round(n * factor) / factor; +} + +function buildCashFlows(input: FinancialInput): DiscountedCashFlow[] { + const { + installation_cost, + annual_maintenance_cost, + annual_energy_output_kwh, + electricity_price_per_kwh, + degradation_rate, + discount_rate, + inflation_rate, + project_lifetime_years, + salvage_value, + tax_incentives, + } = input; + + const cashFlows: DiscountedCashFlow[] = []; + const realDiscountRate = (1 + discount_rate) / (1 + inflation_rate) - 1; + + for (let year = 0; year <= project_lifetime_years; year++) { + if (year === 0) { + const netCF = -(installation_cost - tax_incentives); + const dcf = netCF; + cashFlows.push({ + year: 0, + energy_output_kwh: 0, + revenue: 0, + maintenance_cost: 0, + net_cash_flow: round(netCF), + discounted_cash_flow: round(dcf), + cumulative_discounted_cash_flow: round(dcf), + }); + continue; + } + + const degradedOutput = annual_energy_output_kwh * Math.pow(1 - degradation_rate, year - 1); + const inflatedPrice = electricity_price_per_kwh * Math.pow(1 + inflation_rate, year - 1); + const revenue = degradedOutput * inflatedPrice; + const maintenanceCost = annual_maintenance_cost * Math.pow(1 + inflation_rate, year - 1); + let netCF = revenue - maintenanceCost; + if (year === project_lifetime_years) { + netCF += salvage_value; + } + const dcf = netCF / Math.pow(1 + realDiscountRate, year); + const prevCumulative = cashFlows[year - 1]?.cumulative_discounted_cash_flow ?? 0; + + cashFlows.push({ + year, + energy_output_kwh: round(degradedOutput), + revenue: round(revenue), + maintenance_cost: round(maintenanceCost), + net_cash_flow: round(netCF), + discounted_cash_flow: round(dcf), + cumulative_discounted_cash_flow: round(prevCumulative + dcf), + }); + } + + return cashFlows; +} + +function calculateIRR(cashFlows: number[], guess = 0.1): number { + const maxIterations = 1000; + const tolerance = 1e-7; + let rate = guess; + + for (let i = 0; i < maxIterations; i++) { + let npv = 0; + let dnpv = 0; + for (let t = 0; t < cashFlows.length; t++) { + npv += cashFlows[t] / Math.pow(1 + rate, t); + dnpv += -t * cashFlows[t] / Math.pow(1 + rate, t + 1); + } + if (Math.abs(npv) < tolerance) { + return round(rate, 4); + } + if (dnpv === 0) break; + rate = rate - npv / dnpv; + } + return NaN; +} + +export function createDefaultFinancialInput( + capacityKw: number, + _efficiencyPct: number, + partial?: Partial, +): FinancialInput { + const capacityFactor = 0.20; + const annualEnergy = capacityKw * 8760 * capacityFactor; + const installationCostPerKw = 1000; + const maintenancePerKwPerYear = 15; + + const defaults = { + system_capacity_kw: capacityKw, + installation_cost: capacityKw * installationCostPerKw, + annual_maintenance_cost: capacityKw * maintenancePerKwPerYear, + annual_energy_output_kwh: annualEnergy, + electricity_price_per_kwh: 0.10, + degradation_rate: 0.005, + discount_rate: 0.07, + inflation_rate: 0.02, + project_lifetime_years: 25, + tax_incentives: capacityKw * installationCostPerKw * 0.30, + salvage_value: capacityKw * installationCostPerKw * 0.10, + capacity_factor: capacityFactor, + }; + + const merged = { ...defaults, ...partial }; + + const finalInstallCost = merged.installation_cost; + if (partial?.tax_incentives === undefined) { + merged.tax_incentives = finalInstallCost * 0.30; + } + if (partial?.salvage_value === undefined) { + merged.salvage_value = finalInstallCost * 0.10; + } + + return merged; +} + +export function calculateCostBenefit(input: FinancialInput): CostBenefitResult { + const cashFlows = buildCashFlows(input); + const operatingCashFlows = cashFlows.filter((cf) => cf.year > 0); + + const totalRevenue = round(operatingCashFlows.reduce((sum, cf) => sum + cf.revenue, 0)); + const totalMaintenance = round(operatingCashFlows.reduce((sum, cf) => sum + cf.maintenance_cost, 0)); + const totalInstallation = input.installation_cost; + const totalOperating = totalMaintenance; + const totalCost = round(totalInstallation + totalOperating); + const netBenefit = round(totalRevenue + input.salvage_value + input.tax_incentives - totalCost); + const benefitCostRatio = round( + (totalRevenue + input.salvage_value + input.tax_incentives) / totalCost, + ); + + return { + total_installation_cost: round(totalInstallation), + total_maintenance_cost: round(totalMaintenance), + total_operating_cost: round(totalOperating), + total_cost: totalCost, + total_revenue: totalRevenue, + tax_incentives: input.tax_incentives, + salvage_value: input.salvage_value, + net_benefit: netBenefit, + benefit_cost_ratio: benefitCostRatio, + cash_flows: cashFlows, + }; +} + +export function calculatePaybackPeriod(input: FinancialInput): PaybackPeriodResult { + const cashFlows = buildCashFlows(input); + const netCashFlows = cashFlows.map((cf) => cf.net_cash_flow); + const discountedCashFlows = cashFlows.map((cf) => cf.discounted_cash_flow); + + const cumulative: { year: number; cumulative_net: number }[] = []; + let cumNet = 0; + + for (let i = 0; i < netCashFlows.length; i++) { + cumNet += netCashFlows[i]; + cumulative.push({ year: i, cumulative_net: round(cumNet) }); + } + + let simplePaybackYears = 0; + let cum = 0; + for (let i = 0; i < netCashFlows.length; i++) { + cum += netCashFlows[i]; + if (cum >= 0) { + const prevCum = cum - netCashFlows[i]; + if (netCashFlows[i] !== 0) { + simplePaybackYears = (i - 1) + Math.abs(prevCum) / netCashFlows[i]; + } else { + simplePaybackYears = i; + } + break; + } + } + + let discountedPaybackYears = 0; + let discCum = 0; + for (let i = 0; i < discountedCashFlows.length; i++) { + discCum += discountedCashFlows[i]; + if (discCum >= 0) { + const prevDiscCum = discCum - discountedCashFlows[i]; + if (discountedCashFlows[i] !== 0) { + discountedPaybackYears = (i - 1) + Math.abs(prevDiscCum) / discountedCashFlows[i]; + } else { + discountedPaybackYears = i; + } + break; + } + } + + return { + payback_years: round(simplePaybackYears), + simple_payback_years: round(simplePaybackYears), + discounted_payback_years: round(discountedPaybackYears), + cumulative_cash_flow: cumulative, + reaches_payback: simplePaybackYears > 0 && simplePaybackYears < input.project_lifetime_years, + }; +} + +export function calculateNPV(input: FinancialInput): NPVResult { + const cashFlows = buildCashFlows(input); + const netCFs = cashFlows.map((cf) => cf.net_cash_flow); + const discountedCFs = cashFlows.map((cf) => cf.discounted_cash_flow); + + const npv = round(discountedCFs.reduce((sum, cf) => sum + cf, 0)); + + const irr = calculateIRR(netCFs); + + const totalPVBenefits = round( + discountedCFs.filter((_, i) => i > 0).reduce((sum, cf) => sum + cf, 0), + ); + const totalPVCosts = round(Math.abs(discountedCFs[0])); + + const profitabilityIndex = totalPVCosts > 0 ? round(totalPVBenefits / totalPVCosts) : 0; + + return { + npv, + irr: isNaN(irr) ? 0 : round(irr * 100, 2), + profitability_index: profitabilityIndex, + total_present_value_benefits: totalPVBenefits, + total_present_value_costs: totalPVCosts, + discounted_cash_flows: cashFlows, + }; +} + +type SensitivityParam = { + key: keyof FinancialInput; + label: string; + variations: { label: string; multiplier: number }[]; +}; + +const SENSITIVITY_PARAMS: SensitivityParam[] = [ + { + key: "installation_cost", + label: "Installation Cost", + variations: [ + { label: "-20%", multiplier: 0.80 }, + { label: "-10%", multiplier: 0.90 }, + { label: "+10%", multiplier: 1.10 }, + { label: "+20%", multiplier: 1.20 }, + ], + }, + { + key: "electricity_price_per_kwh", + label: "Electricity Price", + variations: [ + { label: "-20%", multiplier: 0.80 }, + { label: "-10%", multiplier: 0.90 }, + { label: "+10%", multiplier: 1.10 }, + { label: "+20%", multiplier: 1.20 }, + ], + }, + { + key: "discount_rate", + label: "Discount Rate", + variations: [ + { label: "-2%", multiplier: (1 / 0.07) * 0.05 }, + { label: "-1%", multiplier: (1 / 0.07) * 0.06 }, + { label: "+1%", multiplier: (1 / 0.07) * 0.08 }, + { label: "+2%", multiplier: (1 / 0.07) * 0.09 }, + ], + }, + { + key: "degradation_rate", + label: "Degradation Rate", + variations: [ + { label: "-0.25%", multiplier: 0.5 }, + { label: "+0.25%", multiplier: 1.5 }, + { label: "+0.50%", multiplier: 2.0 }, + ], + }, + { + key: "annual_energy_output_kwh", + label: "Energy Output", + variations: [ + { label: "-20%", multiplier: 0.80 }, + { label: "-10%", multiplier: 0.90 }, + { label: "+10%", multiplier: 1.10 }, + { label: "+20%", multiplier: 1.20 }, + ], + }, +]; + +export function performSensitivityAnalysis(input: FinancialInput): SensitivityResult { + const baseNPV = calculateNPV(input); + const basePayback = calculatePaybackPeriod(input); + + const baseCase = { + npv: baseNPV.npv, + payback_years: basePayback.payback_years, + irr: baseNPV.irr, + }; + + const sensitivities: SensitivityPoint[] = []; + + for (const param of SENSITIVITY_PARAMS) { + for (const variation of param.variations) { + let variedInput: FinancialInput; + if (param.key === "discount_rate") { + variedInput = { ...input, [param.key]: input[param.key] * variation.multiplier }; + } else { + variedInput = { ...input, [param.key]: (input[param.key] as number) * variation.multiplier }; + } + const npvResult = calculateNPV(variedInput); + const paybackResult = calculatePaybackPeriod(variedInput); + + sensitivities.push({ + label: `${param.label} ${variation.label}`, + parameter: param.label, + change: variation.label, + multiplier: variation.multiplier, + npv: npvResult.npv, + payback_years: paybackResult.payback_years, + irr: npvResult.irr, + }); + } + } + + return { base_case: baseCase, sensitivities }; +} + +function calculateProjectROI(projectId: number, input: FinancialInput): ProjectROI { + const npvResult = calculateNPV(input); + const paybackResult = calculatePaybackPeriod(input); + const costBenefit = calculateCostBenefit(input); + + const totalInvestment = input.installation_cost - input.tax_incentives; + const totalNetReturn = costBenefit.net_benefit; + const roiPct = totalInvestment > 0 ? round((totalNetReturn / totalInvestment) * 100) : 0; + + return { + project_id: projectId, + roi_pct: roiPct, + npv: npvResult.npv, + irr: npvResult.irr, + payback_years: paybackResult.payback_years, + benefit_cost_ratio: costBenefit.benefit_cost_ratio, + }; +} + +export function compareROI(projects: { project_id: number; input: FinancialInput }[]): ROIComparisonResult { + const all = projects.map((p) => calculateProjectROI(p.project_id, p.input)); + + const byROI = [...all].sort((a, b) => b.roi_pct - a.roi_pct); + const byNPV = [...all].sort((a, b) => b.npv - a.npv); + const byIRR = [...all].sort((a, b) => b.irr - a.irr); + const byPayback = [...all].sort((a, b) => a.payback_years - b.payback_years); + + return { + comparison: all, + rankings: { + by_roi: byROI, + by_npv: byNPV, + by_irr: byIRR, + by_payback: byPayback, + }, + }; +} diff --git a/src/lib/forecast.ts b/src/lib/forecast.ts new file mode 100644 index 0000000..f8b2aa5 --- /dev/null +++ b/src/lib/forecast.ts @@ -0,0 +1,436 @@ +const MAX_POWER_KW = 1000; + +export interface ForecastPoint { + timestamp: number; + value: number; +} + +export interface ForecastResult { + project_id: number; + field: string; + method: string; + horizon: number; + forecasts: ForecastPoint[]; +} + +export interface SeasonalPattern { + period: string; + patterns: { label: string; avg_value: number; count: number }[]; + strength: number; +} + +export interface AccuracyMetrics { + mae: number; + rmse: number; + mape: number; + bias: number; + sample_count: number; +} + +export interface ForecastAccuracyResult { + project_id: number; + field: string; + method: string; + metrics: AccuracyMetrics; + comparisons: { timestamp: number; actual: number; predicted: number; error: number }[]; +} + +export interface SolarSnapshot { + power_output_kw: number; + efficiency_pct: number; + max_power_kw: number; + timestamp: number; +} + +export interface SatelliteSnapshot { + forest_density_pct: number; + ndvi_score: number; + timestamp: number; +} + +function seededRandomAtTime(seed: number, timeMs: number): number { + const hourSeed = Math.floor(timeMs / 3_600_000); + const x = Math.sin(seed * 9301 + hourSeed * 49297 + 233) * 10000; + return x - Math.floor(x); +} + +export function getHistoricalSolarData(projectId: number, timestamp: number): SolarSnapshot { + const base = seededRandomAtTime(projectId, timestamp); + const drift = seededRandomAtTime(projectId * 7 + 1, timestamp); + const efficiency_pct = Math.min(98, Math.max(40, 40 + base * 58 + drift * 2 - 1)); + const power_output_kw = (efficiency_pct / 100) * MAX_POWER_KW; + return { + power_output_kw: Math.round(power_output_kw * 100) / 100, + efficiency_pct: Math.round(efficiency_pct * 100) / 100, + max_power_kw: MAX_POWER_KW, + timestamp, + }; +} + +export function getHistoricalSatelliteData(projectId: number, timestamp: number): SatelliteSnapshot { + const base = seededRandomAtTime(projectId * 3 + 5, timestamp); + const drift = seededRandomAtTime(projectId * 11 + 2, timestamp); + const forest_density_pct = Math.min(100, Math.max(0, 30 + base * 65 + drift * 5 - 2.5)); + return { + forest_density_pct: Math.round(forest_density_pct * 100) / 100, + ndvi_score: Math.round(Math.min(1, forest_density_pct / 100) * 1000) / 1000, + timestamp, + }; +} + +export function generateHistory( + projectId: number, + field: "power_output_kw" | "efficiency_pct", + hoursBack: number, +): ForecastPoint[] { + const now = Date.now(); + const points: ForecastPoint[] = []; + for (let i = hoursBack; i >= 1; i--) { + const ts = now - i * 3_600_000; + const solar = getHistoricalSolarData(projectId, ts); + points.push({ timestamp: ts, value: solar[field] }); + } + return points; +} + +function round(n: number, d = 4): number { + const f = Math.pow(10, d); + return Math.round(n * f) / f; +} + +function mean(values: number[]): number { + return values.reduce((s, v) => s + v, 0) / values.length; +} + +function stdDev(values: number[], avg: number): number { + return Math.sqrt(values.reduce((s, v) => s + (v - avg) ** 2, 0) / values.length); +} + +function last(values: number[]): number { + return values[values.length - 1]; +} + +// ── Forecasting methods ────────────────────────────────────────────────────── + +function naiveForecast(history: number[], horizon: number): number[] { + if (history.length === 0) return []; + const v = last(history); + return Array(horizon).fill(v); +} + +function movingAverageForecast(history: number[], horizon: number, window = 4): number[] { + if (history.length < window) return naiveForecast(history, horizon); + const windowed = history.slice(-window); + const avg = mean(windowed); + return Array(horizon).fill(round(avg)); +} + +function exponentialSmoothingForecast(history: number[], horizon: number, alpha = 0.3): number[] { + if (history.length === 0) return []; + let s = history[0]; + for (let i = 1; i < history.length; i++) { + s = alpha * history[i] + (1 - alpha) * s; + } + return Array(horizon).fill(round(s)); +} + +function linearTrendForecast(history: number[], horizon: number): number[] { + const n = history.length; + if (n < 2) return naiveForecast(history, horizon); + const xMean = (n - 1) / 2; + const yMean = mean(history); + let num = 0; + let den = 0; + for (let i = 0; i < n; i++) { + const x = i; + num += (x - xMean) * (history[i] - yMean); + den += (x - xMean) ** 2; + } + const slope = den !== 0 ? num / den : 0; + const intercept = yMean - slope * xMean; + const forecasts: number[] = []; + for (let i = 0; i < horizon; i++) { + const t = n + i; + forecasts.push(round(intercept + slope * t)); + } + return forecasts; +} + +function seasonalNaiveForecast(history: number[], horizon: number, seasonLength = 24): number[] { + if (history.length < seasonLength) return naiveForecast(history, horizon); + const forecasts: number[] = []; + for (let i = 0; i < horizon; i++) { + const idx = history.length - seasonLength + (i % seasonLength); + forecasts.push(history[idx]); + } + return forecasts; +} + +function seasonalDecompositionForecast(history: number[], horizon: number, seasonLength = 24): number[] { + const n = history.length; + if (n < seasonLength * 2) return linearTrendForecast(history, horizon); + + const seasonalComponents: number[] = []; + for (let i = 0; i < seasonLength; i++) { + let sum = 0; + let count = 0; + for (let j = i; j < n; j += seasonLength) { + sum += history[j]; + count++; + } + seasonalComponents.push(count > 0 ? sum / count : 0); + } + + const seasonalAvg = mean(seasonalComponents); + const detrended: number[] = history.map((v, i) => v - seasonalComponents[i % seasonLength] + seasonalAvg); + const trend = linearTrendForecast(detrended, horizon); + + const forecasts: number[] = []; + for (let i = 0; i < horizon; i++) { + const idx = (n + i) % seasonLength; + forecasts.push(round(trend[i] - seasonalAvg + seasonalComponents[idx])); + } + return forecasts; +} + +type ForecastMethod = "naive" | "moving_average" | "exponential_smoothing" | "linear_trend" | "seasonal_naive" | "seasonal_decomposition"; + +const FORECAST_METHODS: Record number[]> = { + naive: naiveForecast, + moving_average: (h, horizon) => movingAverageForecast(h, horizon, 4), + exponential_smoothing: (h, horizon) => exponentialSmoothingForecast(h, horizon, 0.3), + linear_trend: linearTrendForecast, + seasonal_naive: (h, horizon) => seasonalNaiveForecast(h, horizon, 24), + seasonal_decomposition: (h, horizon) => seasonalDecompositionForecast(h, horizon, 24), +}; + +export function getValidMethods(): string[] { + return Object.keys(FORECAST_METHODS); +} + +export function isMethodValid(method: string): method is ForecastMethod { + return method in FORECAST_METHODS; +} + +// ── Main forecast function ─────────────────────────────────────────────────── + +export function forecastProject( + projectId: number, + field: "power_output_kw" | "efficiency_pct", + horizon: number, + method: ForecastMethod = "exponential_smoothing", + historyHours = 168, +): ForecastResult { + const history = generateHistory(projectId, field, historyHours); + const values = history.map((p) => p.value); + + const forecastFn = FORECAST_METHODS[method]; + const predicted = forecastFn(values, horizon); + + const lastTs = history.length > 0 ? history[history.length - 1].timestamp : Date.now(); + const forecasts: ForecastPoint[] = predicted.map((v, i) => ({ + timestamp: lastTs + (i + 1) * 3_600_000, + value: round(v, 2), + })); + + return { + project_id: projectId, + field, + method, + horizon, + forecasts, + }; +} + +// ── Weather-adjusted predictions ───────────────────────────────────────────── + +export function forecastWeatherAdjusted( + projectId: number, + horizon: number, + historyHours = 168, +): ForecastResult { + const solarHistory = generateHistory(projectId, "power_output_kw", historyHours); + const tsValues = solarHistory.map((p) => p.value); + + const now = Date.now(); + const satHistory: { forest_density_pct: number; ndvi_score: number }[] = []; + for (let i = historyHours; i >= 1; i--) { + satHistory.push(getHistoricalSatelliteData(projectId, now - i * 3_600_000)); + } + + const correlations = satHistory.map((s) => s.ndvi_score); + const avgCorr = mean(correlations); + const baseForecast = exponentialSmoothingForecast(tsValues, horizon, 0.3); + + const lateHours = 24; + const recentSatData = correlations.slice(-lateHours).filter((v) => v > 0); + const recentWeatherFactor = recentSatData.length > 0 + ? mean(recentSatData) / Math.max(avgCorr, 0.01) + : 1; + + const lastTs = solarHistory.length > 0 ? solarHistory[solarHistory.length - 1].timestamp : now; + const forecasts: ForecastPoint[] = baseForecast.map((v, i) => { + const weatherImpact = 1 + (recentWeatherFactor - 1) * Math.max(0, 1 - i / horizon); + return { + timestamp: lastTs + (i + 1) * 3_600_000, + value: round(Math.max(0, v * weatherImpact), 2), + }; + }); + + return { + project_id: projectId, + field: "power_output_kw", + method: "weather_adjusted", + horizon, + forecasts, + }; +} + +// ── Seasonal patterns ──────────────────────────────────────────────────────── + +export function analyzeSeasonalPatterns( + projectId: number, + field: "power_output_kw" | "efficiency_pct", + historyHours = 720, +): { hourly: SeasonalPattern; monthly: SeasonalPattern } { + const history = generateHistory(projectId, field, historyHours); + + const hourlyBuckets = new Map(); + for (const point of history) { + const hour = new Date(point.timestamp).getUTCHours(); + if (!hourlyBuckets.has(hour)) hourlyBuckets.set(hour, []); + hourlyBuckets.get(hour)!.push(point.value); + } + + const hourlyPatterns: { label: string; avg_value: number; count: number }[] = []; + let hourlyStrengthSum = 0; + for (let h = 0; h < 24; h++) { + const vals = hourlyBuckets.get(h) ?? []; + const avg = vals.length > 0 ? mean(vals) : 0; + hourlyPatterns.push({ label: `${h}:00`, avg_value: round(avg, 2), count: vals.length }); + if (vals.length > 0) { + hourlyStrengthSum += Math.abs(avg - mean(history.map((p) => p.value))); + } + } + + const monthlyBuckets = new Map(); + for (const point of history) { + const month = new Date(point.timestamp).getUTCMonth(); + if (!monthlyBuckets.has(month)) monthlyBuckets.set(month, []); + monthlyBuckets.get(month)!.push(point.value); + } + + const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const monthlyPatterns: { label: string; avg_value: number; count: number }[] = []; + let monthlyStrengthSum = 0; + const overallAvg = mean(history.map((p) => p.value)); + for (let m = 0; m < 12; m++) { + const vals = monthlyBuckets.get(m) ?? []; + const avg = vals.length > 0 ? mean(vals) : 0; + monthlyPatterns.push({ label: monthNames[m], avg_value: round(avg, 2), count: vals.length }); + if (vals.length > 0) { + monthlyStrengthSum += Math.abs(avg - overallAvg); + } + } + + const totalHourlyDev = history.length > 0 ? hourlyStrengthSum / 24 : 0; + const totalVar = history.length > 0 ? stdDev(history.map((p) => p.value), overallAvg) : 1; + const hourlyStrength = totalVar > 0 ? Math.min(1, totalHourlyDev / totalVar) : 0; + + const totalMonthlyDev = history.length > 0 ? monthlyStrengthSum / 12 : 0; + const monthlyStrength = totalVar > 0 ? Math.min(1, totalMonthlyDev / totalVar) : 0; + + return { + hourly: { period: "hourly", patterns: hourlyPatterns, strength: round(hourlyStrength, 4) }, + monthly: { period: "monthly", patterns: monthlyPatterns, strength: round(monthlyStrength, 4) }, + }; +} + +// ── Forecast accuracy ──────────────────────────────────────────────────────── + +export function evaluateForecastAccuracy( + projectId: number, + field: "power_output_kw" | "efficiency_pct", + method: ForecastMethod = "exponential_smoothing", + testHours = 24, + trainingHours = 168, +): ForecastAccuracyResult { + const history = generateHistory(projectId, field, trainingHours); + if (history.length < testHours + 1) { + return { + project_id: projectId, + field, + method, + metrics: { mae: 0, rmse: 0, mape: 0, bias: 0, sample_count: 0 }, + comparisons: [], + }; + } + + const training = history.slice(0, -testHours); + const actuals = history.slice(-testHours); + + const trainValues = training.map((p) => p.value); + const forecastFn = FORECAST_METHODS[method]; + const predictedValues = forecastFn(trainValues, testHours); + + const comparisons: { timestamp: number; actual: number; predicted: number; error: number }[] = []; + for (let i = 0; i < testHours && i < predictedValues.length && i < actuals.length; i++) { + comparisons.push({ + timestamp: actuals[i].timestamp, + actual: actuals[i].value, + predicted: round(predictedValues[i], 2), + error: round(actuals[i].value - predictedValues[i], 2), + }); + } + + const errors = comparisons.map((c) => Math.abs(c.error)); + const sqErrors = comparisons.map((c) => c.error ** 2); + const pctErrors = comparisons.map((c) => (c.actual !== 0 ? Math.abs(c.error / c.actual) * 100 : 0)); + const biases = comparisons.map((c) => c.error); + + const count = comparisons.length; + const metrics: AccuracyMetrics = { + mae: count > 0 ? round(mean(errors), 2) : 0, + rmse: count > 0 ? round(Math.sqrt(mean(sqErrors)), 2) : 0, + mape: count > 0 ? round(mean(pctErrors), 2) : 0, + bias: count > 0 ? round(mean(biases), 2) : 0, + sample_count: count, + }; + + return { project_id: projectId, field, method, metrics, comparisons }; +} + +// ── CSV export ─────────────────────────────────────────────────────────────── + +export function forecastToCsv(result: ForecastResult): string { + const header = "project_id,field,method,horizon,timestamp,forecast_value"; + const rows = result.forecasts.map((f) => + `${result.project_id},${result.field},${result.method},${result.horizon},${new Date(f.timestamp).toISOString()},${f.value}`, + ); + return [header, ...rows].join("\n") + "\n"; +} + +export function seasonalPatternsToCsv( + projectId: number, + patterns: { hourly: SeasonalPattern; monthly: SeasonalPattern }, +): string { + const rows: string[] = ["type,label,avg_value,count,strength"]; + const hourly = patterns.hourly; + for (const p of hourly.patterns) { + rows.push(`hourly,${p.label},${p.avg_value},${p.count},${hourly.strength}`); + } + const monthly = patterns.monthly; + for (const p of monthly.patterns) { + rows.push(`monthly,${p.label},${p.avg_value},${p.count},${monthly.strength}`); + } + return rows.join("\n") + "\n"; +} + +export function accuracyToCsv(result: ForecastAccuracyResult): string { + const header = "project_id,field,method,timestamp,actual,predicted,error"; + const rows = result.comparisons.map((c) => + `${result.project_id},${result.field},${result.method},${new Date(c.timestamp).toISOString()},${c.actual},${c.predicted},${c.error}`, + ); + const summary = `\nMAE,RMSE,MAPE,Bias,SampleCount\n${result.metrics.mae},${result.metrics.rmse},${result.metrics.mape},${result.metrics.bias},${result.metrics.sample_count}`; + return [header, ...rows, summary].join("\n") + "\n"; +} diff --git a/src/lib/maintenance-tracking.ts b/src/lib/maintenance-tracking.ts new file mode 100644 index 0000000..fed6c59 --- /dev/null +++ b/src/lib/maintenance-tracking.ts @@ -0,0 +1,395 @@ +export type TaskStatus = "scheduled" | "in_progress" | "completed" | "cancelled"; +export type TaskPriority = "low" | "medium" | "high" | "critical"; + +export interface MaintenanceTask { + id: string; + project_id: number; + title: string; + description: string; + action_type: string; + priority: TaskPriority; + status: TaskStatus; + scheduled_date: string; + completed_date: string | null; + assigned_to: string; + estimated_cost: number; + actual_cost: number | null; + notes: string; + created_at: string; + updated_at: string; +} + +export interface TaskInput { + project_id?: unknown; + title?: unknown; + description?: unknown; + action_type?: unknown; + priority?: unknown; + scheduled_date?: unknown; + assigned_to?: unknown; + estimated_cost?: unknown; +} + +export interface TaskPatch { + title?: string; + description?: string; + action_type?: string; + priority?: TaskPriority; + status?: TaskStatus; + scheduled_date?: string; + assigned_to?: string; + estimated_cost?: number; + actual_cost?: number; + notes?: string; +} + +export interface MaintenanceRecord { + id: string; + project_id: number; + task_id: string | null; + action_type: string; + description: string; + completed_date: string; + cost: number; + efficiency_before: number | null; + efficiency_after: number | null; + effectiveness_pct: number | null; + notes: string; + created_at: string; +} + +export interface CalendarEntry { + date: string; + tasks: MaintenanceTask[]; +} + +export interface CompletionStats { + total: number; + completed: number; + cancelled: number; + in_progress: number; + scheduled: number; + completion_rate: number; + total_estimated_cost: number; + total_actual_cost: number; +} + +export type CalendarView = "daily" | "weekly" | "monthly"; + +const taskStore = new Map(); +const historyStore = new Map(); + +function generateId(): string { + return `mt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +function now(): string { + return new Date().toISOString(); +} + +function todayDate(): string { + return new Date().toISOString().split("T")[0]; +} + +function validateTaskInput(input: TaskInput): Omit { + const { project_id, title, description, action_type, priority, scheduled_date, assigned_to, estimated_cost } = input; + + if (typeof project_id !== "number" || !Number.isInteger(project_id) || project_id < 1) { + throw new Error("project_id must be a positive integer"); + } + if (typeof title !== "string" || title.trim().length === 0) { + throw new Error("title must be a non-empty string"); + } + if (typeof description !== "string" || description.trim().length === 0) { + throw new Error("description must be a non-empty string"); + } + if (typeof action_type !== "string" || action_type.trim().length === 0) { + throw new Error("action_type must be a non-empty string"); + } + const validPriorities: TaskPriority[] = ["low", "medium", "high", "critical"]; + if (typeof priority !== "string" || !validPriorities.includes(priority as TaskPriority)) { + throw new Error(`priority must be one of: ${validPriorities.join(", ")}`); + } + if (typeof scheduled_date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(scheduled_date)) { + throw new Error("scheduled_date must be a valid date (YYYY-MM-DD)"); + } + if (typeof assigned_to !== "string" || assigned_to.trim().length === 0) { + throw new Error("assigned_to must be a non-empty string"); + } + if (typeof estimated_cost !== "number" || estimated_cost < 0) { + throw new Error("estimated_cost must be a non-negative number"); + } + + return { + project_id, + title: title.trim(), + description: description.trim(), + action_type: action_type.trim(), + priority: priority as TaskPriority, + scheduled_date, + assigned_to: assigned_to.trim(), + estimated_cost, + }; +} + +export function createTask(input: TaskInput): MaintenanceTask { + const validated = validateTaskInput(input); + const task: MaintenanceTask = { + id: generateId(), + ...validated, + status: "scheduled", + completed_date: null, + actual_cost: null, + notes: "", + created_at: now(), + updated_at: now(), + }; + taskStore.set(task.id, task); + return task; +} + +export function getTask(id: string): MaintenanceTask | undefined { + return taskStore.get(id); +} + +export interface TaskFilter { + project_id?: number; + status?: TaskStatus; + priority?: TaskPriority; + from_date?: string; + to_date?: string; +} + +export function listTasks(filter?: TaskFilter): MaintenanceTask[] { + let tasks = Array.from(taskStore.values()); + + if (filter) { + if (filter.project_id !== undefined) { + tasks = tasks.filter((t) => t.project_id === filter.project_id); + } + if (filter.status) { + tasks = tasks.filter((t) => t.status === filter.status); + } + if (filter.priority) { + tasks = tasks.filter((t) => t.priority === filter.priority); + } + if (filter.from_date) { + tasks = tasks.filter((t) => t.scheduled_date >= filter.from_date!); + } + if (filter.to_date) { + tasks = tasks.filter((t) => t.scheduled_date <= filter.to_date!); + } + } + + return tasks.sort((a, b) => a.scheduled_date.localeCompare(b.scheduled_date)); +} + +export function updateTask(id: string, patch: TaskPatch): MaintenanceTask { + const existing = taskStore.get(id); + if (!existing) { + throw new Error("Task not found"); + } + + const updated: MaintenanceTask = { + ...existing, + ...patch, + updated_at: now(), + }; + + if (patch.status === "completed" && !updated.completed_date) { + updated.completed_date = todayDate(); + } + + taskStore.set(id, updated); + return updated; +} + +export function completeTask( + id: string, + actualCost?: number, + notes?: string, + effBefore?: number, + effAfter?: number, +): { task: MaintenanceTask; record: MaintenanceRecord } { + const task = getTask(id); + if (!task) throw new Error("Task not found"); + if (task.status === "completed") throw new Error("Task is already completed"); + + const completedDate = todayDate(); + const updated: MaintenanceTask = { + ...task, + status: "completed", + completed_date: completedDate, + actual_cost: actualCost ?? task.estimated_cost, + notes: notes ?? "", + updated_at: now(), + }; + taskStore.set(id, updated); + + const effBeforeVal = effBefore ?? null; + const effAfterVal = effAfter ?? null; + const effectiveness = (effBeforeVal !== null && effAfterVal !== null && effBeforeVal !== 0) + ? Math.round(((effAfterVal - effBeforeVal) / effBeforeVal) * 10000) / 100 + : null; + + const record: MaintenanceRecord = { + id: `mh_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + project_id: task.project_id, + task_id: task.id, + action_type: task.action_type, + description: task.description, + completed_date: completedDate, + cost: updated.actual_cost ?? 0, + efficiency_before: effBeforeVal, + efficiency_after: effAfterVal, + effectiveness_pct: effectiveness, + notes: notes ?? "", + created_at: now(), + }; + + if (!historyStore.has(task.project_id)) { + historyStore.set(task.project_id, []); + } + historyStore.get(task.project_id)!.push(record); + + return { task: updated, record }; +} + +export function deleteTask(id: string): boolean { + return taskStore.delete(id); +} + +export function getCompletionStats(filter?: TaskFilter): CompletionStats { + const tasks = listTasks(filter); + const total = tasks.length; + const completed = tasks.filter((t) => t.status === "completed").length; + const cancelled = tasks.filter((t) => t.status === "cancelled").length; + const inProgress = tasks.filter((t) => t.status === "in_progress").length; + const scheduled = tasks.filter((t) => t.status === "scheduled").length; + + return { + total, + completed, + cancelled, + in_progress: inProgress, + scheduled, + completion_rate: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0, + total_estimated_cost: tasks.reduce((s, t) => s + t.estimated_cost, 0), + total_actual_cost: tasks.reduce((s, t) => s + (t.actual_cost ?? 0), 0), + }; +} + +export function getCalendar( + fromDate?: string, + toDate?: string, + projectId?: number, +): CalendarEntry[] { + const tasks = listTasks({ project_id: projectId, from_date: fromDate, to_date: toDate }); + const grouped = new Map(); + + for (const task of tasks) { + const date = task.scheduled_date; + if (!grouped.has(date)) grouped.set(date, []); + grouped.get(date)!.push(task); + } + + return Array.from(grouped.entries()) + .map(([date, tasks]) => ({ date, tasks })) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +export function getCalendarView( + view: CalendarView = "monthly", + referenceDate?: string, + projectId?: number, +): CalendarEntry[] { + const ref = referenceDate ? new Date(referenceDate) : new Date(); + let from: Date; + let to: Date; + + if (view === "daily") { + from = new Date(ref); + to = new Date(ref); + } else if (view === "weekly") { + const dayOfWeek = ref.getDay(); + from = new Date(ref); + from.setDate(ref.getDate() - dayOfWeek); + to = new Date(from); + to.setDate(from.getDate() + 6); + } else { + from = new Date(ref.getFullYear(), ref.getMonth(), 1); + to = new Date(ref.getFullYear(), ref.getMonth() + 1, 0); + } + + const fmt = (d: Date) => d.toISOString().split("T")[0]; + return getCalendar(fmt(from), fmt(to), projectId); +} + +export function recordManualMaintenance( + projectId: number, + actionType: string, + description: string, + cost: number, + efficiencyBefore?: number, + efficiencyAfter?: number, + notes?: string, +): MaintenanceRecord { + const effectiveness = (efficiencyBefore !== undefined && efficiencyAfter !== undefined && efficiencyBefore !== 0) + ? Math.round(((efficiencyAfter - efficiencyBefore) / efficiencyBefore) * 10000) / 100 + : null; + + const record: MaintenanceRecord = { + id: `mh_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + project_id: projectId, + task_id: null, + action_type: actionType, + description, + completed_date: todayDate(), + cost, + efficiency_before: efficiencyBefore ?? null, + efficiency_after: efficiencyAfter ?? null, + effectiveness_pct: effectiveness, + notes: notes ?? "", + created_at: now(), + }; + + if (!historyStore.has(projectId)) { + historyStore.set(projectId, []); + } + historyStore.get(projectId)!.push(record); + + return record; +} + +export function getMaintenanceHistory(projectId: number): MaintenanceRecord[] { + return historyStore.get(projectId) ?? []; +} + +export function listAllHistory(): MaintenanceRecord[] { + const all: MaintenanceRecord[] = []; + for (const records of historyStore.values()) { + all.push(...records); + } + return all.sort((a, b) => b.created_at.localeCompare(a.created_at)); +} + +export function clearAllData(): void { + taskStore.clear(); + historyStore.clear(); +} + +export function tasksToCsv(tasks: MaintenanceTask[]): string { + const header = "id,project_id,title,action_type,priority,status,scheduled_date,completed_date,assigned_to,estimated_cost,actual_cost,notes"; + const rows = tasks.map((t) => + `${t.id},${t.project_id},"${t.title}",${t.action_type},${t.priority},${t.status},${t.scheduled_date},${t.completed_date ?? ""},${t.assigned_to},${t.estimated_cost},${t.actual_cost ?? ""},"${t.notes}"`, + ); + return [header, ...rows].join("\n") + "\n"; +} + +export function historyToCsv(records: MaintenanceRecord[]): string { + const header = "id,project_id,task_id,action_type,description,completed_date,cost,efficiency_before,efficiency_after,effectiveness_pct,notes"; + const rows = records.map((r) => + `${r.id},${r.project_id},${r.task_id ?? ""},${r.action_type},"${r.description}",${r.completed_date},${r.cost},${r.efficiency_before ?? ""},${r.efficiency_after ?? ""},${r.effectiveness_pct ?? ""},"${r.notes}"`, + ); + return [header, ...rows].join("\n") + "\n"; +} diff --git a/src/lib/maintenance.ts b/src/lib/maintenance.ts new file mode 100644 index 0000000..b3fc318 --- /dev/null +++ b/src/lib/maintenance.ts @@ -0,0 +1,473 @@ +import { getHistoricalSolarData } from "./forecast"; +import { getPanelConfig } from "./panels"; + +export interface EfficiencyPoint { + timestamp: number; + efficiency_pct: number; +} + +export interface EfficiencyTrend { + direction: "improving" | "declining" | "stable"; + degradation_rate_pct_per_year: number; + slope: number; + intercept: number; + r_squared: number; + baseline_avg: number; + current_avg: number; + sample_count: number; +} + +export interface EfficiencyTrendAnalysis { + project_id: number; + trend: EfficiencyTrend; + monthly_averages: { month: string; efficiency_pct: number; point_count: number }[]; + weekly_averages: { week: string; efficiency_pct: number; point_count: number }[]; +} + +export interface FailurePrediction { + project_id: number; + current_efficiency: number; + critical_threshold: number; + estimated_hours_to_threshold: number; + estimated_days_to_threshold: number; + severity: "none" | "low" | "medium" | "high" | "critical"; + trend_quality: number; + confidence: number; + panel_type: string; +} + +export type MaintenanceActionType = "inspection" | "cleaning" | "repair" | "panel_replacement" | "inverter_service" | "wiring_check" | "structural_check"; + +export interface MaintenanceAction { + type: MaintenanceActionType; + priority: "low" | "medium" | "high" | "critical"; + description: string; + estimated_cost: number; + urgency_hours: number; +} + +export interface MaintenanceRecommendation { + project_id: number; + panel_type: string; + current_efficiency: number; + efficiency_rating: number; + shading_factor: number; + overall_health: "good" | "fair" | "poor" | "critical"; + actions: MaintenanceAction[]; + summary: string; +} + +export interface ScheduleEntry { + date: string; + actions: MaintenanceAction[]; + priority: "low" | "medium" | "high" | "critical"; +} + +export interface MaintenanceSchedule { + project_id: number; + generated_at: string; + schedule: ScheduleEntry[]; +} + +export interface FullMaintenanceReport { + project_id: number; + generated_at: string; + trend_analysis: EfficiencyTrendAnalysis; + failure_prediction: FailurePrediction; + recommendation: MaintenanceRecommendation; + schedule: MaintenanceSchedule; +} + +export type DegradationSeverity = "none" | "low" | "medium" | "high" | "critical"; + +const CRITICAL_EFFICIENCY: Record = { + monocrystalline: 70, + polycrystalline: 65, + "thin-film": 60, + bifacial: 72, +}; + +const PANEL_LIFESPAN: Record = { + monocrystalline: 87600, + polycrystalline: 78840, + "thin-film": 52560, + bifacial: 96360, +}; + +function round(n: number, d = 2): number { + const f = Math.pow(10, d); + return Math.round(n * f) / f; +} + +function mean(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((s, v) => s + v, 0) / values.length; +} + +function linearRegression(y: number[]): { slope: number; intercept: number; rSquared: number } { + const n = y.length; + if (n < 2) return { slope: 0, intercept: y[0] || 0, rSquared: 1 }; + + const xMean = (n - 1) / 2; + const yMean = mean(y); + let num = 0; + let den = 0; + for (let i = 0; i < n; i++) { + const x = i; + num += (x - xMean) * (y[i] - yMean); + den += (x - xMean) ** 2; + } + const slope = den !== 0 ? num / den : 0; + const intercept = yMean - slope * xMean; + + const yPred = y.map((_, i) => intercept + slope * i); + const ssRes = y.reduce((s, v, i) => s + (v - yPred[i]) ** 2, 0); + const ssTot = y.reduce((s, v) => s + (v - yMean) ** 2, 0); + const rSquared = ssTot > 0 ? 1 - ssRes / ssTot : 1; + + return { slope, intercept, rSquared }; +} + +function sampleEfficiencyHistory(projectId: number, hoursBack: number): EfficiencyPoint[] { + const now = Date.now(); + const points: EfficiencyPoint[] = []; + for (let i = hoursBack; i >= 1; i--) { + const ts = now - i * 3_600_000; + const solar = getHistoricalSolarData(projectId, ts); + points.push({ timestamp: ts, efficiency_pct: solar.efficiency_pct }); + } + return points; +} + +function getMonthLabel(ts: number): string { + const d = new Date(ts); + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + return `${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`; +} + +function getWeekLabel(ts: number): string { + const d = new Date(ts); + const startOfYear = new Date(d.getUTCFullYear(), 0, 1); + const diff = d.getTime() - startOfYear.getTime(); + const week = Math.ceil((diff / 86_400_000 + startOfYear.getUTCDay() + 1) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + +export function analyzeEfficiencyTrend(projectId: number, historyHours = 720): EfficiencyTrendAnalysis { + const points = sampleEfficiencyHistory(projectId, historyHours); + const values = points.map((p) => p.efficiency_pct); + + const { slope, intercept, rSquared } = linearRegression(values); + + const hourlySlope = slope; + const annualDegradation = hourlySlope * 8760; + + const half = Math.floor(values.length / 2); + const baselineAvg = half > 0 ? mean(values.slice(0, half)) : mean(values); + const currentAvg = mean(values.slice(half)); + + const diff = currentAvg - baselineAvg; + const direction: EfficiencyTrend["direction"] = + diff > 0.5 ? "improving" : diff < -0.5 ? "declining" : "stable"; + + const monthlyMap = new Map(); + const weeklyMap = new Map(); + + for (const point of points) { + const mLabel = getMonthLabel(point.timestamp); + const wLabel = getWeekLabel(point.timestamp); + + const mBucket = monthlyMap.get(mLabel) ?? { sum: 0, count: 0 }; + mBucket.sum += point.efficiency_pct; + mBucket.count++; + monthlyMap.set(mLabel, mBucket); + + const wBucket = weeklyMap.get(wLabel) ?? { sum: 0, count: 0 }; + wBucket.sum += point.efficiency_pct; + wBucket.count++; + weeklyMap.set(wLabel, wBucket); + } + + const monthlyAverages = Array.from(monthlyMap.entries()) + .map(([month, { sum, count }]) => ({ month, efficiency_pct: round(sum / count), point_count: count })) + .sort((a, b) => a.month.localeCompare(b.month)); + + const weeklyAverages = Array.from(weeklyMap.entries()) + .map(([week, { sum, count }]) => ({ week, efficiency_pct: round(sum / count), point_count: count })) + .sort((a, b) => a.week.localeCompare(b.week)); + + return { + project_id: projectId, + trend: { + direction, + degradation_rate_pct_per_year: round(Math.abs(annualDegradation), 4), + slope: round(slope, 6), + intercept: round(intercept, 2), + r_squared: round(Math.min(1, Math.max(0, rSquared)), 4), + baseline_avg: round(baselineAvg, 2), + current_avg: round(currentAvg, 2), + sample_count: values.length, + }, + monthly_averages: monthlyAverages, + weekly_averages: weeklyAverages, + }; +} + +export function predictFailure(projectId: number, historyHours = 720): FailurePrediction { + const analysis = analyzeEfficiencyTrend(projectId, historyHours); + const points = sampleEfficiencyHistory(projectId, Math.min(historyHours, 24)); + const currentEfficiency = points.length > 0 ? points[points.length - 1].efficiency_pct : 0; + + const config = getPanelConfig(projectId); + const panelType = config?.panel_type ?? "monocrystalline"; + const criticalThreshold = CRITICAL_EFFICIENCY[panelType] ?? 70; + + const slope = analysis.trend.slope; + const projectedHours = slope < 0 + ? Math.max(0, (criticalThreshold - currentEfficiency) / slope) + : Infinity; + + const rSq = analysis.trend.r_squared; + const sampleCount = analysis.trend.sample_count; + const trendQuality = Math.min(1, rSq * sampleCount / 100); + const confidence = round(Math.min(1, trendQuality), 4); + + let severity: DegradationSeverity = "none"; + if (Number.isFinite(projectedHours)) { + if (projectedHours < 720) severity = "critical"; + else if (projectedHours < 2160) severity = "high"; + else if (projectedHours < 4320) severity = "medium"; + else if (projectedHours < 8760) severity = "low"; + } else { + const diff = currentEfficiency - criticalThreshold; + if (diff < 5) severity = "high"; + else if (diff < 10) severity = "medium"; + else if (diff < 15) severity = "low"; + } + + if (currentEfficiency < criticalThreshold) { + severity = "critical"; + } + + return { + project_id: projectId, + current_efficiency: round(currentEfficiency, 2), + critical_threshold: criticalThreshold, + estimated_hours_to_threshold: Number.isFinite(projectedHours) ? Math.round(projectedHours) : -1, + estimated_days_to_threshold: Number.isFinite(projectedHours) ? Math.round(projectedHours / 24) : -1, + severity, + trend_quality: round(trendQuality, 4), + confidence, + panel_type: panelType, + }; +} + +function classifyHealth(eff: number, threshold: number): "good" | "fair" | "poor" | "critical" { + const ratio = eff / threshold; + if (ratio >= 1.1) return "good"; + if (ratio >= 0.95) return "fair"; + if (ratio >= 0.85) return "poor"; + return "critical"; +} + +export function recommendMaintenance(projectId: number, historyHours = 720): MaintenanceRecommendation { + const prediction = predictFailure(projectId, historyHours); + const config = getPanelConfig(projectId); + const panelType = config?.panel_type ?? "monocrystalline"; + const efficiencyRating = config?.efficiency_rating ?? 18; + const shadingFactor = config?.shading_factor ?? 0; + + const eff = prediction.current_efficiency; + const threshold = prediction.critical_threshold; + const health = classifyHealth(eff, threshold); + const actions: MaintenanceAction[] = []; + + if (shadingFactor > 0.2) { + actions.push({ + type: "cleaning", + priority: shadingFactor > 0.4 ? "high" : "medium", + description: `Vegetation or debris shading detected (factor: ${(shadingFactor * 100).toFixed(0)}%). Trim vegetation and clean panels.`, + estimated_cost: Math.round(500 + shadingFactor * 2000), + urgency_hours: shadingFactor > 0.4 ? 168 : 720, + }); + } + + if (prediction.severity === "critical" || health === "critical") { + actions.push({ + type: "panel_replacement", + priority: "critical", + description: `Efficiency of ${eff}% is below critical threshold of ${threshold}% for ${panelType} panels. Immediate replacement recommended.`, + estimated_cost: 15000, + urgency_hours: 48, + }); + } + + if (prediction.severity === "high" || health === "poor") { + actions.push({ + type: "repair", + priority: "high", + description: `Degradation trend indicates efficiency will reach ${threshold}% in ~${prediction.estimated_days_to_threshold} days. Schedule repair and detailed inspection.`, + estimated_cost: 5000, + urgency_hours: Math.max(24, Math.min(prediction.estimated_hours_to_threshold * 0.5, 2160)), + }); + + if (!actions.some((a) => a.type === "cleaning")) { + actions.push({ + type: "cleaning", + priority: "medium", + description: "Routine cleaning to maximize light absorption and slow degradation.", + estimated_cost: 800, + urgency_hours: 720, + }); + } + } + + if (prediction.severity === "medium") { + actions.push({ + type: "inspection", + priority: "medium", + description: `Moderate degradation detected. Efficiency trending at ${eff}% (threshold: ${threshold}%). Perform detailed inspection.`, + estimated_cost: 1500, + urgency_hours: Math.min(prediction.estimated_hours_to_threshold * 0.3, 4320), + }); + + actions.push({ + type: "wiring_check", + priority: "low", + description: "Check wiring and connections for corrosion or damage as part of preventive maintenance.", + estimated_cost: 600, + urgency_hours: 2160, + }); + } + + if (prediction.severity === "low" || prediction.severity === "none") { + if (prediction.estimated_hours_to_threshold > 0 && Number.isFinite(prediction.estimated_hours_to_threshold)) { + actions.push({ + type: "inspection", + priority: "low", + description: `Routine inspection. Efficiency at ${eff}%, threshold at ${threshold}%. Next check recommended within ${Math.round(prediction.estimated_hours_to_threshold / 2 / 24)} days.`, + estimated_cost: 1000, + urgency_hours: Math.min(prediction.estimated_hours_to_threshold * 0.5, 8760), + }); + } + + if (!actions.some((a) => a.type === "cleaning")) { + actions.push({ + type: "cleaning", + priority: "low", + description: "Scheduled routine cleaning to maintain optimal efficiency.", + estimated_cost: 500, + urgency_hours: 2160, + }); + } + } + + actions.push({ + type: "inverter_service", + priority: "low", + description: "Routine inverter performance check and cooling system service.", + estimated_cost: 1200, + urgency_hours: 4320, + }); + + if (health !== "good") { + actions.push({ + type: "structural_check", + priority: health === "critical" ? "high" : "low", + description: "Inspect mounting structures, racking, and tracking systems for stability and corrosion.", + estimated_cost: 2000, + urgency_hours: health === "critical" ? 168 : 4320, + }); + } + + const maxPriority = actions.reduce((max, a) => { + const order = { critical: 4, high: 3, medium: 2, low: 1 }; + return order[a.priority] > order[max] ? a.priority : max; + }, "low" as MaintenanceAction["priority"]); + + const summary = health === "critical" + ? `CRITICAL: Project ${projectId} requires immediate maintenance. Efficiency at ${eff}% (below ${threshold}% threshold for ${panelType}).` + : health === "poor" + ? `WARNING: Project ${projectId} shows significant degradation. Schedule maintenance within ${prediction.estimated_days_to_threshold} days.` + : health === "fair" + ? `ATTENTION: Project ${projectId} efficiency (${eff}%) approaching threshold (${threshold}%). Preventive maintenance recommended.` + : `OK: Project ${projectId} is operating efficiently at ${eff}% (threshold: ${threshold}%). Routine maintenance only.`; + + return { + project_id: projectId, + panel_type: panelType, + current_efficiency: eff, + efficiency_rating: efficiencyRating, + shading_factor: shadingFactor, + overall_health: health, + actions: actions.sort((a, b) => { + const order = { critical: 0, high: 1, medium: 2, low: 3 }; + return order[a.priority] - order[b.priority]; + }), + summary, + }; +} + +export function generateSchedule(projectId: number, historyHours = 720): MaintenanceSchedule { + const recommendation = recommendMaintenance(projectId, historyHours); + const now = Date.now(); + const schedule: ScheduleEntry[] = []; + + for (const action of recommendation.actions) { + const date = new Date(now + action.urgency_hours * 3_600_000); + const existing = schedule.find((s) => s.date === date.toISOString().split("T")[0]); + if (existing) { + existing.actions.push(action); + const order = { critical: 0, high: 1, medium: 2, low: 3 }; + if (order[action.priority] < order[existing.priority]) { + existing.priority = action.priority; + } + } else { + schedule.push({ + date: date.toISOString().split("T")[0], + actions: [action], + priority: action.priority, + }); + } + } + + schedule.sort((a, b) => a.date.localeCompare(b.date)); + + return { + project_id: projectId, + generated_at: new Date().toISOString(), + schedule, + }; +} + +export function generateFullReport(projectId: number, historyHours = 720): FullMaintenanceReport { + return { + project_id: projectId, + generated_at: new Date().toISOString(), + trend_analysis: analyzeEfficiencyTrend(projectId, historyHours), + failure_prediction: predictFailure(projectId, historyHours), + recommendation: recommendMaintenance(projectId, historyHours), + schedule: generateSchedule(projectId, historyHours), + }; +} + +export function recommendationToCsv(recommendation: MaintenanceRecommendation): string { + const header = "project_id,panel_type,current_efficiency,efficiency_rating,shading_factor,overall_health,action_type,priority,description,estimated_cost,urgency_hours,summary"; + const rows = recommendation.actions.map((a) => + `${recommendation.project_id},${recommendation.panel_type},${recommendation.current_efficiency},${recommendation.efficiency_rating},${recommendation.shading_factor},${recommendation.overall_health},${a.type},${a.priority},"${a.description}",${a.estimated_cost},${a.urgency_hours},"${recommendation.summary}"`, + ); + return [header, ...rows].join("\n") + "\n"; +} + +export function scheduleToCsv(schedule: MaintenanceSchedule): string { + const header = "project_id,date,priority,action_type,description,estimated_cost,urgency_hours"; + const rows: string[] = []; + for (const entry of schedule.schedule) { + for (const action of entry.actions) { + rows.push( + `${schedule.project_id},${entry.date},${entry.priority},${action.type},"${action.description}",${action.estimated_cost},${action.urgency_hours}`, + ); + } + } + return [header, ...rows].join("\n") + "\n"; +} diff --git a/src/routes/financial.ts b/src/routes/financial.ts new file mode 100644 index 0000000..4e1c983 --- /dev/null +++ b/src/routes/financial.ts @@ -0,0 +1,120 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { getSolarData } from "./iot"; +import { getTotalProjects } from "../lib/registry"; +import { + createDefaultFinancialInput, + calculateCostBenefit, + calculatePaybackPeriod, + calculateNPV, + performSensitivityAnalysis, + compareROI, +} from "../lib/financial"; +import { badRequest, parseProjectId, parseOptionalInt } from "../middleware/errors"; + +const router = Router(); + +function parseOptionalFloat(raw: string | undefined, field: string): number | undefined { + if (raw === undefined) return undefined; + const n = Number(raw); + if (!isFinite(n)) throw badRequest(`${field} must be a number`); + return n; +} + +function buildFinancialInput(projectId: number, req: Request) { + const solar = getSolarData(projectId); + const capacityKw = parseOptionalFloat(req.query.capacity_kw as string, "capacity_kw") ?? solar.max_power_kw; + const efficiencyPct = parseOptionalFloat(req.query.efficiency_pct as string, "efficiency_pct") ?? solar.efficiency_pct; + + const overrides: Record = { + installation_cost: parseOptionalFloat(req.query.installation_cost as string, "installation_cost"), + annual_maintenance_cost: parseOptionalFloat(req.query.annual_maintenance_cost as string, "annual_maintenance_cost"), + annual_energy_output_kwh: parseOptionalFloat(req.query.annual_energy_output_kwh as string, "annual_energy_output_kwh"), + electricity_price_per_kwh: parseOptionalFloat(req.query.electricity_price_per_kwh as string, "electricity_price_per_kwh"), + degradation_rate: parseOptionalFloat(req.query.degradation_rate as string, "degradation_rate"), + discount_rate: parseOptionalFloat(req.query.discount_rate as string, "discount_rate"), + inflation_rate: parseOptionalFloat(req.query.inflation_rate as string, "inflation_rate"), + project_lifetime_years: parseOptionalFloat(req.query.project_lifetime_years as string, "project_lifetime_years"), + tax_incentives: parseOptionalFloat(req.query.tax_incentives as string, "tax_incentives"), + salvage_value: parseOptionalFloat(req.query.salvage_value as string, "salvage_value"), + capacity_factor: parseOptionalFloat(req.query.capacity_factor as string, "capacity_factor"), + }; + + const filteredOverrides: Partial = {}; + for (const [key, value] of Object.entries(overrides)) { + if (value !== undefined) { + (filteredOverrides as Record)[key] = value; + } + } + + return createDefaultFinancialInput(capacityKw, efficiencyPct, filteredOverrides); +} + +router.get("/cost-benefit/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const input = buildFinancialInput(id, req); + const result = calculateCostBenefit(input); + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/payback/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const input = buildFinancialInput(id, req); + const result = calculatePaybackPeriod(input); + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/npv/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const input = buildFinancialInput(id, req); + const result = calculateNPV(input); + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/sensitivity/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const input = buildFinancialInput(id, req); + const result = performSensitivityAnalysis(input); + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/roi-comparison", async (req: Request, res: Response, next: NextFunction) => { + try { + const idsRaw = req.query.ids as string | undefined; + if (!idsRaw) throw badRequest("ids query parameter is required (comma-separated)"); + const ids = idsRaw.split(",").map((s) => { + const n = Number(s.trim()); + if (!Number.isInteger(n) || n < 1) throw badRequest(`Invalid project id "${s.trim()}"`); + return n; + }); + if (ids.length === 0) throw badRequest("At least one project id is required"); + if (ids.length > 20) throw badRequest("Cannot compare more than 20 projects at once"); + + const projects = ids.map((id) => ({ + project_id: id, + input: buildFinancialInput(id, req), + })); + + const result = compareROI(projects); + res.json(result); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/src/routes/forecast.ts b/src/routes/forecast.ts new file mode 100644 index 0000000..36d83dc --- /dev/null +++ b/src/routes/forecast.ts @@ -0,0 +1,133 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { + forecastProject, + forecastWeatherAdjusted, + analyzeSeasonalPatterns, + evaluateForecastAccuracy, + forecastToCsv, + seasonalPatternsToCsv, + accuracyToCsv, + getValidMethods, + isMethodValid, +} from "../lib/forecast"; +import { badRequest, parseProjectId, parseOptionalInt } from "../middleware/errors"; + +const router = Router(); + +function parseOptionalFloat(raw: string | undefined, field: string): number | undefined { + if (raw === undefined) return undefined; + const n = Number(raw); + if (!isFinite(n)) throw badRequest(`${field} must be a number`); + return n; +} + +function parseForecastField(raw: string | undefined): "power_output_kw" | "efficiency_pct" { + if (raw === "efficiency_pct") return "efficiency_pct"; + return "power_output_kw"; +} + +router.get("/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const horizon = Math.min(Math.max(parseOptionalInt(req.query.horizon as string, "horizon", 24), 1), 8760); + const field = parseForecastField(req.query.field as string | undefined); + const method = (req.query.method as string) ?? "exponential_smoothing"; + const historyHours = Math.min(Math.max(parseOptionalInt(req.query.history_hours as string, "history_hours", 168), 4), 8760); + + if (!isMethodValid(method)) { + throw badRequest(`Invalid method "${method}". Valid methods: ${getValidMethods().join(", ")}`); + } + + const result = forecastProject(id, field, horizon, method, historyHours); + + const format = req.query.format === "csv" ? "csv" : "json"; + if (format === "csv") { + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="forecast-${id}.csv"`); + res.send(forecastToCsv(result)); + return; + } + + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/weather-adjusted/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const horizon = Math.min(Math.max(parseOptionalInt(req.query.horizon as string, "horizon", 24), 1), 8760); + const historyHours = Math.min(Math.max(parseOptionalInt(req.query.history_hours as string, "history_hours", 168), 4), 8760); + + const result = forecastWeatherAdjusted(id, horizon, historyHours); + + const format = req.query.format === "csv" ? "csv" : "json"; + if (format === "csv") { + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="forecast-weather-adjusted-${id}.csv"`); + res.send(forecastToCsv(result)); + return; + } + + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/seasonal/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const field = parseForecastField(req.query.field as string | undefined); + const historyHours = Math.min(Math.max(parseOptionalInt(req.query.history_hours as string, "history_hours", 720), 24), 8760); + + const result = analyzeSeasonalPatterns(id, field, historyHours); + + const format = req.query.format === "csv" ? "csv" : "json"; + if (format === "csv") { + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="seasonal-patterns-${id}.csv"`); + res.send(seasonalPatternsToCsv(id, result)); + return; + } + + res.json({ project_id: id, field, ...result }); + } catch (error) { + next(error); + } +}); + +router.get("/accuracy/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const field = parseForecastField(req.query.field as string | undefined); + const method = (req.query.method as string) ?? "exponential_smoothing"; + const testHours = Math.min(Math.max(parseOptionalInt(req.query.test_hours as string, "test_hours", 24), 1), 8760); + const trainingHours = Math.min(Math.max(parseOptionalInt(req.query.training_hours as string, "training_hours", 168), 4), 8760); + + if (!isMethodValid(method)) { + throw badRequest(`Invalid method "${method}". Valid methods: ${getValidMethods().join(", ")}`); + } + + const result = evaluateForecastAccuracy(id, field, method, testHours, trainingHours); + + const format = req.query.format === "csv" ? "csv" : "json"; + if (format === "csv") { + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="forecast-accuracy-${id}.csv"`); + res.send(accuracyToCsv(result)); + return; + } + + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/methods/available", (_req: Request, res: Response) => { + res.json({ methods: getValidMethods() }); +}); + +export default router; diff --git a/src/routes/maintenance.ts b/src/routes/maintenance.ts new file mode 100644 index 0000000..2409b74 --- /dev/null +++ b/src/routes/maintenance.ts @@ -0,0 +1,324 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { + analyzeEfficiencyTrend, + predictFailure, + recommendMaintenance, + generateSchedule, + generateFullReport, + recommendationToCsv, + scheduleToCsv, +} from "../lib/maintenance"; +import { + createTask, + getTask, + listTasks, + updateTask, + completeTask, + deleteTask, + getCalendar, + getCalendarView, + getMaintenanceHistory, + recordManualMaintenance, + getCompletionStats, + tasksToCsv, + historyToCsv, +} from "../lib/maintenance-tracking"; +import { badRequest, parseProjectId, parseOptionalInt } from "../middleware/errors"; + +const router = Router(); + +router.get("/:id/trend", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const historyHours = Math.min(Math.max(parseOptionalInt(req.query.history_hours as string, "history_hours", 720), 24), 8760); + const result = analyzeEfficiencyTrend(id, historyHours); + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/:id/failure-prediction", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const historyHours = Math.min(Math.max(parseOptionalInt(req.query.history_hours as string, "history_hours", 720), 24), 8760); + const result = predictFailure(id, historyHours); + + const format = req.query.format === "csv" ? "csv" : "json"; + if (format === "csv") { + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="failure-prediction-${id}.csv"`); + const header = "project_id,current_efficiency,critical_threshold,estimated_hours_to_threshold,estimated_days_to_threshold,severity,trend_quality,confidence,panel_type"; + const row = `${result.project_id},${result.current_efficiency},${result.critical_threshold},${result.estimated_hours_to_threshold},${result.estimated_days_to_threshold},${result.severity},${result.trend_quality},${result.confidence},${result.panel_type}`; + res.send([header, row].join("\n") + "\n"); + return; + } + + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/:id/recommendation", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const historyHours = Math.min(Math.max(parseOptionalInt(req.query.history_hours as string, "history_hours", 720), 24), 8760); + const result = recommendMaintenance(id, historyHours); + + const format = req.query.format === "csv" ? "csv" : "json"; + if (format === "csv") { + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="maintenance-recommendation-${id}.csv"`); + res.send(recommendationToCsv(result)); + return; + } + + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/:id/schedule", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const historyHours = Math.min(Math.max(parseOptionalInt(req.query.history_hours as string, "history_hours", 720), 24), 8760); + const result = generateSchedule(id, historyHours); + + const format = req.query.format === "csv" ? "csv" : "json"; + if (format === "csv") { + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="maintenance-schedule-${id}.csv"`); + res.send(scheduleToCsv(result)); + return; + } + + res.json(result); + } catch (error) { + next(error); + } +}); + +router.get("/:id/full-report", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const historyHours = Math.min(Math.max(parseOptionalInt(req.query.history_hours as string, "history_hours", 720), 24), 8760); + const result = generateFullReport(id, historyHours); + res.json(result); + } catch (error) { + next(error); + } +}); + +// ── Task / work order management ───────────────────────────────────────────── + +router.post("/tasks", (req: Request, res: Response, next: NextFunction) => { + try { + const task = createTask(req.body ?? {}); + res.status(201).json(task); + } catch (error) { + next(error instanceof Error ? badRequest(error.message) : error); + } +}); + +router.get("/tasks", (req: Request, res: Response, next: NextFunction) => { + try { + const filter: Record = {}; + if (req.query.project_id) filter.project_id = Number(req.query.project_id); + if (req.query.status) filter.status = req.query.status; + if (req.query.priority) filter.priority = req.query.priority; + if (req.query.from_date) filter.from_date = req.query.from_date; + if (req.query.to_date) filter.to_date = req.query.to_date; + + const tasks = listTasks(filter as any); + + const format = req.query.format === "csv" ? "csv" : "json"; + if (format === "csv") { + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", 'attachment; filename="maintenance-tasks.csv"'); + res.send(tasksToCsv(tasks)); + return; + } + + res.json({ tasks, count: tasks.length }); + } catch (error) { + next(error); + } +}); + +router.post("/tasks/generate/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const historyHours = Math.min(Math.max(parseOptionalInt(req.query.history_hours as string, "history_hours", 720), 24), 8760); + + const recommendation = recommendMaintenance(id, historyHours); + const schedule = generateSchedule(id, historyHours); + + const tasks = schedule.schedule.map((entry) => { + return entry.actions.map((action) => + createTask({ + project_id: id, + title: `${action.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} - Project ${id}`, + description: action.description, + action_type: action.type, + priority: action.priority, + scheduled_date: entry.date, + assigned_to: "unassigned", + estimated_cost: action.estimated_cost, + }), + ); + }).flat(); + + res.status(201).json({ tasks, count: tasks.length }); + } catch (error) { + next(error); + } +}); + +router.get("/tasks/:taskId", (req: Request, res: Response, next: NextFunction) => { + try { + const task = getTask(req.params.taskId as string); + if (!task) { + res.status(404).json({ error: "not_found", message: "Task not found" }); + return; + } + res.json(task); + } catch (error) { + next(error); + } +}); + +router.patch("/tasks/:taskId", (req: Request, res: Response, next: NextFunction) => { + try { + const task = updateTask(req.params.taskId as string, req.body ?? {}); + res.json(task); + } catch (error) { + next(error instanceof Error ? badRequest(error.message) : error); + } +}); + +router.post("/tasks/:taskId/complete", (req: Request, res: Response, next: NextFunction) => { + try { + const body = req.body as Record; + const result = completeTask( + req.params.taskId as string, + body.actual_cost as number | undefined, + body.notes as string | undefined, + body.efficiency_before as number | undefined, + body.efficiency_after as number | undefined, + ); + res.json(result); + } catch (error) { + next(error instanceof Error ? badRequest(error.message) : error); + } +}); + +router.delete("/tasks/:taskId", (req: Request, res: Response, next: NextFunction) => { + try { + const removed = deleteTask(req.params.taskId as string); + if (!removed) { + res.status(404).json({ error: "not_found", message: "Task not found" }); + return; + } + res.json({ removed: true }); + } catch (error) { + next(error); + } +}); + +// ── Calendar ───────────────────────────────────────────────────────────────── + +router.get("/calendar", (req: Request, res: Response, next: NextFunction) => { + try { + const view = (req.query.view as string) ?? "monthly"; + const refDate = req.query.date as string | undefined; + const projectId = req.query.project_id ? Number(req.query.project_id) : undefined; + + if (!["daily", "weekly", "monthly"].includes(view)) { + throw badRequest('view must be one of: daily, weekly, monthly'); + } + + const entries = getCalendarView(view as any, refDate, projectId); + res.json({ view, entries, count: entries.reduce((s, e) => s + e.tasks.length, 0) }); + } catch (error) { + next(error); + } +}); + +router.get("/calendar/range", (req: Request, res: Response, next: NextFunction) => { + try { + const from = req.query.from as string; + const to = req.query.to as string; + const projectId = req.query.project_id ? Number(req.query.project_id) : undefined; + + if (!from || !to) throw badRequest("from and to query parameters are required (YYYY-MM-DD)"); + + const entries = getCalendar(from, to, projectId); + res.json({ from, to, entries, count: entries.reduce((s, e) => s + e.tasks.length, 0) }); + } catch (error) { + next(error); + } +}); + +// ── Maintenance history ────────────────────────────────────────────────────── + +router.get("/history/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const records = getMaintenanceHistory(id); + + const format = req.query.format === "csv" ? "csv" : "json"; + if (format === "csv") { + res.setHeader("Content-Type", "text/csv"); + res.setHeader("Content-Disposition", `attachment; filename="maintenance-history-${id}.csv"`); + res.send(historyToCsv(records)); + return; + } + + res.json({ project_id: id, count: records.length, records }); + } catch (error) { + next(error); + } +}); + +router.post("/history/:id", (req: Request, res: Response, next: NextFunction) => { + try { + const id = parseProjectId(req.params.id, "project id"); + const body = req.body as Record; + + if (typeof body.action_type !== "string" || body.action_type.trim().length === 0) throw badRequest("action_type is required"); + if (typeof body.description !== "string" || body.description.trim().length === 0) throw badRequest("description is required"); + if (typeof body.cost !== "number" || body.cost < 0) throw badRequest("cost must be a non-negative number"); + + const record = recordManualMaintenance( + id, + body.action_type, + body.description, + body.cost, + body.efficiency_before as number | undefined, + body.efficiency_after as number | undefined, + body.notes as string | undefined, + ); + + res.status(201).json(record); + } catch (error) { + next(error); + } +}); + +// ── Completion stats ───────────────────────────────────────────────────────── + +router.get("/stats", (req: Request, res: Response, next: NextFunction) => { + try { + const filter: Record = {}; + if (req.query.project_id) filter.project_id = Number(req.query.project_id); + + const stats = getCompletionStats(filter as any); + res.json(stats); + } catch (error) { + next(error); + } +}); + +export default router;