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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions app/api/v1/power-plants/[slug]/substations/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* GET /api/v1/power-plants/[slug]/substations
*
* List substations connected to a specific power plant (interconnection points).
* Uses the power_plant_interconnections join table to find nearest substations.
*/

import { neon } from "@neondatabase/serverless";
import { notFound } from "next/navigation";

interface InterconnectionResult {
substationId: string;
substationName: string;
substationType: string;
voltageClass: string;
owner: string;
distanceKm: number;
isPrimary: boolean;
}

export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const databaseUrl = process.env.DATABASE_URL;

if (!databaseUrl) {
return Response.json({ error: "Database not configured" }, { status: 503 });
}

try {
const sql = neon(databaseUrl);

// 1. Find power plant by slug
const plantResult = await sql`
SELECT id FROM power_plants
WHERE slug = ${slug} AND deleted_at IS NULL
LIMIT 1
`;

if (plantResult.length === 0) {
return notFound();
}

const plantId = (plantResult[0] as { id: string }).id;

// 2. Fetch interconnected substations via join table
const substations = (await sql`
SELECT
s.id as "substationId",
s.name as "substationName",
s.substation_type as "substationType",
s.voltage_class as "voltageClass",
s.owner_name as "owner",
(ppi.distance_meters / 1000.0) as "distanceKm",
ppi.is_primary as "isPrimary"
FROM power_plant_interconnections ppi
JOIN substations s ON ppi.substation_id = s.id
WHERE
ppi.power_plant_id = ${plantId}
AND s.deleted_at IS NULL
ORDER BY ppi.is_primary DESC, ppi.distance_meters ASC
`) as unknown as InterconnectionResult[];

// 3. Separate primary from secondaries
const primary = substations.find((s) => s.isPrimary);
const secondaries = substations.filter((s) => !s.isPrimary);

return Response.json(
{
power_plant_id: plantId,
primary_interconnection: primary || null,
secondary_interconnections: secondaries,
total_candidate_substations: substations.length,
distance_range_km: {
min: substations.length > 0 ? Math.min(...substations.map((s) => s.distanceKm)) : null,
max: substations.length > 0 ? Math.max(...substations.map((s) => s.distanceKm)) : null,
},
},
{
headers: {
"Cache-Control": "public, max-age=3600",
"Content-Type": "application/json",
},
}
);
} catch (error) {
console.error("Error fetching substations for power plant:", error);
return Response.json({ error: "Failed to fetch interconnected substations" }, { status: 500 });
}
}
92 changes: 92 additions & 0 deletions app/api/v1/substations/[slug]/transmission-lines/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* GET /api/v1/substations/[slug]/transmission-lines
*
* List transmission lines connected to a specific substation.
* Uses the transmission_line_endpoints join table to find all connected lines.
*/

import { neon } from "@neondatabase/serverless";
import { notFound } from "next/navigation";

interface ConnectedLineResult {
lineId: string;
lineName: string;
lineVoltageClass: string;
lineVoltage: number | null;
lineStatus: string;
lineOwner: string;
role: "from" | "to";
matchConfidence: number | null;
}

export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const databaseUrl = process.env.DATABASE_URL;

if (!databaseUrl) {
return Response.json({ error: "Database not configured" }, { status: 503 });
}

try {
const sql = neon(databaseUrl);

// 1. Find substation by slug
const substationResult = await sql`
SELECT id FROM substations
WHERE slug = ${slug} AND deleted_at IS NULL
LIMIT 1
`;

if (substationResult.length === 0) {
return notFound();
}

const substationId = (substationResult[0] as { id: string }).id;

// 2. Fetch connected transmission lines via join table
const lines = (await sql`
SELECT
tl.id as "lineId",
tl.name as "lineName",
tl.voltage_class as "lineVoltageClass",
tl.voltage as "lineVoltage",
tl.status as "lineStatus",
tl.owner as "lineOwner",
tle.role,
tle.match_confidence as "matchConfidence"
FROM transmission_line_endpoints tle
JOIN transmission_lines tl ON tle.transmission_line_id = tl.id
WHERE
tle.substation_id = ${substationId}
AND tl.deleted_at IS NULL
ORDER BY tle.role, tl.name
`) as unknown as ConnectedLineResult[];

// 3. Group by role
const fromLines = lines.filter((l) => l.role === "from");
const toLines = lines.filter((l) => l.role === "to");

return Response.json(
{
substation_id: substationId,
from_lines: fromLines,
to_lines: toLines,
total_connected: lines.length,
confidence_distribution: {
high: lines.filter((l) => (l.matchConfidence ?? 0) >= 0.9).length,
medium: lines.filter((l) => (l.matchConfidence ?? 0) >= 0.75 && (l.matchConfidence ?? 0) < 0.9).length,
low: lines.filter((l) => (l.matchConfidence ?? 0) < 0.75).length,
},
},
{
headers: {
"Cache-Control": "public, max-age=3600",
"Content-Type": "application/json",
},
}
);
} catch (error) {
console.error("Error fetching transmission lines for substation:", error);
return Response.json({ error: "Failed to fetch transmission lines" }, { status: 500 });
}
}
20 changes: 20 additions & 0 deletions drizzle/0005_transmission_line_endpoints.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Migration: Add transmission_line_endpoints join table
-- Links transmission lines to substations at their endpoints
-- Replaces fuzzy sub1/sub2 strings with formal foreign keys

CREATE TABLE IF NOT EXISTS transmission_line_endpoints (
transmission_line_id TEXT NOT NULL,
substation_id TEXT NOT NULL,
role TEXT NOT NULL, -- 'from' | 'to'
match_confidence DOUBLE PRECISION, -- 0..1, NULL if manual/verified

PRIMARY KEY (transmission_line_id, substation_id, role),
FOREIGN KEY (transmission_line_id) REFERENCES transmission_lines(id) ON DELETE CASCADE,
FOREIGN KEY (substation_id) REFERENCES substations(id) ON DELETE CASCADE
);

-- Indexes for common queries
CREATE INDEX idx_tl_endpoints_tl_id ON transmission_line_endpoints(transmission_line_id);
CREATE INDEX idx_tl_endpoints_sub_id ON transmission_line_endpoints(substation_id);
CREATE INDEX idx_tl_endpoints_role ON transmission_line_endpoints(role);
CREATE INDEX idx_tl_endpoints_confidence ON transmission_line_endpoints(match_confidence) WHERE match_confidence < 0.9;
19 changes: 19 additions & 0 deletions drizzle/0006_power_plant_interconnections.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Migration: Add power_plant_interconnections join table
-- Links power plants to their nearest substation(s) for interconnection analysis

CREATE TABLE IF NOT EXISTS power_plant_interconnections (
power_plant_id TEXT NOT NULL,
substation_id TEXT NOT NULL,
distance_meters DOUBLE PRECISION NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT false,

PRIMARY KEY (power_plant_id, substation_id),
FOREIGN KEY (power_plant_id) REFERENCES power_plants(id) ON DELETE CASCADE,
FOREIGN KEY (substation_id) REFERENCES substations(id) ON DELETE CASCADE
);

-- Indexes for common queries
CREATE INDEX idx_pp_intercon_plant_id ON power_plant_interconnections(power_plant_id);
CREATE INDEX idx_pp_intercon_sub_id ON power_plant_interconnections(substation_id);
CREATE INDEX idx_pp_intercon_primary ON power_plant_interconnections(power_plant_id) WHERE is_primary = true;
CREATE INDEX idx_pp_intercon_distance ON power_plant_interconnections(distance_meters);
33 changes: 33 additions & 0 deletions lib/db/schema/power-plant-interconnections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { boolean, doublePrecision, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import { powerPlants } from "./power-plants";
import { substations } from "./substations";

/**
* Power Plant Interconnections
*
* Join table linking power plants to their nearest substation(s).
* Computed via spatial distance (ST_Distance in PostGIS) to identify
* the likely interconnection point on the grid.
*
* Typically, a power plant has 1 primary interconnection (isPrimary=true)
* and may have 0+ secondary candidates within a configured radius.
*
* Distance is stored in meters for precise filtering.
*/
export const powerPlantInterconnections = pgTable(
"power_plant_interconnections",
{
powerPlantId: text("power_plant_id")
.notNull()
.references(() => powerPlants.id, { onDelete: "cascade" }),
substationId: text("substation_id")
.notNull()
.references(() => substations.id, { onDelete: "cascade" }),
distanceMeters: doublePrecision("distance_meters").notNull(),
isPrimary: boolean("is_primary").notNull().default(false),
},
(table) => [primaryKey({ columns: [table.powerPlantId, table.substationId] })]
);

export type PowerPlantInterconnectionSelect = typeof powerPlantInterconnections.$inferSelect;
export type PowerPlantInterconnectionInsert = typeof powerPlantInterconnections.$inferInsert;
36 changes: 36 additions & 0 deletions lib/db/schema/transmission-line-endpoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { doublePrecision, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import { substations } from "./substations";
import { transmissionLines } from "./transmission-lines";

/**
* Transmission Line Endpoints
*
* Join table linking transmission lines to substations at their endpoints.
* Replaces the fuzzy `transmission_lines.sub1` / `sub2` string references
* with formal foreign keys.
*
* Each transmission line has exactly 2 endpoints (from/to).
* `matchConfidence` (0..1) indicates the quality of the fuzzy name match
* used to populate this join — used for filtering and community review workflow.
*/
export const transmissionLineEndpoints = pgTable(
"transmission_line_endpoints",
{
transmissionLineId: text("transmission_line_id")
.notNull()
.references(() => transmissionLines.id, { onDelete: "cascade" }),
substationId: text("substation_id")
.notNull()
.references(() => substations.id, { onDelete: "cascade" }),
role: text("role").notNull(), // 'from' | 'to'
matchConfidence: doublePrecision("match_confidence"), // 0..1, NULL if manual / verified
},
(table) => [
primaryKey({
columns: [table.transmissionLineId, table.substationId, table.role],
}),
]
);

export type TransmissionLineEndpointSelect = typeof transmissionLineEndpoints.$inferSelect;
export type TransmissionLineEndpointInsert = typeof transmissionLineEndpoints.$inferInsert;
Loading
Loading