[예측] - OpenDrift Python API 서버 및 스크립트 추가 (prediction/opendrift/) - 시뮬레이션 상태 폴링 훅(useSimulationStatus), 로딩 오버레이 추가 - HydrParticleOverlay: deck.gl 기반 입자 궤적 시각화 레이어 - OilSpillView/LeftPanel/RightPanel: 시뮬레이션 실행·결과 표시 UI 개편 - predictionService/predictionRouter: 시뮬레이션 CRUD 및 상태 관리 API - simulation.ts: OpenDrift 연동 엔드포인트 확장 - docs/PREDICTION-GUIDE.md: 예측 기능 개발 가이드 추가 [CCTV/항공방제] - CCTV 오일 감지 GPU 추론 연동 (OilDetectionOverlay, useOilDetection) - CCTV 안전관리 감지 기능 추가 (선박 출입, 침입 감지) - oil_inference_server.py: Python GPU 추론 서버 [관리자] - 관리자 화면 고도화 (사용자/권한/게시판/선박신호 패널) - AdminSidebar, BoardMgmtPanel, VesselSignalPanel 신규 컴포넌트 [기타] - DB: 시뮬레이션 결과, 선박보험 시드(1391건), 역할 정리 마이그레이션 - 팀 워크플로우 v1.6.1 동기화 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
524 lines
15 KiB
TypeScript
524 lines
15 KiB
TypeScript
import { wingPool } from '../db/wingDb.js';
|
|
|
|
interface PredictionAnalysis {
|
|
acdntSn: number;
|
|
acdntNm: string;
|
|
occurredAt: string;
|
|
analysisDate: string;
|
|
requestor: string;
|
|
duration: string;
|
|
oilType: string;
|
|
volume: number | null;
|
|
location: string;
|
|
lat: number | null;
|
|
lon: number | null;
|
|
kospsStatus: string;
|
|
poseidonStatus: string;
|
|
opendriftStatus: string;
|
|
backtrackStatus: string;
|
|
analyst: string;
|
|
officeName: string;
|
|
}
|
|
|
|
interface PredictionDetail {
|
|
acdnt: {
|
|
acdntSn: number;
|
|
acdntNm: string;
|
|
occurredAt: string;
|
|
lat: number | null;
|
|
lon: number | null;
|
|
location: string;
|
|
analyst: string;
|
|
officeName: string;
|
|
};
|
|
spill: {
|
|
oilType: string;
|
|
volume: number | null;
|
|
unit: string;
|
|
fcstHr: number | null;
|
|
} | null;
|
|
vessels: Array<{
|
|
vesselInfoSn: number;
|
|
imoNo: string;
|
|
vesselNm: string;
|
|
vesselTp: string;
|
|
loaM: number | null;
|
|
breadthM: number | null;
|
|
draftM: number | null;
|
|
gt: number | null;
|
|
dwt: number | null;
|
|
builtYr: number | null;
|
|
flagCd: string;
|
|
callsign: string;
|
|
engineDc: string;
|
|
insuranceData: unknown;
|
|
}>;
|
|
weather: Array<{
|
|
obsDtm: string;
|
|
locNm: string;
|
|
temp: string;
|
|
weatherDc: string;
|
|
wind: string;
|
|
wave: string;
|
|
humid: string;
|
|
vis: string;
|
|
sst: string;
|
|
}>;
|
|
}
|
|
|
|
interface BacktrackResult {
|
|
backtrackSn: number;
|
|
acdntSn: number;
|
|
estSpilDtm: string | null;
|
|
anlysRange: string | null;
|
|
lon: number | null;
|
|
lat: number | null;
|
|
srchRadiusNm: number | null;
|
|
totalVessels: number | null;
|
|
execSttsCd: string;
|
|
rsltData: unknown;
|
|
regDtm: string;
|
|
}
|
|
|
|
interface CreateBacktrackInput {
|
|
acdntSn: number;
|
|
lat: number;
|
|
lon: number;
|
|
estSpilDtm?: string;
|
|
anlysRange?: string;
|
|
srchRadiusNm?: number;
|
|
}
|
|
|
|
interface SaveBoomLineInput {
|
|
acdntSn: number;
|
|
boomNm: string;
|
|
priorityOrd?: number;
|
|
geojson: unknown;
|
|
lengthM?: number;
|
|
efficiencyPct?: number;
|
|
}
|
|
|
|
interface BoomLineItem {
|
|
boomLineSn: number;
|
|
acdntSn: number;
|
|
boomNm: string;
|
|
priorityOrd: number;
|
|
geom: unknown;
|
|
lengthM: number | null;
|
|
efficiencyPct: number | null;
|
|
sttsCd: string;
|
|
regDtm: string;
|
|
}
|
|
|
|
interface ListAnalysesInput {
|
|
search?: string;
|
|
}
|
|
|
|
export async function listAnalyses(input: ListAnalysesInput): Promise<PredictionAnalysis[]> {
|
|
const params: unknown[] = [];
|
|
const conditions: string[] = ["A.USE_YN = 'Y'"];
|
|
|
|
if (input.search) {
|
|
params.push(`%${input.search}%`);
|
|
conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
const sql = `
|
|
SELECT
|
|
A.ACDNT_SN,
|
|
A.ACDNT_NM,
|
|
A.OCCRN_DTM,
|
|
A.LAT,
|
|
A.LNG,
|
|
A.LOC_DC,
|
|
A.ANALYST_NM,
|
|
A.OFFICE_NM,
|
|
A.REGION_NM,
|
|
S.OIL_TP_CD,
|
|
S.SPIL_QTY,
|
|
S.SPIL_UNIT_CD,
|
|
S.FCST_HR,
|
|
P.KOSPS_STATUS,
|
|
P.POSEIDON_STATUS,
|
|
P.OPENDRIFT_STATUS,
|
|
B.BACKTRACK_STATUS
|
|
FROM ACDNT A
|
|
LEFT JOIN SPIL_DATA S ON S.ACDNT_SN = A.ACDNT_SN
|
|
LEFT JOIN (
|
|
SELECT
|
|
ACDNT_SN,
|
|
MAX(CASE WHEN ALGO_CD = 'KOSPS' THEN EXEC_STTS_CD END) AS KOSPS_STATUS,
|
|
MAX(CASE WHEN ALGO_CD = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS,
|
|
MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_STATUS
|
|
FROM PRED_EXEC
|
|
GROUP BY ACDNT_SN
|
|
) P ON P.ACDNT_SN = A.ACDNT_SN
|
|
LEFT JOIN (
|
|
SELECT
|
|
ACDNT_SN,
|
|
MAX(CASE WHEN B.EXEC_STTS_CD IS NOT NULL THEN B.EXEC_STTS_CD ELSE 'pending' END) AS BACKTRACK_STATUS
|
|
FROM BACKTRACK B
|
|
GROUP BY ACDNT_SN
|
|
) B ON B.ACDNT_SN = A.ACDNT_SN
|
|
${whereClause}
|
|
ORDER BY A.OCCRN_DTM DESC
|
|
`;
|
|
|
|
const { rows } = await wingPool.query(sql, params);
|
|
|
|
return rows.map((row: Record<string, unknown>) => ({
|
|
acdntSn: Number(row['acdnt_sn']),
|
|
acdntNm: String(row['acdnt_nm'] ?? ''),
|
|
occurredAt: row['occrn_dtm'] ? String(row['occrn_dtm']) : '',
|
|
analysisDate: row['occrn_dtm'] ? String(row['occrn_dtm']) : '',
|
|
requestor: String(row['analyst_nm'] ?? ''),
|
|
duration: row['fcst_hr'] != null ? `${row['fcst_hr']}hr` : '',
|
|
oilType: String(row['oil_tp_cd'] ?? ''),
|
|
volume: row['spil_qty'] != null ? parseFloat(String(row['spil_qty'])) : null,
|
|
location: String(row['loc_dc'] ?? ''),
|
|
lat: row['lat'] != null ? parseFloat(String(row['lat'])) : null,
|
|
lon: row['lng'] != null ? parseFloat(String(row['lng'])) : null,
|
|
kospsStatus: String(row['kosps_status'] ?? 'pending').toLowerCase(),
|
|
poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(),
|
|
opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(),
|
|
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
|
|
analyst: String(row['analyst_nm'] ?? ''),
|
|
officeName: String(row['office_nm'] ?? ''),
|
|
}));
|
|
}
|
|
|
|
export async function getAnalysisDetail(acdntSn: number): Promise<PredictionDetail | null> {
|
|
const acdntSql = `
|
|
SELECT
|
|
A.ACDNT_SN,
|
|
A.ACDNT_NM,
|
|
A.OCCRN_DTM,
|
|
A.LAT,
|
|
A.LNG,
|
|
A.LOC_DC,
|
|
A.ANALYST_NM,
|
|
A.OFFICE_NM
|
|
FROM ACDNT A
|
|
WHERE A.ACDNT_SN = $1
|
|
AND A.USE_YN = 'Y'
|
|
`;
|
|
const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]);
|
|
if (acdntRows.length === 0) return null;
|
|
|
|
const a = acdntRows[0] as Record<string, unknown>;
|
|
|
|
const spillSql = `
|
|
SELECT
|
|
OIL_TP_CD,
|
|
SPIL_QTY,
|
|
SPIL_UNIT_CD,
|
|
FCST_HR
|
|
FROM SPIL_DATA
|
|
WHERE ACDNT_SN = $1
|
|
ORDER BY SPIL_DATA_SN ASC
|
|
LIMIT 1
|
|
`;
|
|
const { rows: spillRows } = await wingPool.query(spillSql, [acdntSn]);
|
|
|
|
const vesselSql = `
|
|
SELECT
|
|
VESSEL_INFO_SN,
|
|
IMO_NO,
|
|
VESSEL_NM,
|
|
VESSEL_TP,
|
|
LOA_M,
|
|
BREADTH_M,
|
|
DRAFT_M,
|
|
GT,
|
|
DWT,
|
|
BUILT_YR,
|
|
FLAG_CD,
|
|
CALLSIGN,
|
|
ENGINE_DC,
|
|
INSURANCE_DATA
|
|
FROM VESSEL_INFO
|
|
WHERE ACDNT_SN = $1
|
|
ORDER BY VESSEL_INFO_SN ASC
|
|
`;
|
|
const { rows: vesselRows } = await wingPool.query(vesselSql, [acdntSn]);
|
|
|
|
const weatherSql = `
|
|
SELECT
|
|
OBS_DTM,
|
|
LOC_NM,
|
|
TEMP,
|
|
WEATHER_DC,
|
|
WIND,
|
|
WAVE,
|
|
HUMID,
|
|
VIS,
|
|
SST
|
|
FROM ACDNT_WEATHER
|
|
WHERE ACDNT_SN = $1
|
|
ORDER BY OBS_DTM ASC
|
|
`;
|
|
const { rows: weatherRows } = await wingPool.query(weatherSql, [acdntSn]);
|
|
|
|
const spill =
|
|
spillRows.length > 0
|
|
? (() => {
|
|
const s = spillRows[0] as Record<string, unknown>;
|
|
return {
|
|
oilType: String(s['oil_tp_cd'] ?? ''),
|
|
volume: s['spil_qty'] != null ? parseFloat(String(s['spil_qty'])) : null,
|
|
unit: String(s['spil_unit_cd'] ?? ''),
|
|
fcstHr: s['fcst_hr'] != null ? parseFloat(String(s['fcst_hr'])) : null,
|
|
};
|
|
})()
|
|
: null;
|
|
|
|
const vessels = vesselRows.map((v: Record<string, unknown>) => ({
|
|
vesselInfoSn: Number(v['vessel_info_sn']),
|
|
imoNo: String(v['imo_no'] ?? ''),
|
|
vesselNm: String(v['vessel_nm'] ?? ''),
|
|
vesselTp: String(v['vessel_tp'] ?? ''),
|
|
loaM: v['loa_m'] != null ? parseFloat(String(v['loa_m'])) : null,
|
|
breadthM: v['breadth_m'] != null ? parseFloat(String(v['breadth_m'])) : null,
|
|
draftM: v['draft_m'] != null ? parseFloat(String(v['draft_m'])) : null,
|
|
gt: v['gt'] != null ? parseFloat(String(v['gt'])) : null,
|
|
dwt: v['dwt'] != null ? parseFloat(String(v['dwt'])) : null,
|
|
builtYr: v['built_yr'] != null ? Number(v['built_yr']) : null,
|
|
flagCd: String(v['flag_cd'] ?? ''),
|
|
callsign: String(v['callsign'] ?? ''),
|
|
engineDc: String(v['engine_dc'] ?? ''),
|
|
insuranceData: v['insurance_data'] ?? null,
|
|
}));
|
|
|
|
const weather = weatherRows.map((w: Record<string, unknown>) => ({
|
|
obsDtm: w['obs_dtm'] ? String(w['obs_dtm']) : '',
|
|
locNm: String(w['loc_nm'] ?? ''),
|
|
temp: String(w['temp'] ?? ''),
|
|
weatherDc: String(w['weather_dc'] ?? ''),
|
|
wind: String(w['wind'] ?? ''),
|
|
wave: String(w['wave'] ?? ''),
|
|
humid: String(w['humid'] ?? ''),
|
|
vis: String(w['vis'] ?? ''),
|
|
sst: String(w['sst'] ?? ''),
|
|
}));
|
|
|
|
return {
|
|
acdnt: {
|
|
acdntSn: Number(a['acdnt_sn']),
|
|
acdntNm: String(a['acdnt_nm'] ?? ''),
|
|
occurredAt: a['occrn_dtm'] ? String(a['occrn_dtm']) : '',
|
|
lat: a['lat'] != null ? parseFloat(String(a['lat'])) : null,
|
|
lon: a['lng'] != null ? parseFloat(String(a['lng'])) : null,
|
|
location: String(a['loc_dc'] ?? ''),
|
|
analyst: String(a['analyst_nm'] ?? ''),
|
|
officeName: String(a['office_nm'] ?? ''),
|
|
},
|
|
spill,
|
|
vessels,
|
|
weather,
|
|
};
|
|
}
|
|
|
|
export async function getBacktrack(sn: number): Promise<BacktrackResult | null> {
|
|
const sql = `
|
|
SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE,
|
|
LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS,
|
|
EXEC_STTS_CD, RSLT_DATA, REG_DTM
|
|
FROM BACKTRACK
|
|
WHERE BACKTRACK_SN = $1 AND USE_YN = 'Y'
|
|
`;
|
|
const { rows } = await wingPool.query(sql, [sn]);
|
|
if (rows.length === 0) return null;
|
|
return rowToBacktrack(rows[0] as Record<string, unknown>);
|
|
}
|
|
|
|
export async function listBacktracksByAcdnt(acdntSn: number): Promise<BacktrackResult[]> {
|
|
const sql = `
|
|
SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE,
|
|
LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS,
|
|
EXEC_STTS_CD, RSLT_DATA, REG_DTM
|
|
FROM BACKTRACK
|
|
WHERE ACDNT_SN = $1 AND USE_YN = 'Y'
|
|
ORDER BY REG_DTM DESC
|
|
`;
|
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
|
return rows.map((r: Record<string, unknown>) => rowToBacktrack(r));
|
|
}
|
|
|
|
function rowToBacktrack(r: Record<string, unknown>): BacktrackResult {
|
|
return {
|
|
backtrackSn: Number(r['backtrack_sn']),
|
|
acdntSn: Number(r['acdnt_sn']),
|
|
estSpilDtm: r['est_spil_dtm'] ? String(r['est_spil_dtm']) : null,
|
|
anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null,
|
|
lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null,
|
|
lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null,
|
|
srchRadiusNm: r['srch_radius_nm'] != null ? parseFloat(String(r['srch_radius_nm'])) : null,
|
|
totalVessels: r['total_vessels'] != null ? Number(r['total_vessels']) : null,
|
|
execSttsCd: String(r['exec_stts_cd'] ?? ''),
|
|
rsltData: r['rslt_data'] ?? null,
|
|
regDtm: String(r['reg_dtm'] ?? ''),
|
|
};
|
|
}
|
|
|
|
export async function createBacktrack(
|
|
input: CreateBacktrackInput,
|
|
): Promise<{ backtrackSn: number }> {
|
|
const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input;
|
|
|
|
const sql = `
|
|
INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD)
|
|
VALUES (
|
|
$1, $2, $3,
|
|
ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326),
|
|
$3 || ' + ' || $2,
|
|
$4, $5, $6, 'PENDING'
|
|
)
|
|
RETURNING BACKTRACK_SN
|
|
`;
|
|
|
|
const { rows } = await wingPool.query(sql, [
|
|
acdntSn, lat, lon,
|
|
estSpilDtm || null, anlysRange || null, srchRadiusNm || null,
|
|
]);
|
|
|
|
return { backtrackSn: Number((rows[0] as Record<string, unknown>)['backtrack_sn']) };
|
|
}
|
|
|
|
export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> {
|
|
const { acdntSn, boomNm, priorityOrd = 0, geojson, lengthM, efficiencyPct } = input;
|
|
|
|
const sql = `
|
|
INSERT INTO BOOM_LINE (ACDNT_SN, BOOM_NM, PRIORITY_ORD, GEOM, LENGTH_M, EFFICIENCY_PCT)
|
|
VALUES ($1, $2, $3, ST_GeomFromGeoJSON($4), $5, $6)
|
|
RETURNING BOOM_LINE_SN
|
|
`;
|
|
|
|
const { rows } = await wingPool.query(sql, [
|
|
acdntSn, boomNm, priorityOrd,
|
|
JSON.stringify(geojson),
|
|
lengthM || null, efficiencyPct || null,
|
|
]);
|
|
|
|
return { boomLineSn: Number((rows[0] as Record<string, unknown>)['boom_line_sn']) };
|
|
}
|
|
|
|
interface TrajectoryParticle {
|
|
lat: number;
|
|
lon: number;
|
|
stranded?: 0 | 1;
|
|
}
|
|
|
|
interface TrajectoryWindPoint {
|
|
lat: number;
|
|
lon: number;
|
|
wind_speed: number;
|
|
wind_direction: number;
|
|
}
|
|
|
|
interface TrajectoryHydrGrid {
|
|
lonInterval: number[];
|
|
boundLonLat: { top: number; bottom: number; left: number; right: number };
|
|
rows: number;
|
|
cols: number;
|
|
latInterval: number[];
|
|
}
|
|
|
|
interface TrajectoryTimeStep {
|
|
particles: TrajectoryParticle[];
|
|
remaining_volume_m3: number;
|
|
weathered_volume_m3: number;
|
|
pollution_area_km2: number;
|
|
beached_volume_m3: number;
|
|
pollution_coast_length_m: number;
|
|
center_lat?: number;
|
|
center_lon?: number;
|
|
wind_data?: TrajectoryWindPoint[];
|
|
hydr_data?: [number[][], number[][]];
|
|
hydr_grid?: TrajectoryHydrGrid;
|
|
}
|
|
|
|
interface TrajectoryResult {
|
|
trajectory: Array<{ lat: number; lon: number; time: number; particle: number; stranded?: 0 | 1 }>;
|
|
summary: {
|
|
remainingVolume: number;
|
|
weatheredVolume: number;
|
|
pollutionArea: number;
|
|
beachedVolume: number;
|
|
pollutionCoastLength: number;
|
|
};
|
|
centerPoints: Array<{ lat: number; lon: number; time: number }>;
|
|
windData: TrajectoryWindPoint[][];
|
|
hydrData: ({ value: [number[][], number[][]]; grid: TrajectoryHydrGrid } | null)[];
|
|
}
|
|
|
|
function transformTrajectoryResult(rawResult: TrajectoryTimeStep[]): TrajectoryResult {
|
|
const trajectory = rawResult.flatMap((step, stepIdx) =>
|
|
step.particles.map((p, i) => ({
|
|
lat: p.lat,
|
|
lon: p.lon,
|
|
time: stepIdx,
|
|
particle: i,
|
|
stranded: p.stranded,
|
|
}))
|
|
);
|
|
const lastStep = rawResult[rawResult.length - 1];
|
|
const summary = {
|
|
remainingVolume: lastStep.remaining_volume_m3,
|
|
weatheredVolume: lastStep.weathered_volume_m3,
|
|
pollutionArea: lastStep.pollution_area_km2,
|
|
beachedVolume: lastStep.beached_volume_m3,
|
|
pollutionCoastLength: lastStep.pollution_coast_length_m,
|
|
};
|
|
const centerPoints = rawResult
|
|
.map((step, stepIdx) =>
|
|
step.center_lat != null && step.center_lon != null
|
|
? { lat: step.center_lat, lon: step.center_lon, time: stepIdx }
|
|
: null
|
|
)
|
|
.filter((p): p is { lat: number; lon: number; time: number } => p !== null);
|
|
const windData = rawResult.map((step) => step.wind_data ?? []);
|
|
const hydrData = rawResult.map((step) =>
|
|
step.hydr_data && step.hydr_grid
|
|
? { value: step.hydr_data, grid: step.hydr_grid }
|
|
: null
|
|
);
|
|
return { trajectory, summary, centerPoints, windData, hydrData };
|
|
}
|
|
|
|
export async function getAnalysisTrajectory(acdntSn: number): Promise<TrajectoryResult | null> {
|
|
const sql = `
|
|
SELECT RSLT_DATA FROM wing.PRED_EXEC
|
|
WHERE ACDNT_SN = $1 AND ALGO_CD = 'OPENDRIFT' AND EXEC_STTS_CD = 'COMPLETED'
|
|
ORDER BY CMPL_DTM DESC LIMIT 1
|
|
`;
|
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
|
if (rows.length === 0 || !rows[0].rslt_data) return null;
|
|
return transformTrajectoryResult(rows[0].rslt_data as TrajectoryTimeStep[]);
|
|
}
|
|
|
|
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
|
const sql = `
|
|
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
|
ST_AsGeoJSON(GEOM) AS GEOM, LENGTH_M, EFFICIENCY_PCT, STTS_CD, REG_DTM
|
|
FROM BOOM_LINE
|
|
WHERE ACDNT_SN = $1 AND USE_YN = 'Y'
|
|
ORDER BY PRIORITY_ORD ASC
|
|
`;
|
|
|
|
const { rows } = await wingPool.query(sql, [acdntSn]);
|
|
|
|
return rows.map((r: Record<string, unknown>) => ({
|
|
boomLineSn: Number(r['boom_line_sn']),
|
|
acdntSn: Number(r['acdnt_sn']),
|
|
boomNm: String(r['boom_nm'] ?? ''),
|
|
priorityOrd: Number(r['priority_ord'] ?? 0),
|
|
geom: r['geom'] != null ? JSON.parse(String(r['geom'])) : null,
|
|
lengthM: r['length_m'] != null ? parseFloat(String(r['length_m'])) : null,
|
|
efficiencyPct: r['efficiency_pct'] != null ? parseFloat(String(r['efficiency_pct'])) : null,
|
|
sttsCd: String(r['stts_cd'] ?? 'PLANNED'),
|
|
regDtm: String(r['reg_dtm'] ?? ''),
|
|
}));
|
|
}
|