|
1 | 1 | import { rankGenerationShedding, rankShedding } from "./solver"; |
2 | | -import { buildTopology, calculateIslands } from "./topology"; |
| 2 | +import { buildTopology, calculateIslands, getSourceUnits } from "./topology"; |
3 | 3 | import type { |
4 | 4 | AdsDecision, |
5 | 5 | BreakerState, |
@@ -52,12 +52,27 @@ export function simulateContingency( |
52 | 52 | ); |
53 | 53 | } |
54 | 54 |
|
| 55 | + const triggerState = snapshot.objectStates[triggerId] ?? "closed"; |
| 56 | + if (!isClosedState(triggerState)) { |
| 57 | + return alreadyOpenRow(triggerId, snapshot, matrixVersion, rule); |
| 58 | + } |
| 59 | + |
| 60 | + const currentIslandId = currentTopology.deviceIslandMap[triggerId]; |
| 61 | + const currentIsland = currentIslandId |
| 62 | + ? currentTopology.islands.find((island) => island.id === currentIslandId) |
| 63 | + : undefined; |
| 64 | + |
| 65 | + const currentOgsTargetDecision = currentIsland |
| 66 | + ? evaluateCurrentIslandOgsTarget(triggerId, currentIsland, rule) |
| 67 | + : null; |
| 68 | + if (currentOgsTargetDecision && currentOgsTargetDecision.status !== "normal") { |
| 69 | + return buildRow(triggerId, snapshot, matrixVersion, currentIsland, currentOgsTargetDecision); |
| 70 | + } |
| 71 | + |
55 | 72 | const nextSnapshot = withToggledState(snapshot, triggerId); |
56 | 73 | const nextTopology = calculateIslands(nextSnapshot); |
57 | | - const currentIslandId = currentTopology.deviceIslandMap[triggerId]; |
58 | 74 | const affectedIsland = findAffectedIsland(rule, nextTopology, currentIslandId); |
59 | 75 | const formsNewIsland = nextTopology.islands.length > currentTopology.islands.length; |
60 | | - const isTrueIsland = Boolean(affectedIsland && !affectedIsland.hasGridSource); |
61 | 76 | const isSourceLossTrigger = isSourceLoss(triggerId); |
62 | 77 | const mustEvaluateIslandBalance = Boolean( |
63 | 78 | affectedIsland && |
@@ -144,6 +159,166 @@ export function simulateContingency( |
144 | 159 | } |
145 | 160 |
|
146 | 161 |
|
| 162 | + |
| 163 | +function isClosedState(state: BreakerState | undefined): boolean { |
| 164 | + return state !== "open" && state !== "failed"; |
| 165 | +} |
| 166 | + |
| 167 | +function alreadyOpenRow( |
| 168 | + triggerId: string, |
| 169 | + snapshot: SystemSnapshot, |
| 170 | + matrixVersion: number, |
| 171 | + rule: ContingencyRule, |
| 172 | +): TripMatrixRow { |
| 173 | + const decision: AdsDecision = { |
| 174 | + status: "normal", |
| 175 | + requiredReliefMw: 0, |
| 176 | + actionType: "NORMAL", |
| 177 | + scenarioKind: rule.scenarioKind, |
| 178 | + title: `${rule.title} - Already Open`, |
| 179 | + mode: "TRIP MATRIX", |
| 180 | + affectedBuses: rule.affectedBuses, |
| 181 | + constraint: rule.constraint, |
| 182 | + explanation: |
| 183 | + "This contingency object is already open/tripped in the current snapshot. The matrix does not arm the same contingency again.", |
| 184 | + detectedCondition: `${triggerId} is already open/tripped. No duplicate arming is generated.`, |
| 185 | + operatorMessage: |
| 186 | + "CB/contingency sudah open, jadi Trip Matrix tidak menampilkan arming ulang. Reclose dulu jika ingin membuat skenario baru.", |
| 187 | + imbalanceBasis: "Open trigger objects are treated as already executed contingencies.", |
| 188 | + imbalanceFormula: "ADS action need = 0 MW because duplicate arming is suppressed.", |
| 189 | + alternatives: [], |
| 190 | + rejected: [], |
| 191 | + }; |
| 192 | + |
| 193 | + return { |
| 194 | + triggerId, |
| 195 | + matrixVersion, |
| 196 | + snapshotHash: snapshot.snapshotHash, |
| 197 | + status: "normal", |
| 198 | + affectedBuses: rule.affectedBuses, |
| 199 | + triggerCommand: { |
| 200 | + objectId: triggerId, |
| 201 | + action: "open", |
| 202 | + }, |
| 203 | + remedialCommands: [], |
| 204 | + selectedTargets: [], |
| 205 | + decision, |
| 206 | + }; |
| 207 | +} |
| 208 | + |
| 209 | +function evaluateCurrentIslandOgsTarget( |
| 210 | + triggerId: string, |
| 211 | + island: ElectricalIsland, |
| 212 | + rule: ContingencyRule, |
| 213 | +): AdsDecision | null { |
| 214 | + if (!triggerId.startsWith("GEN_")) return null; |
| 215 | + if (island.hasGridSource) return null; |
| 216 | + if (!island.generatorIds.includes(triggerId)) return null; |
| 217 | + |
| 218 | + const generationMw = island.generationMw; |
| 219 | + const loadMw = island.loadMw; |
| 220 | + if (generationMw <= 0) return null; |
| 221 | + |
| 222 | + if (loadMw <= 0) { |
| 223 | + return { |
| 224 | + status: "armed", |
| 225 | + requiredReliefMw: generationMw, |
| 226 | + actionType: "OGS_GENERATOR_SHEDDING", |
| 227 | + scenarioKind: "ogs_surplus", |
| 228 | + title: `OGS - ${island.id} No Load Island`, |
| 229 | + mode: "TRIP MATRIX", |
| 230 | + affectedBuses: island.buses, |
| 231 | + constraint: "Island has online generation but no closed load", |
| 232 | + explanation: |
| 233 | + "Current true island has generation with zero local load. OGS must trip local generators; no load shedding target is valid.", |
| 234 | + detectedCondition: `Island ${island.id}: Pgen ${generationMw} MW, Pload 0 MW.`, |
| 235 | + operatorMessage: |
| 236 | + "OGS armed: island tanpa beban lokal. Generator lokal harus dilepas, bukan load remote.", |
| 237 | + generationBeforeMw: generationMw, |
| 238 | + loadBeforeMw: 0, |
| 239 | + generationAfterMw: 0, |
| 240 | + balanceRatioPct: 100, |
| 241 | + imbalanceBasis: `Island ${island.id}: Pgen ${generationMw} MW, Pload 0 MW.`, |
| 242 | + imbalanceFormula: `Required generation trip = ${generationMw} MW`, |
| 243 | + selectedGeneration: { |
| 244 | + id: island.generatorIds.join("+"), |
| 245 | + name: `${island.generatorIds.join(" + ")} trip`, |
| 246 | + bus: island.buses[0] ?? "C", |
| 247 | + mw: generationMw, |
| 248 | + priority: 1, |
| 249 | + action: "trip", |
| 250 | + }, |
| 251 | + alternatives: [], |
| 252 | + rejected: [], |
| 253 | + }; |
| 254 | + } |
| 255 | + |
| 256 | + const upperLimitMw = loadMw * 1.05; |
| 257 | + if (generationMw <= upperLimitMw) return null; |
| 258 | + |
| 259 | + const generator = getSourceUnits().find((source) => source.id === triggerId && source.kind === "generator"); |
| 260 | + if (!generator) { |
| 261 | + return { |
| 262 | + status: "blocked", |
| 263 | + requiredReliefMw: Math.ceil(generationMw - upperLimitMw), |
| 264 | + actionType: "OGS_GENERATOR_SHEDDING", |
| 265 | + scenarioKind: "ogs_surplus", |
| 266 | + title: `OGS - ${island.id} Generator Not Found`, |
| 267 | + mode: "TRIP MATRIX", |
| 268 | + affectedBuses: island.buses, |
| 269 | + constraint: "Invalid OGS generator target", |
| 270 | + explanation: "The hovered generator is not registered as a dispatchable OGS target.", |
| 271 | + operatorMessage: "OGS blocked: generator target tidak ditemukan di source model.", |
| 272 | + alternatives: [], |
| 273 | + rejected: [], |
| 274 | + }; |
| 275 | + } |
| 276 | + |
| 277 | + const finalGenerationMw = generationMw - generator.mw; |
| 278 | + const finalRatioPct = (finalGenerationMw / loadMw) * 100; |
| 279 | + const requiredReductionMw = Math.ceil(generationMw - upperLimitMw); |
| 280 | + const pass = finalRatioPct >= 95 && finalRatioPct <= 105; |
| 281 | + |
| 282 | + return { |
| 283 | + status: pass ? "armed" : "blocked", |
| 284 | + requiredReliefMw: requiredReductionMw, |
| 285 | + actionType: "OGS_GENERATOR_SHEDDING", |
| 286 | + scenarioKind: "ogs_surplus", |
| 287 | + title: pass |
| 288 | + ? `OGS - Trip ${generator.name}` |
| 289 | + : `OGS - ${generator.name} Runback Required`, |
| 290 | + mode: "TRIP MATRIX", |
| 291 | + affectedBuses: island.buses, |
| 292 | + constraint: "True island overgeneration", |
| 293 | + explanation: |
| 294 | + "Current true island has generation above the 105% upper balance limit. The matrix checks whether tripping this local generator keeps the island inside 95-105%.", |
| 295 | + detectedCondition: |
| 296 | + `Island ${island.id}: Pgen ${generationMw} MW > 105% x Pload ${loadMw} MW (${upperLimitMw.toFixed(1)} MW).`, |
| 297 | + operatorMessage: pass |
| 298 | + ? `OGS armed: trip ${generator.name} keeps final balance at ${finalRatioPct.toFixed(1)}%.` |
| 299 | + : `OGS required, but tripping ${generator.name} would make final balance ${finalRatioPct.toFixed(1)}%. Use generator runback instead of hard trip.`, |
| 300 | + generationBeforeMw: generationMw, |
| 301 | + loadBeforeMw: loadMw, |
| 302 | + generationAfterMw: finalGenerationMw, |
| 303 | + balanceRatioPct: finalRatioPct, |
| 304 | + imbalanceBasis: `Island ${island.id}: Pgen ${generationMw} MW, Pload ${loadMw} MW.`, |
| 305 | + imbalanceFormula: |
| 306 | + `Required gen reduction >= ${requiredReductionMw} MW; trip ${generator.id} ${generator.mw} MW -> final Pgen ${finalGenerationMw} MW (${finalRatioPct.toFixed(1)}%).`, |
| 307 | + selectedGeneration: pass |
| 308 | + ? { |
| 309 | + id: generator.id, |
| 310 | + name: `${generator.name} trip`, |
| 311 | + bus: generator.bus, |
| 312 | + mw: generator.mw, |
| 313 | + priority: 1, |
| 314 | + action: "trip", |
| 315 | + } |
| 316 | + : undefined, |
| 317 | + alternatives: [], |
| 318 | + rejected: [], |
| 319 | + }; |
| 320 | +} |
| 321 | + |
147 | 322 | function isSourceLoss(triggerId: string): boolean { |
148 | 323 | return triggerId.startsWith("GEN_") || triggerId.startsWith("IBT_"); |
149 | 324 | } |
|
0 commit comments