# Covers Report

Operator-facing screen that tracks **ladle covers** in the caster area, the
**heats** currently sitting on each cover, and the **temperatures** each
cover's thermocouples are reporting.

Path in the app: **Caster → Covers Report** (`/!/covers-report`).

> Looking for the **technical reference** (table schemas, proc signatures,
> file map, conventions)? See [`CLAUDE.md`](./CLAUDE.md) in this same folder.

---

## What is a "cover" in this context?

A **cover** is the insulated lid placed on a HEAT which has a high value on hydris. Each cover has a
single thermocouple device wired into a wireless gateway, reporting one
temperature per cover.

When a ladle is moved under a cover, an operator **assigns** the heat
(by name, e.g. `H123456`) to that cover on this screen. Later, when the
ladle moves out, the operator **unassigns** the heat. The system records
the start and end times so we can:

- compute **time under cover** for each heat (how long the cover retained
  heat before tap)
- compare time-under-cover against the temperature drop curve
- alert when a heat has been under cover longer than a configured
  threshold (e.g. > 24 h might mean a stuck ladle)

---

## What the screen shows

Two AG Grids on the same page:

### Top — History grid

One row per **heat × cover** assignment that started inside the chosen
date range. Columns:

| Column | Meaning |
|---|---|
| Cover | Cover number the heat was on |
| Heat | Heat name |
| Started | When the heat was assigned (local time) |
| Ended | When the heat was unassigned (blank if still on cover) |
| Under Cover | Duration; if `Ended` is blank, ticks up against `now` |

The grid is **only refreshed manually** — pick a date range at the top of
the screen and click **Refresh**.

### Bottom — Realtime grid (auto-refreshes every 30 s)

Grouped by Cover (rows that read `Cover 2`, `Cover 3`, …). Inside each
group, one row per heat currently assigned to that cover. Columns:

| Column | Meaning |
|---|---|
| Heat | Heat name currently on this cover |
| Started | When this heat was assigned |
| Under Cover | Running duration |
| Temp | Latest temperature reading (°F) |
| Status | `G` (good reading in last 30 min) or `L` (lost / stale) |
| Peak | Maximum temperature since the heat was assigned |
| ✏️ | Edit / Unassign action |
| 📈 | Open per-cover temperature history chart |
| 🔔 | Edit "Out of Cover Alert" threshold (in hours) |

Click the **pencil** on a row with a heat → **Unassign** modal. Click the
pencil on a row with no heat → **Assign** modal. Click **Assign Cover**
above the grid to assign to any available cover.

---

## How assign / unassign works (end-to-end)

```
                    USER ─► Assign modal ─► Pick cover + heat (+ optional Start)
                                                    │
                                                    ▼
                                CAS.UpdateCoverHeat(@CoverId, @HeatName, @action,
                                                    @Started, @timeZoneCode,
                                                    @currentUser)
                                                    │
                                                    ▼
                                ┌───────────────────┼───────────────────┐
                                │                   │                   │
                                ▼                   ▼                   ▼
                       CAS.CoverHeats    CAS.CoverHeatsEvents     SYSTEM.AuditMsg
                       (current state)   (append-only log)        (cross-app audit)
                                │
                                ▼
            CAS.GetCoversReport @realTime=1   ◄── pulled by the realtime grid
            CAS.GetAvailableCovers            ◄── feeds the next modal open
```

---

## Must Under Cover box (Melt Shop Overview)

The **Melt Shop Overview** screen has a **Must Under Cover** box that flags heats
which should go under a cover because their hydrogen reading is high (> 1.5). It
reads the same cover-tracking data this screen writes:

- **Red (left)** — a high-hydrogen heat that is not under a cover yet. Drag it
  onto a cover to assign.
- **Green (right)** — a heat that is currently under a cover.

**Safeguard:** once a heat has been put under a cover and then taken out
(assigned and later unassigned), it will **not** appear in the box again, even if
its hydrogen is still high. The box flags heats that still need attention, not
ones that already finished their cover cycle. A heat that is currently under a
cover always stays visible in green (including a fresh re-assignment).

This is driven by the `'U'` (unassign) events in `CAS.CoverHeatsEvents`. To make a
heat eligible for the box again while testing, clear its cover-tracking records —
see the *Reset one heat's cover history* recipe in [`CLAUDE.md`](./CLAUDE.md).

---

## Where the temperature data comes from

```
   Wireless thermocouples on covers (the gateway pushes readings)
                            │
                            ▼
              Covers SQL database  (a separate SQL Server DB)
                            │
                            ▼
       SQL Agent: CAS.Job_TrackCoverTemps   (every 30 seconds)
        │  Routes its source query automatically:
        │    - If the Covers DB is on THIS instance → direct query
        │    - Otherwise → OPENQUERY([Leap_1], …)
        │  Maps device IDs → cover numbers via CAS.CoverSensorMap.
        ▼
   CAS.CoverTemps           ◄── time-series  (the "historian")
   CAS.CoverHeats           ◄── current state (placeholder rows + assignments)
                            │
                            ▼
   This screen + the Covers Temperatures screen
```

The job is documented in detail (logs, branches, observability, mapping
behaviour) in [`CLAUDE.md`](./CLAUDE.md).

---

## Operator quick-reference

**A reading isn't showing up for a cover.**
The thermocouple sends a value but you don't see it on the screen.

1. Check the most recent log:
   ```sql
   SELECT TOP 3 [Timestamp], [Message]
   FROM [SYSTEM].[Logs]
   WHERE Procedure_ = 'CAS.Job_TrackCoverTemps'
   ORDER BY [Timestamp] DESC;
   ```
   The message tells you (a) which environment branch fired, (b) how many
   rows landed, (c) how many `IsSensorLive` flips happened, and (d) how
   many devices on the source aren't yet in the map. *Unmapped devices*
   is the typical reason a new sensor is silent.

2. If the device IS in the map but no rows are arriving — the gateway is
   probably offline. Check `Covers.dbo.Reading` directly on the dev box;
   if it's not getting new rows there, the gateway side needs attention,
   not this app.

**A cover went stale ("L" status).**
The cover has had a heat assigned but the screen shows `Status = L` and
the temperatures haven't updated. The job's 30-min freshness rule flipped
`IsSensorLive` to 0 because no reading arrived in that window. Either the
device went offline or the gateway lost contact. Once readings resume,
the next tick flips it back to 1 (and the log records "1 IsSensorLive
flips").

**A heat shows the wrong start time.**
`Started` is shown in local time; it's stored as UTC. If you manually
chose a Start time when you assigned and it looks off:

- The modal asks for **local** time.
- We pass the user's timezone code (`app.models.user.get('timezoneCode')`)
  along with the modal value.
- `UpdateCoverHeat` does `IH.ToUtc(@Started, @timeZoneCode)` before
  storing.

If your account's timezone is misconfigured this is where it'll show.

---

## Mapping new covers (bulk seed)

The job now **auto-maps** new covers on its own: when it sees a source
device named `Cover NN` that isn't in `CAS.CoverSensorMap`, it inserts the
mapping on the next tick (see [`CLAUDE.md`](./CLAUDE.md) → *auto-map*). So in
the normal case you don't have to do anything — a new `Cover NN` device just
appears on the screen within ~30 s.

The manual script below is only needed to **back-fill** before the next tick,
or to map a device whose name doesn't follow the `Cover NN` pattern. It's
re-runnable; run it on the MES database (it auto-routes to the `Covers`
source: local on dev, `OPENQUERY([Leap_1])` on prod). Set `@commit = 0` first
to preview.

```sql
/* Seed CAS.CoverSensorMap from source devices named 'Cover NN' (one sensor
   per cover). Re-runnable. @commit = 0 previews; @commit = 1 inserts. */
SET NOCOUNT ON;
DECLARE @commit BIT = 1;   -- 0 = preview only, 1 = perform INSERT

-- 1) Pull candidate devices from the source (env-agnostic, like the job)
CREATE TABLE #Dev (DeviceId BIGINT NOT NULL, DeviceName NVARCHAR(200) NULL);

IF DB_ID('Covers') IS NOT NULL
    INSERT INTO #Dev (DeviceId, DeviceName)
    EXEC sp_executesql N'
        SELECT DISTINCT CAST(dev.Id AS BIGINT), dev.Name
        FROM [Covers].dbo.Device dev
        WHERE dev.Name LIKE ''Cover%'' AND dev.Name LIKE ''%[0-9]%''';
ELSE
    INSERT INTO #Dev (DeviceId, DeviceName)
    EXEC sp_executesql N'
        SELECT src.DeviceId, src.DeviceName
        FROM OPENQUERY([Leap_1], ''
            SELECT DISTINCT CAST(dev.Id AS BIGINT) AS DeviceId, dev.Name AS DeviceName
            FROM [Covers].dbo.Device dev
            WHERE dev.Name LIKE ''''Cover%'''' AND dev.Name LIKE ''''%[0-9]%''''
        '') AS src';

-- 2) Parse Name -> (CoverNumber, DisplayName). One device per cover.
;WITH parsed AS (
    SELECT DeviceId, DeviceName,
           SUBSTRING(DeviceName, PATINDEX('%[0-9]%', DeviceName), 50) AS Tail
    FROM #Dev
),
candidates AS (
    SELECT DeviceId, DeviceName,
           TRY_CAST(LEFT(Tail, PATINDEX('%[^0-9]%', Tail + 'X') - 1) AS INT) AS CoverNumber
    FROM parsed
)
SELECT DeviceId, DeviceName, CoverNumber,
       'Cover ' + CAST(CoverNumber AS VARCHAR(10)) AS DisplayName,
       ROW_NUMBER() OVER (PARTITION BY CoverNumber ORDER BY DeviceId) AS rnCover
INTO #Candidates
FROM candidates
WHERE CoverNumber IS NOT NULL;

-- 3) Preview the new rows (and surface anything skipped)
PRINT '--- NEW rows to insert ---';
SELECT c.DeviceId, c.CoverNumber, c.DisplayName, c.DeviceName
FROM #Candidates c
WHERE c.rnCover = 1
  AND NOT EXISTS (SELECT 1 FROM CAS.CoverSensorMap m WHERE m.DeviceId = c.DeviceId)
  AND NOT EXISTS (SELECT 1 FROM CAS.CoverSensorMap m WHERE m.CoverNumber = c.CoverNumber)
ORDER BY c.CoverNumber;

PRINT '--- Cover already held by a DIFFERENT device (skipped - review!) ---';
SELECT c.DeviceId, c.DeviceName, c.CoverNumber, m.DeviceId AS ExistingDeviceId
FROM #Candidates c
JOIN CAS.CoverSensorMap m ON m.CoverNumber = c.CoverNumber
WHERE c.rnCover = 1 AND m.DeviceId <> c.DeviceId
ORDER BY c.CoverNumber;

-- 4) Insert (guarded; idempotent)
IF @commit = 1
BEGIN
    INSERT INTO CAS.CoverSensorMap (DeviceId, CoverNumber, DisplayName, Active)
    SELECT c.DeviceId, c.CoverNumber, c.DisplayName, 1
    FROM #Candidates c
    WHERE c.rnCover = 1
      AND NOT EXISTS (SELECT 1 FROM CAS.CoverSensorMap m WHERE m.DeviceId = c.DeviceId)
      AND NOT EXISTS (SELECT 1 FROM CAS.CoverSensorMap m WHERE m.CoverNumber = c.CoverNumber);
    PRINT '--- Inserted ' + CAST(@@ROWCOUNT AS VARCHAR(10)) + ' new row(s). ---';
END
ELSE PRINT '--- DRY RUN (@commit = 0): nothing inserted. ---';

DROP TABLE #Candidates;
DROP TABLE #Dev;
```

After it runs, the next job tick (within 30 s) creates the placeholder
`CoverHeats` rows and starts populating temperatures; the realtime grid
picks the new covers up automatically.

---

## Backfilling temperature history

The job only writes the **latest** reading per cover on each tick, so a
freshly rebuilt `CAS.CoverTemps` (e.g. after dropping and recreating it)
starts empty. To preload the last N hours of history per cover straight from
the source `Covers` DB, run the **backfill script** documented in
[`CLAUDE.md`](./CLAUDE.md) → *Backfill `CoverTemps` history from the source*.

Run it **after** `CoverSensorMap` is seeded (unmapped devices are skipped),
adjust the window via `@hoursBack` (default 12), and re-run freely — it only
inserts rows that aren't already there.

---

## Files in this folder

```
covers-report/
├── README.md                ← you are here
├── CLAUDE.md                ← agent-oriented technical reference
├── covers-report.aspx       ← ASPX template (HTML scaffolding + i18n)
├── covers-report.js         ← screen logic: 2 grids, modals, refresh tick
├── covers-report.sql        ← roles, menu route, SP permissions (one-time deploy)
└── App_LocalResources/      ← .resx localization
```

---

## Related code outside this folder

| Path | Role |
|---|---|
| `Website/.../app/pages/caster/covers-temperatures/` | Sister screen — pivoted temperatures monitor |
| `Website/.../app/components/modal-covers/` | The Assign / Unassign / Edit-Alert modal |
| `Website/.../app/components/modal-temperatures-history/` | Per-cover history chart modal (📈 icon) |
| `Website/.../app/custom-screens/melt-shop-overview/` | Must Under Cover box (reads this subsystem via `LAD.GetMeltshopOverviewWeb` Table7) |
| `mes-db/CAS/Tables/CoverTemps.sql` | Temperature time-series |
| `mes-db/CAS/Tables/CoverHeats.sql` | Current assignments + flags |
| `mes-db/CAS/Tables/CoverHeatsEvents.sql` | Append-only audit log |
| `mes-db/CAS/Tables/CoverSensorMap.sql` | DeviceId → CoverNumber routing |
| `mes-db/CAS/Stored Procedures/Job_TrackCoverTemps.sql` | The Agent job |
| `mes-db/CAS/Stored Procedures/UpdateCoverHeat.sql` | Assign / Unassign / Edit-Alert |
| `mes-db/CAS/Stored Procedures/GetCoversReport.sql` | Feeds the two grids on this screen |
| `mes-db/CAS/Stored Procedures/GetAvailableCovers.sql` | Feeds the modal dropdowns |
| `mes-db/CAS/Stored Procedures/GetLastCoverTemperatures.sql` | Feeds the sister screen |
| `mes-db/CAS/Stored Procedures/GetTemperaturesByCover.sql` | Feeds the per-cover chart modal |

---

## Glossary

| Term | Meaning |
|---|---|
| **Cover** | Insulated lid placed on a ladle to retain heat |
| **Heat** | A batch of liquid steel identified by a heat number (e.g. `H123456`) |
| **Under cover** | The ladle currently has a cover on it |
| **Time under cover** | Duration between Assign and Unassign (or to "now" if still assigned) |
| **Cover number** | Internal numeric ID of a physical cover (e.g. Cover 2, Cover 3) |
| **Device ID** | The thermocouple sensor's serial number on the wireless gateway |
| **Gateway** | The wireless receiver that collects readings from the cover thermocouples |
| **Leap_1** | The linked server name pointing at the host that runs the Covers DB in production |
| **Out of Cover Alert** | Threshold in hours; if a heat stays under cover longer than this, the row turns red on the realtime grid |
| **IsSensorLive** | DB flag; set by the job to 1 if the cover got a reading in the last 30 min, 0 otherwise |
| **isAvailable** | DB flag; set by `UpdateCoverHeat`; 0 means the cover is at its capacity limit (10 simultaneous heats) |
