components:
  schemas:
    alert:
      properties:
        code:
          description: |
            `SETPOINT_UNREACHABLE`：区間の設定値を丸めて実行している。制約となった条件を `detail` に書く。
            `GRID_ABNORMAL`：系統の喪失、保護継電器のトリップ、連系点での電圧・周波数の逸脱。
            `OTHER`：上記以外。内容を `detail` に書く。
          enum:
            - BATTERY_COMM_LOST
            - PV_COMM_LOST
            - METER_COMM_LOST
            - CURTAILMENT_COMM_LOST
            - BATTERY_OVER_TEMPERATURE
            - BATTERY_FAULT
            - PCS_FAULT
            - GRID_ABNORMAL
            - SETPOINT_UNREACHABLE
            - OTHER
          type: string
        component:
          description: アラートが出ている機器。同じ `code` が複数の機器で出る場合に区別するために使う。例：`PCS1`、`BMS`。
          example: PCS1
          type: string
        detail:
          description: 運用担当者向けの自由記述。
          type: string
        severity:
          description: '`FAULT`：設備の一部または全部が動作できない。`WARNING`：動作は続いているが、放置すると問題になる。J-EMS は `FAULT` を緊急、`WARNING` を通常の優先度で通知する。'
          enum:
            - WARNING
            - FAULT
          type: string
        since:
          description: その状態になった時刻。
          example: "2026-10-01T10:00:00+09:00"
          format: date-time
          type: string
      required:
        - code
        - severity
        - since
      type: object
    curtailment_slot:
      properties:
        end:
          example: "2026-10-01T10:30:00+09:00"
          format: date-time
          type: string
        limit_percent:
          description: 許容される出力。連系容量に対する割合。
          maximum: 100
          minimum: 0
          type: number
        start:
          example: "2026-10-01T10:00:00+09:00"
          format: date-time
          type: string
      required:
        - start
        - end
        - limit_percent
      type: object
    desired_schedule:
      description: 指定した期間のスケジュール。EMS は受け取ったら、手元のスケジュールを丸ごと置き換える。
      properties:
        horizon_end:
          description: 最後の区間の終了時刻（この時刻は含まない）。EMS はこの時刻を過ぎたら、新しいスケジュールを受け取るまで `IDLE` を保つ。
          example: "2026-10-01T13:00:00+09:00"
          format: date-time
          type: string
        horizon_start:
          description: 最初の区間の開始時刻。現在時刻を含む区間の開始時刻になる。
          example: "2026-10-01T10:00:00+09:00"
          format: date-time
          type: string
        issued_at:
          description: J-EMS がこのスケジュールを返した時刻。参考情報。
          example: "2026-10-01T10:00:00+09:00"
          format: date-time
          type: string
        operating_state:
          description: |
            `RUNNING`：区間に従って運転する。
            `STOPPED`：PCS を停止状態にし、`RUNNING` のスケジュールが届くまで区間を無視する。
          enum:
            - RUNNING
            - STOPPED
          type: string
        poll_interval_seconds:
          description: 以後 `desired` を呼び出す間隔の上限（秒）。EMS はこれより長い間隔を空けない。短くするのは自由だが、1 秒に1回までとする。
          example: 10
          maximum: 300
          minimum: 1
          type: integer
        slots:
          description: '`start` の昇順に並ぶ。最初の区間の `start` は `horizon_start` と一致し、どの区間の `start` も `horizon_end` より前にある。'
          items:
            $ref: '#/components/schemas/slot'
          maxItems: 1440
          minItems: 1
          type: array
        version:
          description: このスケジュールの中身を表す識別子で、`ETag` と同じ値。中身が同じなら同じ値になり、違えば違う値になる。大小の比較はできない。
          example: b41e08d3
          type: string
      required:
        - version
        - issued_at
        - poll_interval_seconds
        - horizon_start
        - horizon_end
        - operating_state
        - slots
      type: object
    error:
      properties:
        code:
          example: UNAUTHORIZED
          type: string
        detail:
          type: string
        errors:
          description: 400 のときの、フィールド単位の問題。
          items:
            $ref: '#/components/schemas/validation_error'
          type: array
      required:
        - code
        - detail
      type: object
    ratings:
      description: 設備が現時点でできること。劣化やストリングの停止、BMS が許容する充放電電力の変化などで値が変わったら、その時点で報告する。
      properties:
        max_charge_kw:
          example: 2000
          minimum: 0
          type: number
        max_discharge_kw:
          example: 2000
          minimum: 0
          type: number
        usable_energy_kwh:
          description: 劣化を考慮した、現時点で空から満充電までに使える電力量。`battery_soc_percent` はこれに対する割合。
          example: 2800
          minimum: 0
          type: number
      required:
        - max_charge_kw
        - max_discharge_kw
        - usable_energy_kwh
      type: object
    reported_state:
      properties:
        active:
          description: 現在サイトを制御している EMS なら true。ホットスタンバイ構成では、true を報告するのはちょうど1台。スタンバイ機も `desired` を取得して状態を PUT するが、実行はしない。
          type: boolean
        alerts:
          description: 現在発生中のアラートすべて。空配列は正常を意味する。解消したアラートは次の PUT から外すだけでよい。
          items:
            $ref: '#/components/schemas/alert'
          type: array
        applied_version:
          description: 現在実行しているスケジュールの `version`（ETag と同じ値）。最初のスケジュールを受理するまでは入れない。
          example: b41e08d3
          type: string
        curtailment:
          description: EMS が一般送配電事業者から受け取った、実際に効く出力制御スケジュール（固定スケジュールと更新スケジュールを統合済みのもの）。把握しているスケジュール全体を毎回送る。太陽光を併設しないサイトでも出力制御の対象になりうるので、すべてのサイトで必須とする。受け取っているスケジュールがない場合は空配列を送る。
          items:
            $ref: '#/components/schemas/curtailment_slot'
          type: array
        frequency_control_active:
          description: 一次調整力の制御が実際に有効になっているか。スケジュールの指示ではなく、PCS に設定されている状態を報告する。J-EMS はこれとスケジュールを突き合わせて、予定外の作動と未作動を検知する。
          type: boolean
        operating_state:
          description: PCS の実際の状態。`FAULT` はスケジュールに従えない状態で、理由は `alerts` に入れる。
          enum:
            - RUNNING
            - STOPPED
            - FAULT
          type: string
        ratings:
          $ref: '#/components/schemas/ratings'
        rejected_version:
          description: 直近に取得して拒否したスケジュールの ETag。ETag はヘッダーから取れるので、本文を解析できなかった場合でも分かる。その後に別のスケジュールを受理したら入れない。
          type: string
        rejection_errors:
          description: '`rejected_version` で見つかった問題をすべて列挙する（最初の1件だけにしない）。クラウドが1往復で直せるようにするため。'
          items:
            $ref: '#/components/schemas/validation_error'
          type: array
        reported_at:
          description: EMS がこの状態をまとめた時刻。
          example: "2026-10-01T10:00:00+09:00"
          format: date-time
          type: string
        software_version:
          description: EMS のソフトウェアバージョン。問い合わせ対応に使う。
          example: acme-ems 4.2.1
          type: string
      required:
        - reported_at
        - active
        - operating_state
        - frequency_control_active
        - software_version
        - ratings
        - alerts
        - curtailment
      type: object
    slot:
      description: |
        `start` から次の区間の `start` まで（最後の区間は `horizon_end` まで）の間にすること。`mode` によって、ほかのどのフィールドが入るかが決まる。

        | mode | 必須フィールド | 意味 |
        | --- | --- | --- |
        | `IDLE` | なし | 蓄電池を 0 kW に保つ。 |
        | `POWER` | `power_kw` | 電力の設定値を保つ。`reference_point` を省略したときは蓄電池での値。 |
        | `FREQUENCY_CONTROL` | `fcr_capacity_kw`、`power_kw` | 蓄電池での基準出力 `power_kw` を中心に、最大 `fcr_capacity_kw` の一次調整力を提供する。 |

        そのモードに挙げていないフィールドは入らない。
      properties:
        fcr_capacity_kw:
          description: その区間に約定した一次調整力の容量。
          example: 1500
          minimum: 0
          type: number
        mode:
          enum:
            - IDLE
            - POWER
            - FREQUENCY_CONTROL
          type: string
        power_kw:
          description: '`POWER` では設定値、`FREQUENCY_CONTROL` では蓄電池での基準出力。正は放電・逆潮流。'
          example: -500
          type: number
        reference_point:
          default: BATTERY
          description: |
            `POWER` モードで `power_kw` をどこで満たすか。省略したときは `BATTERY`。
            `BATTERY`：蓄電池そのもの。併設の発電はそのまま通す。
            `METER`：系統の計量点での正味の潮流。差は蓄電池が吸収または供給する。例：出力制御中に `0` で逆潮流ゼロ。
          enum:
            - BATTERY
            - METER
          type: string
        start:
          description: 区間の開始時刻。分の境界（秒が 0）にそろう。
          example: "2026-10-01T10:00:00+09:00"
          format: date-time
          type: string
      required:
        - start
        - mode
      type: object
    telemetry_batch:
      properties:
        samples:
          items:
            $ref: '#/components/schemas/telemetry_sample'
          maxItems: 3600
          minItems: 1
          type: array
      required:
        - samples
      type: object
    telemetry_result:
      properties:
        accepted:
          description: 新たに保存したサンプル数。
          type: integer
        duplicates:
          description: 同じ内容で保存済みだったサンプル数。成功として扱う。
          type: integer
        rejected:
          items:
            properties:
              error:
                $ref: '#/components/schemas/validation_error'
              index:
                description: リクエストの `samples` 配列内の位置。
                type: integer
              sample_id:
                description: 読み取れる `sample_id` がなかったサンプルでは入らない。
                type: string
            required:
              - index
              - error
            type: object
          type: array
      required:
        - accepted
        - duplicates
        - rejected
      type: object
    telemetry_sample:
      properties:
        aggregation:
          default: INSTANT
          description: '`AVERAGE`：`[ts, ts + window)` の平均値。`MIN`：同じ期間の最小値。'
          enum:
            - INSTANT
            - AVERAGE
            - MIN
          type: string
        metric:
          description: |
            | metric | 必須 | 最低サンプリング間隔 | 備考 |
            | --- | --- | --- | --- |
            | `battery_power_kw` | 必須 | 1 分。一次調整力の区間は 1 秒 | 正は放電。 |
            | `battery_soc_percent` | 必須 | 1 分 | `ratings.usable_energy_kwh` に対する割合。 |
            | `meter_import_energy_kwh` | 必須 | 1 分 | 系統の計量点の、設置以来の積算。計量器の交換時を除き減らない。 |
            | `meter_export_energy_kwh` | 必須 | 1 分 | 系統の計量点の、設置以来の積算。 |
            | `grid_frequency_hz` | 必須 | 1 分（`aggregation: MIN`）。一次調整力の区間は 1 秒 | 連系点で実測する。分解能 0.01 Hz 以上。1 分ごとの値は、その1分間の最小値を送る。瞬時値では数秒の周波数低下を見逃すため。 |
            | `meter_power_kw` | 任意 | 1 分 | 系統の計量点での正味。正は逆潮流。一次調整力に受電点で参入するサイトでは、一次調整力の区間の 1 秒値が必須。 |
            | `pv_power_kw` | 任意 | 1 分 | 太陽光のあるサイトで送る。 |

            「一次調整力の区間」は `mode: FREQUENCY_CONTROL` の区間を指す。その区間の 1 秒ごとの電力と周波数は、一次調整力のアセスメントⅡで応動を評価する材料になる。欠けた秒があるとその分だけ評価に使える点が減るので、後から送る分も含めて欠かさず送る。
          enum:
            - battery_power_kw
            - meter_power_kw
            - pv_power_kw
            - battery_soc_percent
            - meter_import_energy_kwh
            - meter_export_energy_kwh
            - grid_frequency_hz
          type: string
        sample_id:
          description: 計測したときに一度だけ生成し、再送のたびに同じものを使う。
          format: uuid
          type: string
        test_mode:
          default: false
          description: サイトが注入した模擬周波数で事前審査の試験をしている間は true。こうしたサンプルは運用データと分けて扱う。
          type: boolean
        ts:
          description: 計測時刻。`AVERAGE` と `MIN` の場合は集計した期間の開始時刻。
          example: "2026-10-01T10:00:00+09:00"
          format: date-time
          type: string
        value:
          example: 1480.5
          type: number
        window:
          description: 集計した期間の長さ（ISO 8601 の期間表記）。`aggregation` が `AVERAGE` または `MIN` のときは必須。
          example: PT1S
          type: string
      required:
        - sample_id
        - metric
        - ts
        - value
      type: object
    validation_error:
      properties:
        code:
          description: |
            `TIME_WINDOW_INVALID`：区間の `start` が昇順でない、分の境界にない、最初の区間の `start` が `horizon_start` と一致しない、`horizon_end` 以降にある。出力制御スケジュールでは `start >= end`。
            `EXPIRED`：`horizon_end` がすでに過ぎている。
            `DUPLICATE`：同じ `sample_id` が別の内容に使われた。
            `INTERNAL_ERROR`：受信側の障害。そのまま再試行する価値があるのはこのコードだけ。
          enum:
            - MALFORMED
            - MISSING_FIELD
            - TYPE_MISMATCH
            - OUT_OF_RANGE
            - TIME_WINDOW_INVALID
            - EXPIRED
            - DUPLICATE
            - INTERNAL_ERROR
          type: string
        detail:
          type: string
        field_path:
          description: 問題のあるフィールドへのパス。
          example: slots[2].power_kw
          type: string
      required:
        - code
        - detail
      type: object
  securitySchemes:
    bearerAuth:
      description: 'J-EMS が EMS ごとに発行する API トークン。`Authorization: Bearer <token>` で送る。'
      scheme: bearer
      type: http
info:
  description: |
    サイトに設置された EMS が、J-EMS Cloud から制御スケジュールを受け取り、状態と計測値を返すためのプロトコルである。

    この API では、サイトに設置されて J-EMS と通信する機器またはソフトウェアを、EMS 本体か、EMS を外部につなぐゲートウェイかを問わず、まとめて EMS と呼ぶ。EMS は蓄電池・PCS・計量器とつながり、それらを制御・計測する。1サイトには通常1台の EMS があり、予備機を常時接続しておくホットスタンバイ構成では2台になる。サイトはパスの `site_id` で指定し、どの EMS からの呼び出しかは J-EMS が認証トークンで判別する。EMS の側で EMS 自身の ID を扱う必要はない。

    この API の OpenAPI 定義（YAML）は [site-control.yml](/openapi/site-control.yml) からダウンロードできる。クライアントコードの生成や、リクエストの検証に使える。

    通信はすべて EMS から外向きの HTTPS で始まるので、EMS の側に受信ポートも VPN もメッセージブローカーも用意する必要がない。

    ## API の呼び出し

    J-EMS が EMS に制御の指示を渡す手段は、`GET /desired` で取得する desired スケジュールだけである。desired スケジュールは、EMS が指定した期間（現在から最大 24 時間先まで、省略時は 3 時間先まで）について、どの時点で何をするかを書き切ったものである。EMS は受け取るたびに、手元のスケジュールを丸ごと置き換える。状態と計測値は、EMS からの2つの呼び出しで返す。

    | 呼び出し | 向き | 意味 |
    | --- | --- | --- |
    | `GET /desired` | クラウド → EMS | 指定した期間の desired スケジュール。ポーリングで取得し、中身が変わっていなければ `304` が返る。 |
    | `PUT /reported` | EMS → クラウド | EMS の現在の状態。PUT のたびに前回の状態を置き換える。 |
    | `POST /telemetry` | EMS → クラウド | 計測値。追記され、`sample_id` で重複が除かれる。 |

    ## EMS の処理の流れ

    EMS は、次の3つのやり取りをそれぞれ独立した周期で繰り返す。

    <div data-pagefind-ignore="all">

    <pre class="mermaid">
    sequenceDiagram
      participant EMS
      participant J as J-EMS
      loop poll_interval_seconds ごと
        EMS-&gt;&gt;J: GET /desired（horizon_hours、If-None-Match）
        alt 指定した期間の中身が変わっていない
          J--&gt;&gt;EMS: 304
        else 変わった
          J--&gt;&gt;EMS: 200 スケジュール（ETag）
          EMS-&gt;&gt;EMS: 全体を検証し、問題がなければ丸ごと置き換える
        end
      end
      loop 状態が変わったとき、および 60 秒ごと
        EMS-&gt;&gt;J: PUT /reported（applied_version など）
        J--&gt;&gt;EMS: 204
      end
      loop 計測のたび
        EMS-&gt;&gt;J: POST /telemetry（サンプルのバッチ）
        J--&gt;&gt;EMS: 200 サンプルごとの結果
      end
    </pre>

    </div>

    それぞれの手順の詳細は次のとおりである。

    1. 必要な期間を `horizon_hours` で指定し、保持しているスケジュールの ETag を `If-None-Match` に入れて `GET /desired` を呼ぶ。間隔は直近のスケジュールの `poll_interval_seconds` に従い、まだ1件も取得していなければ 10 秒とする。
    2. `200` が返ったら、スケジュール全体を検証する。問題がなければ、手元のスケジュールを捨てて、受け取ったスケジュールに一度に切り替える。一部でも不正ならスケジュール全体を拒否し、前のスケジュールの実行を続ける。
    3. 状態が変わるたびに `PUT /reported` を送り、変化がなくても 60 秒ごとには送る。J-EMS はこの PUT の途絶えで EMS の停止を検知する。
    4. 計測のたびに `POST /telemetry` を送り、オフラインの間はローカルに溜めておく。

    ## スケジュールの実行規則

    スケジュールは区間の並びである。各区間は、その `start` から次の区間の `start` まで続き、最後の区間は `horizon_end` まで続く。区間に終わりの時刻を持たせないのは、隣の区間との隙間や重なりが起こらない形にするためである。区間の開始は分の境界（秒が 0）にそろい、最初の区間の開始は `horizon_start` と一致する。

    J-EMS は、同じ指示が続く間を1区間にまとめ、指示が変わる時刻にだけ新しい区間を置く。コマの中で出力を分単位で変える場合（計画した電力量を計量点で達成するための配分など）も、J-EMS が分単位の `POWER` 区間に分解して送る。したがって EMS がコマ内の配分を計算することはなく、J-EMS も EMS が各分に何をするかを把握できる。

    受け取ったスケジュールは、手元のスケジュールを丸ごと置き換える。前回もっと長い期間を受け取っていても、今回の `horizon_end` より後ろの分は捨てる。古い分を残して新しい分とつなぎ合わせると、どちらの指示が効くかを EMS が決めることになり、スケジュールを1件だけ持つ意味がなくなるからである。

    スケジュールが区間の途中で届いた場合も、現在時刻を含む区間は即座に適用する。区間の開始を待つと、その区間の残りを前のスケジュールの指示で運転することになるからである。

    設備が実現できない設定値は拒否せず、実現できる最も近い値に丸めて実行し、`SETPOINT_UNREACHABLE` アラートで報告する。拒否すると蓄電池が前のスケジュールの指示のまま動き続け、クラウドが意図した方向とは逆の運転になることがあるため、丸めて近づけるほうを選ぶ。

    ## 通信が途絶えたときの動作

    J-EMS に接続できない間、EMS は最後に受理したスケジュールの実行を続ける。そして `horizon_end` で実行をやめ、新しいスケジュールが届くまで `IDLE`（0 kW）を保つ。手元のスケジュールを独自に延長することはしない。延長した運転は市場での約定と一致する保証がないからである。

    したがって、通信が途絶えても運転を続けられる時間は、最後に受け取った期間の長さで決まる。`horizon_hours` を省略した 3 時間では、通信が途絶えてから最大約 3 時間で `IDLE` になる。通信の途絶えで運転を止めたくない EMS は、`horizon_hours=24` で取得する。

    ## J-EMS が監視に使う情報

    J-EMS は、EMS から届く `reported` と `telemetry` をもとに、次の異常を検知して運用担当者に通知する。そのため、下の表で必須としている情報は、どの EMS も送る必要がある。必須と任意の区別は、各フィールドと `telemetry_sample.metric` の表にも記載している。

    | 検知する異常 | 使う情報 | 必須 |
    | --- | --- | --- |
    | EMS との通信断 | `PUT /reported` の受信間隔（60 秒ごと） | 必須 |
    | 設備の故障・警告 | `reported.alerts`（`severity`、`component`） | 必須 |
    | PCS を制御できない状態 | `reported.operating_state`、`reported.alerts` | 必須 |
    | スケジュールを実行していない | `reported.applied_version`、`reported.rejected_version` | 必須 |
    | 計画した電力量との乖離 | `meter_import_energy_kwh`、`meter_export_energy_kwh`（1 分） | 必須 |
    | 一次調整力の予定外の作動・未作動 | `reported.frequency_control_active` | 必須 |
    | 一次調整力の応動不良 | 一次調整力の区間の `battery_power_kw` と `grid_frequency_hz`（1 秒） | 必須 |
    | 系統周波数の異常 | `grid_frequency_hz`（1 分ごとの最小値） | 必須 |
    | SOC 不足の見込み | `battery_soc_percent`（1 分）、`reported.ratings` | 必須 |
    | 出力制御との矛盾 | `reported.curtailment` | 必須 |

    J-EMS は受け取った `reported` をすべて履歴として保存する。`reported` は状態が変わるたびに送る決まりなので、この履歴から状態の変化を時刻付きで追える。

    ## 符号・単位・時刻などの約束事

    - 電力の符号：正は系統に向かって出ていく向き、負は入ってくる向きを表す。`battery_power_kw` が正なら放電であり、計量点では正が逆潮流（送電）である。
    - 単位：フィールド名に含める（`_kw`、`_kwh`、`_percent`、`_hz`）。暗黙の単位はない。
    - 欠測：値を読めなかったサンプルは送らない。`0` を代わりに送ると、実測の 0 と区別できなくなる。
    - 時刻：時計は NTP で同期し、タイムスタンプには必ずオフセットを付ける。
    - 互換性：`/v1` の中ではフィールドを追加するだけで、削除や意味の変更はしない。EMS は知らないフィールドを無視する。
    - 認証：EMS 1台ごとに、そのサイトに限定した Bearer トークンを発行する。ホットスタンバイの予備機にも別のトークンを発行し、J-EMS はトークンで2台を区別する。発行と更新は J-EMS の運用担当が行う。

    ## MQTT によるコマンド配信との比較

    現場の EMS を最適化クラウドにつなぐ方法としては、MQTT によるコマンド配信がよく使われる。クラウドがスケジュールをコマンドとしてパブリッシュし、EMS はそれをサブスクライブして、応答用のトピックに確認応答を返す構成である。この節では、この構成と比べて EMS の側で何が要らなくなるかを示す。

    MQTT のコマンド配信で EMS の負担が大きくなる主な理由は、1通のコマンドが、タイムラインのうちそのコマンドが覆う時間帯だけを上書きする差分である点にある。EMS は、まだ期間の残っている以前のコマンドを保持し、優先度・発行時刻・到着順の規則で重ね合わせて、今どの指示が効いているかを自分で計算しなければならない。コマンドが届かなかった場合、EMS は前のスケジュールのまま運転を続けることになり、欠落に気付けるのはクラウドの側だけである。そのうえで、ブローカーとの常時接続と切断時の再接続も EMS が担う。

    この API では、クラウドが複数の計画の合成、優先順位付け、取り消しを済ませ、その結果を desired スケジュールとして渡す。EMS がすることは、最新のスケジュールを HTTPS で取得し、検証し、実行することだけである。

    以下では、MQTT のコマンド配信で EMS に必要になる実装を3種類に分けて、この API での扱いと並べる。

    ### スケジュールの合成

    コマンドが差分なので、どの時点にどの指示が効くかを EMS が決める必要がある。

    | MQTT のコマンド配信で EMS が実装すること | この API での扱い |
    | --- | --- |
    | 優先度の異なる複数のスケジュール（短期と長期など）を重ね合わせる | 合成済みのスケジュールが1件届く |
    | 同じ時間帯を覆う複数のコマンドから、発行時刻と到着順で採るものを決める | 最後に受け取ったスケジュールを使うだけ |
    | 新しいコマンドが覆わない時間帯を、古いコマンドで埋める | 計画期間のすべての時間帯が書かれている。何もしない時間帯も `IDLE` と明記される |
    | 取り消し専用のコマンドを解釈する | 取り消したい部分を含まないスケジュールが届くだけ |
    | 不正な部分を含むコマンドを全体ごと拒否する。一部だけ適用すると、置き換えたはずの古いコマンドが隙間の補完で復活するため | 拒否したら前のスケジュールを続けるだけ。古い指示が復活する仕組みがない |

    ### メッセージのやり取りの管理

    リクエストと応答が別々のメッセージになり、届いたかどうかを送り手が確認できないので、その突き合わせを EMS が担う。

    | MQTT のコマンド配信で EMS が実装すること | この API での扱い |
    | --- | --- |
    | 応答用のトピックに、どのコマンドへの応答かを示す ID を付けて返す。本文を解析できないときは代用の値を入れる | HTTP の応答がそのまま結果になる。受理・拒否は `reported` の `applied_version` と `rejected_version` で伝える。どのスケジュールかは `ETag` で分かる |
    | 同じコマンドが二度届いたときに、二重に適用しない | 中身が同じなら `304` が返るだけで、何も起きない |
    | ホットスタンバイの予備機には、届いたコマンドへの確認応答を返させない | 各 EMS が自分の状態を報告するだけ |
    | 届かなかったコマンドは、EMS からは気付けない | EMS が取りに行くので、取れなければ EMS 自身が気付く。J-EMS も EMS ごとの最終取得時刻を見られる |

    ### 接続と運用

    ブローカーとの接続を張り続け、その制約の中で送受信する必要がある。

    | MQTT のコマンド配信で EMS が実装すること | この API での扱い |
    | --- | --- |
    | ブローカーとの常時接続、セッションの維持、切断時の再接続 | 必要なときに HTTPS のリクエストを送るだけ |
    | EMS ごとの X.509 証明書の管理。1枚の証明書で張れる同時接続を1本に制限するブローカーもある | EMS ごとの Bearer トークン |
    | ブローカーのポート（通常 8883）への外向き TLS を、現場のネットワークで通す | 外向き HTTPS（443）。多くの社内プロキシを通過できる |
    | ペイロードの上限（128 KB が多い）や接続ごとの送信回数の上限に合わせて、送るデータを分割・間引く | 計測値は1リクエスト 3,600 件まで |
    | 異常系の試験のために、モックのブローカーを用意する。EMS の認証情報では、コマンド用トピックにパブリッシュできないことが多い | 任意の HTTP スタブから固定のスケジュールを返せば試験できる |

    ### どちらの方式でも残る要件

    一方で、次の要件は設備と系統に由来するので、どちらの方式でも EMS が実装する。

    - クラウドに接続できない間は最後に受理したスケジュールを続け、期限が来たら待機する。
    - 設備が実現できない設定値は丸め、アラートを上げる。
    - ホットスタンバイ構成では、どの EMS が稼働するかを EMS 同士で決める。
    - オフラインの間は計測値を溜め、元の ID のまま後から送る。
    - 一次調整力の 1 秒データは周波数と秒単位で突き合わされるので、時計を NTP で合わせる。

    ### MQTT のほうが有利な点

    MQTT のコマンド配信が上回るのは、新しいスケジュールが EMS に届くまでの速さである。MQTT ではほぼ即時に届くのに対し、この API では、スケジュールの変更が EMS に届くまで最大で `poll_interval_seconds` 秒遅れる。遅れを縮めたい時間帯（一次調整力のコマ、需給調整市場の指令が来うる時間帯など）には、J-EMS がスケジュールに含める `poll_interval_seconds` を小さくし、EMS はその間隔で取得する。新しい間隔も EMS が次に取得したときに届くので、J-EMS はその時間帯より前に値を切り替える。EMS の側で追加の設定や実装は要らない。
  title: J-EMS サイト制御 API
  version: 1.0.0
openapi: 3.0.3
paths:
  /sites/{site_id}/desired:
    get:
      description: |
        EMS が定期的に呼び出して、サイトで実行すべき最新のスケジュールを、指定した期間の分だけ取得する。スケジュールはサイトに1つで、同じサイトの EMS には、同じ期間を指定すれば同じものが返る。

        期間は `horizon_hours` で指定する。返るのは、現在時刻を含む区間の開始から、現在時刻の `horizon_hours` 時間後を含む区間の終わりまでである。終わりを区間の境界にそろえるのは、呼ぶたびに期間の終わりが秒単位でずれて中身が変わり、`304` が返らなくなるのを防ぐためである。J-EMS の計画がまだ決まっていない時間帯（翌日の市場が約定する前など）は含まれず、その場合 `horizon_end` は指定より手前になる。

        毎回スケジュール全体を受け取る必要はない。前回受け取った ETag を `If-None-Match` に入れて呼び出せば、指定した期間の中身が変わっていないときは本文のない `304` が返る。EMS はそのまま今のスケジュールの実行を続ければよい。ETag は返した期間の中身ごとに付くので、期間の中の計画が変わったときのほか、時間が進んで期間が区間1つ分ずれたときにも変わる。

        1回の応答に含まれる区間の数は、期間の中で指示が変わる回数で決まる。通常は1日あたり数十件で、最も多い場合でも期間の分数（24 時間で 1,440 件）を超えない。`Accept-Encoding: gzip` を付けて呼び出せば、J-EMS は応答を圧縮して返す。

        呼び出す間隔は、直近に受け取ったスケジュールの `poll_interval_seconds` を上限とする。J-EMS は、指令を早く届けたい時間帯の前にこの値を小さくすることがある。EMS は自分の判断でこれより短い間隔で呼び出してもよいが、同じ EMS からの呼び出しは 1 秒に1回までとする。それより頻繁な呼び出しには `429` が返る。
      operationId: get_desired
      parameters:
        - description: サイト ID。サイトの登録時に J-EMS が発行する。
          in: path
          name: site_id
          required: true
          schema:
            example: st_7k2m9q
            type: string
        - description: EMS が保持しているスケジュールの ETag。前回の応答の `ETag` をそのまま送る。
          in: header
          name: If-None-Match
          required: false
          schema:
            example: '"9f2c1a7e"'
            type: string
        - description: 取得する期間。現在時刻から何時間先までを含めるか。通信が途絶えたときに運転を続けられる時間もこの長さで決まる。
          in: query
          name: horizon_hours
          required: false
          schema:
            default: 3
            maximum: 24
            minimum: 1
            type: integer
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/desired_schedule'
          description: 指定した期間のスケジュール。`If-None-Match` と中身が異なるとき、またはヘッダーがないときに返る。
          headers:
            ETag:
              description: このスケジュールの中身を表す識別子（引用符付き）。本文の `version` と同じ値。次回の `If-None-Match` にそのまま送る。
              schema:
                example: '"b41e08d3"'
                type: string
        "304":
          description: 指定した期間の中身は `If-None-Match` と同じ。今のスケジュールの実行を続ける。
        "400":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: '`horizon_hours` が 1〜24 の範囲にない。'
        "401":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: Bearer トークンがない、または無効。
        "403":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: トークンは有効だが、このサイト向けに発行されたものではない。
        "404":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: サイトが存在しない。
        "429":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: リクエストが多すぎる。指数バックオフで再試行する。
        "500":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: サーバーの一時的な障害。指数バックオフで再試行する。
      summary: スケジュールを取得する
      tags:
        - エンドポイント
  /sites/{site_id}/reported:
    put:
      description: |
        EMS が自分の現在の状態を J-EMS に報告する。報告するのは、どのスケジュールを実行しているか、PCS の運転状態、設備の能力、発生中のアラート、出力制御のスケジュールである。

        呼び出すたびに、J-EMS が保持しているその EMS の状態は丸ごと置き換わる。そのため変わった項目だけでなく、毎回すべての項目を送る。どの EMS からの報告かは、J-EMS が認証トークンで判別する。

        報告は、いずれかの項目が変わったときに送り、変化がなくても 60 秒ごとには送る。J-EMS は、180 秒報告が届かない EMS をオフラインとみなす。

        取得したスケジュールを受理したか拒否したかも、この報告の `applied_version` と `rejected_version` で伝える。受理・拒否を伝える専用の呼び出しはない。
      operationId: put_reported
      parameters:
        - description: サイト ID。サイトの登録時に J-EMS が発行する。
          in: path
          name: site_id
          required: true
          schema:
            example: st_7k2m9q
            type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/reported_state'
        required: true
      responses:
        "204":
          description: 保存した。
        "400":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: 本文が検証に失敗した。何も保存していない。
        "401":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: Bearer トークンがない、または無効。
        "403":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: トークンは有効だが、このサイト向けに発行されたものではない。
        "404":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: サイトが存在しない。
        "429":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: リクエストが多すぎる。指数バックオフで再試行する。
        "500":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: サーバーの一時的な障害。指数バックオフで再試行する。
      summary: 状態を報告する
      tags:
        - エンドポイント
  /sites/{site_id}/telemetry:
    post:
      description: |
        EMS が計測値を J-EMS に送る。1回の呼び出しで、複数のサンプルをまとめて送れる（最大 3,600 件）。

        受理するか拒否するかはサンプルごとに決まる。不正なサンプルが1件あっても、残りのサンプルは保存される。

        各サンプルには、計測したときに一度だけ `sample_id` を付ける。J-EMS はこの ID で重複を見分けるので、送信に失敗したときは同じ内容をそのまま送り直してよい。保存済みのサンプルは重複として数えられ、エラーにはならない。ただし、同じ `sample_id` を別の内容に使うと `DUPLICATE` で拒否される。

        通信が途絶えている間は、未送信のサンプルを最低 60 日分保持しておき、接続が戻ったら送る。送る順序は最新の計測値を先に、溜めていた分を後にする。遅れて届いたサンプルも受け付ける。

        どの計測値をどの間隔で送るかは、`telemetry_sample.metric` の表にまとめている。
      operationId: post_telemetry
      parameters:
        - description: サイト ID。サイトの登録時に J-EMS が発行する。
          in: path
          name: site_id
          required: true
          schema:
            example: st_7k2m9q
            type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/telemetry_batch'
        required: true
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/telemetry_result'
          description: サンプルごとの結果。再送するのは `rejected` のうち、再試行できるコード（`INTERNAL_ERROR`）のものだけ。
        "400":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: 本文が有効なバッチではない（JSON でない、または `samples` 配列がない）。何も保存していない。
        "401":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: Bearer トークンがない、または無効。
        "403":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: トークンは有効だが、このサイト向けに発行されたものではない。
        "404":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: サイトが存在しない。
        "413":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: サンプルが 3,600 件を超えた、または本文が 1 MiB を超えた。バッチを分割する。
        "429":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: リクエストが多すぎる。指数バックオフで再試行する。
        "500":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/error'
          description: サーバーの一時的な障害。指数バックオフで再試行する。
      summary: 計測値を送信する
      tags:
        - エンドポイント
security:
  - bearerAuth: []
servers:
  - url: https://{host}/site-control/v1
    variables:
      host:
        default: api.example.com
        description: 環境（ステージング／本番）ごとに、登録時に通知する。
tags:
  - name: エンドポイント
