Skip to content

Commit 610481a

Browse files
committed
LOGIC SUDAH MULAI BENAR
1 parent 3b49cbe commit 610481a

3 files changed

Lines changed: 402 additions & 21 deletions

File tree

src/components/sld/SldCanvas.tsx

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -122,25 +122,81 @@ export function SldCanvas() {
122122
const substationPowerCards = useMemo(
123123
() =>
124124
[
125-
{ id: "A", name: "Substation A", x: 240, y: 420 },
126-
{ id: "B", name: "Substation B", x: 700, y: 29 },
125+
{ id: "A", name: "Substation A", x: 240, y: 400 },
126+
{ id: "B", name: "Substation B", x: 700, y: 25 },
127127
{ id: "C", name: "Substation C", x: 1350, y: 400 },
128128
].map((card) => {
129129
const islands = tripMatrix.topology.islands.filter((island) =>
130130
island.buses.includes(card.id as "A" | "B" | "C"),
131131
);
132+
const localGen = islands.reduce(
133+
(sum, island) => sum + island.generationMw,
134+
0,
135+
);
136+
const gridImport = islands.reduce(
137+
(sum, island) => sum + island.gridImportMw,
138+
0,
139+
);
132140
const source = islands.reduce(
133141
(sum, island) => sum + island.sourceMw,
134142
0,
135143
);
136144
const load = islands.reduce((sum, island) => sum + island.loadMw, 0);
137-
const reserve = source - load;
145+
const rawMargin = source - load;
146+
const lowerLimit = load * 0.95;
147+
const upperLimit = load * 1.05;
148+
const balancePct =
149+
load > 0 ? (source / load) * 100 : source > 0 ? Infinity : 100;
150+
const hasGridSource = islands.some((island) => island.hasGridSource);
151+
const isSplit = tripMatrix.topology.islands.length > 1;
152+
const isPureIsland = isSplit && !hasGridSource;
153+
const ogsRequired = isPureIsland && load > 0 && localGen > upperLimit;
154+
const noLoadOgs = isPureIsland && load === 0 && localGen > 0;
155+
const sheddingRequired = load > 0 && source < lowerLimit;
156+
const watch = load > 0 && source < load && source >= lowerLimit;
157+
const adsNeed = ogsRequired
158+
? Math.ceil(localGen - upperLimit)
159+
: noLoadOgs
160+
? localGen
161+
: sheddingRequired
162+
? Math.ceil(load - source / 0.95)
163+
: 0;
164+
const status = noLoadOgs
165+
? "ogs"
166+
: ogsRequired
167+
? "ogs"
168+
: sheddingRequired
169+
? "shedding"
170+
: watch
171+
? "watch"
172+
: "supported";
173+
const statusLabel = noLoadOgs
174+
? "OGS · No load"
175+
: ogsRequired
176+
? "OGS required"
177+
: sheddingRequired
178+
? "Shedding required"
179+
: watch
180+
? "Within tolerance"
181+
: hasGridSource
182+
? "Grid supported"
183+
: "Supported";
184+
138185
return {
139186
...card,
187+
localGen,
188+
gridImport,
140189
source,
141190
load,
142-
reserve,
143-
islanded: tripMatrix.topology.islands.length > 1,
191+
rawMargin,
192+
lowerLimit,
193+
upperLimit,
194+
balancePct,
195+
adsNeed,
196+
status,
197+
statusLabel,
198+
islanded: isSplit,
199+
hasGridSource,
144200
};
145201
}),
146202
[tripMatrix],
@@ -617,26 +673,72 @@ export function SldCanvas() {
617673
>
618674
{substationPowerCards.map((card) => (
619675
<article
620-
className={`substation-flow-card ${card.reserve < 0 ? "is-deficit" : "is-healthy"}`}
676+
className={`substation-flow-card is-${card.status}`}
621677
key={card.id}
622678
style={{ left: card.x, top: card.y }}
623679
>
624680
<header>
625681
<span>{card.name}</span>
626-
<b>{card.islanded ? "Island" : "Grid"}</b>
682+
<b>{card.statusLabel}</b>
627683
</header>
628684
<div>
629-
<small>Gen</small>
685+
<small>Source</small>
630686
<strong>{card.source} MW</strong>
631687
</div>
632688
<div>
633689
<small>Load</small>
634690
<strong>{card.load} MW</strong>
635691
</div>
692+
<div>
693+
<small>Balance</small>
694+
<strong>
695+
{Number.isFinite(card.balancePct)
696+
? `${card.balancePct.toFixed(1)}%`
697+
: "∞"}
698+
</strong>
699+
</div>
700+
<div>
701+
<small>ADS Need</small>
702+
<strong>{card.adsNeed} MW</strong>
703+
</div>
636704
<footer>
637-
<small>{card.reserve >= 0 ? "Margin" : "Deficit"}</small>
638-
<strong>{Math.abs(card.reserve)} MW</strong>
705+
<small>
706+
{card.rawMargin >= 0 ? "Raw margin" : "Raw shortage"}
707+
</small>
708+
<strong>
709+
{card.rawMargin >= 0 ? "+" : ""}
710+
{card.rawMargin} MW
711+
</strong>
639712
</footer>
713+
<section className="substation-flow-tooltip">
714+
<strong>{card.name} balance reasoning</strong>
715+
<p>
716+
Local Gen {card.localGen} MW + IBT/Grid {card.gridImport} MW
717+
= Total Source {card.source} MW. Load {card.load} MW.
718+
</p>
719+
<p>
720+
Raw margin = {card.source} - {card.load} = {card.rawMargin}{" "}
721+
MW. Balance ={" "}
722+
{Number.isFinite(card.balancePct)
723+
? `${card.balancePct.toFixed(1)}%`
724+
: "∞"}
725+
.
726+
</p>
727+
<p>
728+
ADS lower limit = 95% × {card.load} ={" "}
729+
{card.lowerLimit.toFixed(1)} MW. Upper limit = 105% ×{" "}
730+
{card.load} = {card.upperLimit.toFixed(1)} MW.
731+
</p>
732+
<p>
733+
{card.status === "watch"
734+
? "Source is below load, but still inside the ADS 95% tolerance band. No load shedding is required."
735+
: card.status === "shedding"
736+
? "Source is below the 95% lower limit. Local load shedding is required."
737+
: card.status === "ogs"
738+
? "Pure island generation is above the 105% upper limit. OGS/runback is required if a valid generator target exists."
739+
: "Area is supported. No ADS action is required."}
740+
</p>
741+
</section>
640742
</article>
641743
))}
642744
</div>

src/lib/ads/matrixEngine.ts

Lines changed: 178 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { rankGenerationShedding, rankShedding } from "./solver";
2-
import { buildTopology, calculateIslands } from "./topology";
2+
import { buildTopology, calculateIslands, getSourceUnits } from "./topology";
33
import type {
44
AdsDecision,
55
BreakerState,
@@ -52,12 +52,27 @@ export function simulateContingency(
5252
);
5353
}
5454

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+
5572
const nextSnapshot = withToggledState(snapshot, triggerId);
5673
const nextTopology = calculateIslands(nextSnapshot);
57-
const currentIslandId = currentTopology.deviceIslandMap[triggerId];
5874
const affectedIsland = findAffectedIsland(rule, nextTopology, currentIslandId);
5975
const formsNewIsland = nextTopology.islands.length > currentTopology.islands.length;
60-
const isTrueIsland = Boolean(affectedIsland && !affectedIsland.hasGridSource);
6176
const isSourceLossTrigger = isSourceLoss(triggerId);
6277
const mustEvaluateIslandBalance = Boolean(
6378
affectedIsland &&
@@ -144,6 +159,166 @@ export function simulateContingency(
144159
}
145160

146161

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+
147322
function isSourceLoss(triggerId: string): boolean {
148323
return triggerId.startsWith("GEN_") || triggerId.startsWith("IBT_");
149324
}

0 commit comments

Comments
 (0)