File size: 18,194 Bytes
13d4e44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0

/*
 * Action-overview pin DATA builders — pure functions, no DOM access.
 *
 * Separated from `actionPinRender.ts` so the severity palette, anchor
 * resolution, and pin descriptor construction can be tested without
 * jsdom.
 */

import type {
    ActionDetail,
    ActionSeverityCategory,
    MetadataIndex,
    NodeMeta,
    UnsimulatedActionScoreInfo,
} from '../../types';
import { pinColors, pinColorsDimmed, pinColorsHighlighted } from '../../styles/tokens';
import { getActionTargetLines, getActionTargetVoltageLevels } from './highlights';

export interface ActionPinInfo {
    id: string;
    x: number;
    y: number;
    severity: 'green' | 'orange' | 'red' | 'grey';
    label: string;
    title: string;
    /**
     * When true this pin represents an action from the scored table
     * that has NOT yet been simulated. Rendered with a dashed outline
     * and reduced opacity, and double-clicking it kicks off a manual
     * simulation (the same code path as the Manual Selection dropdown).
     */
    unsimulated?: boolean;
    /**
     * When true this pin was filtered out by the overview header
     * filter but kept visible because a passing combined action
     * references it. Rendered with reduced opacity so it reads as
     * a context pin rather than a first-class action.
     */
    dimmedByFilter?: boolean;
}

/**
 * Descriptor for a combined-action pin — rendered at the midpoint of
 * a curved connection between the two unitary action pins it combines.
 */
export interface CombinedPinInfo {
    /** Pair key, e.g. "action1+action2". */
    pairId: string;
    /** The two unitary action ids. */
    action1Id: string;
    action2Id: string;
    /** Anchor positions of the two unitary pins (endpoints of the curve). */
    p1: { x: number; y: number };
    p2: { x: number; y: number };
    /** Midpoint of the curve (where the combined pin sits). */
    x: number;
    y: number;
    /** Max loading after combined application. */
    label: string;
    title: string;
    severity: ActionPinInfo['severity'];
}

export const severityFill: Record<ActionPinInfo['severity'], string> = pinColors;

/**
 * Dimmed fill colours for rejected actions — each severity hue is
 * shifted toward grey and lowered in saturation so the pin recedes
 * visually while still being colour-identifiable.
 */
export const severityFillDimmed: Record<ActionPinInfo['severity'], string> = pinColorsDimmed;

/**
 * Highlighted (selected) fill colours — slightly more vivid/brighter
 * versions of the severity palette so the pin stands out.
 */
export const severityFillHighlighted: Record<ActionPinInfo['severity'], string> = pinColorsHighlighted;

export const computeActionSeverity = (
    details: ActionDetail,
    monitoringFactor: number,
): ActionPinInfo['severity'] => {
    if (details.non_convergence || details.is_islanded) return 'grey';
    if (details.max_rho == null) {
        return details.is_rho_reduction ? 'green' : 'red';
    }
    if (details.max_rho > monitoringFactor) return 'red';
    if (details.max_rho > monitoringFactor - 0.05) return 'orange';
    return 'green';
};

/**
 * Predicate used by both the overview (pin visibility) and the sidebar
 * ActionFeed (card visibility) to decide whether an action passes the
 * active category + threshold filters.
 *
 * - `categoryEnabled` controls the four severity buckets (green / orange
 *   / red / grey); if the action's bucket is disabled, it is hidden.
 * - `threshold` is a max-loading cap: actions whose `max_rho` is
 *   **strictly greater** than the threshold are hidden. Actions with a
 *   null `max_rho` (divergent / islanded — all in the 'grey' bucket)
 *   bypass the threshold so the operator keeps seeing non-numeric
 *   outcomes when the grey category is enabled.
 */
export const actionPassesOverviewFilter = (
    details: ActionDetail,
    monitoringFactor: number,
    categoryEnabled: Record<ActionSeverityCategory, boolean>,
    threshold: number,
): boolean => {
    const severity = computeActionSeverity(details, monitoringFactor);
    if (!categoryEnabled[severity]) return false;
    if (details.max_rho != null && details.max_rho > threshold) return false;
    return true;
};

/**
 * Format the loading label shown on the pin body.
 *
 * Exported so tests can pin the exact string contract: percentage,
 * "DIV" on divergence, "ISL" on islanding, or em-dash otherwise.
 */
export const formatPinLabel = (details: ActionDetail): string => {
    if (details.max_rho != null) return `${(details.max_rho * 100).toFixed(0)}%`;
    if (details.non_convergence) return 'DIV';
    if (details.is_islanded) return 'ISL';
    return '\u2014';
};

/**
 * Build the human-readable title (tooltip) shown on hover. The `idLabel`
 * is the pin id as the user thinks of it (e.g. "a+b" for combined).
 */
export const formatPinTitle = (idLabel: string, details: ActionDetail): string => {
    const parts = [
        idLabel,
        details.description_unitaire,
        details.max_rho != null
            ? `max loading ${(details.max_rho * 100).toFixed(1)}%${details.max_rho_line ? ` on ${details.max_rho_line}` : ''}`
            : details.non_convergence
                ? 'load-flow divergent'
                : details.is_islanded
                    ? 'islanding'
                    : '',
    ];
    return parts.filter(Boolean).join(' \u2014 ');
};

/**
 * Resolve an action to a point on the NAD background.
 *
 * For line / PST actions we take the midpoint of the edge; for
 * nodal actions we take the voltage-level node position. Returns
 * null when no impacted asset can be located — the pin is then
 * silently skipped.
 */
export const resolveActionAnchor = (
    actionId: string,
    details: ActionDetail,
    metaIndex: MetadataIndex,
): { x: number; y: number } | null => {
    const { nodesByEquipmentId, nodesBySvgId, edgesByEquipmentId } = metaIndex;

    const lookupNode = (nodeRef: unknown): NodeMeta | undefined => {
        if (typeof nodeRef !== 'string') return undefined;
        return nodesBySvgId.get(nodeRef) ?? nodesByEquipmentId.get(nodeRef);
    };

    // Load shedding / curtailment actions carry an explicit
    // voltage_level_id in their detail objects — use it directly
    // so the pin lands on the VL node, not on an unrelated line.
    if (details.load_shedding_details?.length) {
        const vlId = details.load_shedding_details[0].voltage_level_id;
        if (vlId) {
            const node = nodesByEquipmentId.get(vlId);
            if (node && Number.isFinite(node.x)) return { x: node.x, y: node.y };
        }
    }
    if (details.curtailment_details?.length) {
        const vlId = details.curtailment_details[0].voltage_level_id;
        if (vlId) {
            const node = nodesByEquipmentId.get(vlId);
            if (node && Number.isFinite(node.x)) return { x: node.x, y: node.y };
        }
    }
    if (details.redispatch_details?.length) {
        const vlId = details.redispatch_details[0].voltage_level_id;
        if (vlId) {
            const node = nodesByEquipmentId.get(vlId);
            if (node && Number.isFinite(node.x)) return { x: node.x, y: node.y };
        }
    }

    // Try line targets first
    const lineTargets = getActionTargetLines(details, actionId, edgesByEquipmentId);
    for (const lineName of lineTargets) {
        const edge = edgesByEquipmentId.get(lineName);
        if (!edge) continue;
        const n1 = lookupNode(edge.node1);
        const n2 = lookupNode(edge.node2);
        if (n1 && n2 && Number.isFinite(n1.x) && Number.isFinite(n2.x)) {
            return { x: (n1.x + n2.x) / 2, y: (n1.y + n2.y) / 2 };
        }
        if (n1 && Number.isFinite(n1.x)) return { x: n1.x, y: n1.y };
        if (n2 && Number.isFinite(n2.x)) return { x: n2.x, y: n2.y };
    }

    // Fallback on voltage-level targets
    const vlTargets = getActionTargetVoltageLevels(details, actionId, nodesByEquipmentId);
    for (const vlName of vlTargets) {
        const node = nodesByEquipmentId.get(vlName);
        if (node && Number.isFinite(node.x)) {
            return { x: node.x, y: node.y };
        }
    }

    // Last resort: max_rho_line (a line the action redistributes onto)
    if (details.max_rho_line) {
        const edge = edgesByEquipmentId.get(details.max_rho_line);
        if (edge) {
            const n1 = lookupNode(edge.node1);
            const n2 = lookupNode(edge.node2);
            if (n1 && n2 && Number.isFinite(n1.x) && Number.isFinite(n2.x)) {
                return { x: (n1.x + n2.x) / 2, y: (n1.y + n2.y) / 2 };
            }
        }
    }
    return null;
};

/**
 * Fan out pins that share the same anchor position so they don't
 * stack on top of each other and remain individually clickable.
 *
 * Mutates `pins` in place. Exported so tests can exercise the
 * fan-out independently from the full builder.
 */
export const fanOutColocatedPins = (pins: ActionPinInfo[], offsetRadius = 30 * 1.2): void => {
    const bucketKey = (p: ActionPinInfo) =>
        `${Math.round(p.x * 100)}:${Math.round(p.y * 100)}`;
    const groups = new Map<string, number[]>();
    pins.forEach((p, i) => {
        const k = bucketKey(p);
        const arr = groups.get(k);
        if (arr) arr.push(i);
        else groups.set(k, [i]);
    });
    for (const indices of groups.values()) {
        if (indices.length < 2) continue;
        const angleStep = (2 * Math.PI) / indices.length;
        indices.forEach((idx, i) => {
            const angle = -Math.PI / 2 + i * angleStep;
            pins[idx] = {
                ...pins[idx],
                x: pins[idx].x + offsetRadius * Math.cos(angle),
                y: pins[idx].y + offsetRadius * Math.sin(angle),
            };
        });
    }
};

/**
 * Build the list of pin descriptors for the action-overview view.
 * Pure function — no DOM access — so it can be unit-tested.
 *
 * When `overviewFilter` is provided, pins whose action does not pass
 * the active category + threshold filters are dropped. The same
 * predicate drives the ActionFeed card filtering, keeping the two
 * views in lock-step.
 */
export const buildActionOverviewPins = (
    actions: Record<string, ActionDetail>,
    metaIndex: MetadataIndex,
    monitoringFactor: number,
    filterIds?: Iterable<string>,
    overviewFilter?: {
        categories: Record<ActionSeverityCategory, boolean>;
        threshold: number;
    } | null,
): ActionPinInfo[] => {
    const allowed = filterIds ? new Set(filterIds) : null;
    const pins: ActionPinInfo[] = [];
    for (const [actionId, details] of Object.entries(actions)) {
        if (allowed && !allowed.has(actionId)) continue;
        // Skip combined-action entries (key contains '+') — those are
        // rendered separately by buildCombinedActionPins with a curved
        // connection between their constituent unitary pins.
        if (actionId.includes('+')) continue;
        if (overviewFilter && !actionPassesOverviewFilter(
            details, monitoringFactor, overviewFilter.categories, overviewFilter.threshold,
        )) continue;
        const anchor = resolveActionAnchor(actionId, details, metaIndex);
        if (!anchor) continue;
        const severity = computeActionSeverity(details, monitoringFactor);
        const label = formatPinLabel(details);
        const title = formatPinTitle(actionId, details);
        pins.push({ id: actionId, x: anchor.x, y: anchor.y, severity, label, title });
    }

    fanOutColocatedPins(pins);
    return pins;
};

/**
 * Format the SVG-title tooltip shown on hover for an un-simulated pin.
 * When {@link scoreInfo} is provided the tooltip carries the same
 * score-table data the Manual Selection dropdown exposes (type,
 * score, rank in category, MW/tap start), so the operator can triage
 * without leaving the overview. Falls back to a generic prompt when
 * score info is absent (e.g. older session reloads).
 */
export const buildUnsimulatedPinTitle = (
    id: string,
    info: UnsimulatedActionScoreInfo | undefined,
): string => {
    if (!info) {
        return `${id} — not yet simulated (double-click to run)`;
    }
    const lines: string[] = [
        `${id} — not yet simulated (double-click to run)`,
        `Type: ${info.type}`,
        `Score: ${info.score.toFixed(2)} — rank ${info.rankInType} of ${info.countInType} (max ${info.maxScoreInType.toFixed(2)})`,
    ];
    if (info.mwStart != null) {
        lines.push(`MW start: ${info.mwStart.toFixed(1)} MW`);
    }
    if (info.tapStart) {
        const ts = info.tapStart;
        const range = ts.low_tap != null && ts.high_tap != null
            ? ` (range ${ts.low_tap}${ts.high_tap})`
            : '';
        lines.push(`Tap start: ${ts.tap}${range}`);
    }
    return lines.join('\n');
};

/**
 * Build pin descriptors for actions that appear in the score table but
 * have NOT yet been simulated. These are rendered as dimmed, dashed
 * pins the operator can double-click to trigger a manual simulation.
 *
 * An unsimulated action has no `ActionDetail`, so anchoring goes
 * through `resolveActionAnchor` with a minimal stub; the helper
 * internally falls back on edge/VL lookups based on the id alone,
 * which matches what the score table does for line / coupling / PST
 * actions. Items that cannot be resolved are silently skipped.
 *
 * Pins whose id is already present in `simulatedIds` are skipped so
 * we never double-pin an action that is both scored and simulated.
 *
 * Pure function — no DOM access.
 */
export const buildUnsimulatedActionPins = (
    scoredActionIds: readonly string[],
    simulatedIds: ReadonlySet<string>,
    metaIndex: MetadataIndex,
    scoreInfo?: Readonly<Record<string, UnsimulatedActionScoreInfo>>,
): ActionPinInfo[] => {
    const pins: ActionPinInfo[] = [];
    const seen = new Set<string>();
    const stub: ActionDetail = {
        description_unitaire: '',
        rho_before: null,
        rho_after: null,
        max_rho: null,
        max_rho_line: '',
        is_rho_reduction: false,
    };
    for (const rawId of scoredActionIds) {
        const id = rawId.trim();
        if (!id || seen.has(id)) continue;
        seen.add(id);
        if (simulatedIds.has(id)) continue;
        const anchor = resolveActionAnchor(id, stub, metaIndex);
        if (!anchor) continue;
        pins.push({
            id,
            x: anchor.x,
            y: anchor.y,
            severity: 'grey',
            label: '?',
            title: buildUnsimulatedPinTitle(id, scoreInfo?.[id]),
            unsimulated: true,
        });
    }

    fanOutColocatedPins(pins);
    return pins;
};

/**
 * Quadratic Bezier midpoint of (p1, ctrl, p2) at t=0.5.
 * Exported so the renderer can reuse the same control-point math for
 * drawing the connecting curve path.
 */
export const curveMidpoint = (
    p1: { x: number; y: number },
    p2: { x: number; y: number },
    offsetFraction = 0.3,
): { ctrlX: number; ctrlY: number; midX: number; midY: number } => {
    const dx = p2.x - p1.x;
    const dy = p2.y - p1.y;
    const dist = Math.sqrt(dx * dx + dy * dy) || 1;
    const ctrlX = (p1.x + p2.x) / 2 + (-dy / dist) * dist * offsetFraction;
    const ctrlY = (p1.y + p2.y) / 2 + (dx / dist) * dist * offsetFraction;
    const t = 0.5;
    const midX = (1 - t) * (1 - t) * p1.x + 2 * t * (1 - t) * ctrlX + t * t * p2.x;
    const midY = (1 - t) * (1 - t) * p1.y + 2 * t * (1 - t) * ctrlY + t * t * p2.y;
    return { ctrlX, ctrlY, midX, midY };
};

/**
 * Build descriptors for combined-action pins. A simulated combined
 * action is identified by an action key containing '+' in the
 * `actions` dict (e.g. "disco_X+reco_Y"). For each such entry the
 * function locates the two constituent unitary pins and produces a
 * `CombinedPinInfo` with a curved connection between them and a
 * dedicated pin at the curve midpoint.
 *
 * NOTE: simulated pairs land in `actions` (not `combined_actions`)
 * — see CombinedActionsModal's handleSimulate. That is why this
 * function scans `actions` for '+' keys rather than iterating over
 * `combined_actions`.
 *
 * Pure function — no DOM access.
 */
export const buildCombinedActionPins = (
    actions: Record<string, ActionDetail> | null | undefined,
    unitaryPins: readonly ActionPinInfo[],
    monitoringFactor: number,
): CombinedPinInfo[] => {
    if (!actions) return [];
    const pinById = new Map(unitaryPins.map(p => [p.id, p]));
    const result: CombinedPinInfo[] = [];

    const combinedKeys = Object.keys(actions).filter(k => k.includes('+'));
    if (combinedKeys.length > 0) {
        console.log('[buildCombinedActionPins] combined keys in actions:', combinedKeys);
        console.log('[buildCombinedActionPins] unitary pin ids:', [...pinById.keys()]);
    }

    for (const [actionId, detail] of Object.entries(actions)) {
        if (!actionId.includes('+')) continue;

        const parts = actionId.split('+');
        if (parts.length !== 2) continue;
        const [id1, id2] = parts;

        const pin1 = pinById.get(id1);
        const pin2 = pinById.get(id2);
        if (!pin1 || !pin2) {
            console.warn(`[buildCombinedActionPins] skipping "${actionId}": pin1(${id1})=${!!pin1}, pin2(${id2})=${!!pin2}`);
            continue;
        }

        const { midX, midY } = curveMidpoint(pin1, pin2);
        const severity = computeActionSeverity(detail, monitoringFactor);
        const label = formatPinLabel(detail);
        const title = formatPinTitle(`${id1} + ${id2}`, detail);

        result.push({
            pairId: actionId,
            action1Id: id1,
            action2Id: id2,
            p1: { x: pin1.x, y: pin1.y },
            p2: { x: pin2.x, y: pin2.y },
            x: midX,
            y: midY,
            label,
            title,
            severity,
        });
    }
    return result;
};