///|
fn volume_sequence_optional(value : Double?) -> Json {
  match value {
    Some(number) => number.to_json()
    None => Json::null()
  }
}

///|
fn volume_h2_observation(analysis : Analysis, frame : Int, index : Int) -> Json {
  let interval = analysis.intervals[index]
  {
    "frame": frame,
    "interval_index": index,
    "birth": interval.birth,
    "death": volume_sequence_optional(interval.death),
    "representative_face_count": interval.representative.length(),
  }
}

///|
/// Compare an ordered sequence of small 3D vertex-valued grids. H2 tracks
/// follow one optimal diagram matching and do not identify physical objects.
pub fn volume_sequence_json(text : String) -> Json raise TopologyError {
  if text.length() > 1000000 {
    raise TopologyError("volume sequence exceeds one million characters")
  }
  let parsed = @json.parse(text) catch {
    _ => raise TopologyError("invalid volume sequence JSON")
  }
  let root = batch_object(parsed, "volume sequence")
  batch_fields(
    root,
    ["kind", "threshold", "max_cells", "frames"],
    "volume sequence",
  )
  if batch_string(root, "kind") != "volume_sequence" {
    raise TopologyError("kind must be volume_sequence")
  }
  let threshold = number_field(root, "threshold")
  if !finite(threshold) || threshold.abs() > 1.0e100 {
    raise TopologyError("volume sequence threshold must be bounded and finite")
  }
  let budget = integer_field(root, "max_cells", 4000)
  check_budget(0, budget)
  let frame_values = batch_array(root, "frames", true)
  if frame_values.length() < 2 || frame_values.length() > 8 {
    raise TopologyError("volume sequence requires 2..8 frames")
  }
  let analyses : Array[Analysis] = []
  let names : Array[String] = []
  let seen : Map[String, Bool] = Map([])
  let frames : Array[Json] = []
  let mut total_cells = 0
  let mut shape : Array[Int] = []
  for value in frame_values {
    let fields = batch_object(value, "volume frame")
    batch_fields(fields, ["name", "volume"], "volume frame")
    let name = batch_string(fields, "name")
    batch_name(name)
    if seen.contains(name) {
      raise TopologyError("duplicate volume frame name: " + name)
    }
    seen[name] = true
    let volume : Array[Array[Array[Double]]] = @json.from_json(
      required_field(fields, "volume"),
    ) catch {
      _ => raise TopologyError("volume frame must contain number matrices")
    }
    let analysis = analyze(
      cubical_volume(volume, threshold~, max_cells=budget),
      max_homology_dimension=2,
    )
    let current_shape = [
      volume.length(),
      volume[0].length(),
      volume[0][0].length(),
    ]
    if !shape.is_empty() && shape != current_shape {
      raise TopologyError("volume sequence frames must have the same shape")
    }
    shape = current_shape
    total_cells += analysis.filtration.cells.length()
    if total_cells > 24000 {
      raise TopologyError("volume sequence exceeds 24000 total cells")
    }
    frames.push({
      "index": frames.length(),
      "name": name,
      "cell_count": analysis.filtration.cells.length(),
      "h0_at_cutoff": betti(analysis, 0, threshold),
      "h1_at_cutoff": betti(analysis, 1, threshold),
      "h2_at_cutoff": betti(analysis, 2, threshold),
      "h2_intervals": analysis.intervals
      .filter(fn(i) { i.dimension == 2 })
      .length(),
    })
    analyses.push(analysis)
    names.push(name)
  }
  let tracks : Array[Array[Json]] = []
  let mut previous : Map[Int, Int] = Map([])
  for index, interval in analyses[0].intervals {
    if interval.dimension == 2 {
      let id = tracks.length()
      tracks.push([volume_h2_observation(analyses[0], 0, index)])
      previous[index] = id
    }
  }
  let transitions : Array[Json] = []
  for frame = 1; frame < analyses.length(); frame = frame + 1 {
    let left = analyses[frame - 1]
    let right = analyses[frame]
    let current : Map[Int, Int] = Map([])
    let mut continued = 0
    let mut appeared = 0
    let mut disappeared = 0
    for pair in interval_matching(left, right, dimension=2) {
      match (pair.left, pair.right) {
        (Some(a), Some(b)) => {
          let id = match previous.get(a) {
            Some(value) => value
            None => raise TopologyError("missing previous H2 track")
          }
          tracks[id].push(volume_h2_observation(right, frame, b))
          current[b] = id
          continued += 1
        }
        (None, Some(b)) => {
          let id = tracks.length()
          tracks.push([volume_h2_observation(right, frame, b)])
          current[b] = id
          appeared += 1
        }
        (Some(_), None) => disappeared += 1
        (None, None) => ()
      }
    }
    transitions.push({
      "left_frame": frame - 1,
      "right_frame": frame,
      "left_name": names[frame - 1],
      "right_name": names[frame],
      "h0_bottleneck": volume_sequence_optional(
        compare_analyses(left, right, dimension=0),
      ),
      "h1_bottleneck": volume_sequence_optional(
        compare_analyses(left, right, dimension=1),
      ),
      "h2_bottleneck": volume_sequence_optional(
        compare_analyses(left, right, dimension=2),
      ),
      "continued_h2": continued,
      "appeared_h2": appeared,
      "disappeared_h2": disappeared,
    })
    previous = current
  }
  let track_json : Array[Json] = []
  for id, observations in tracks {
    track_json.push({
      "track_id": id,
      "observation_count": observations.length(),
      "observations": observations,
    })
  }
  {
    "schema_version": 1,
    "kind": "volume_sequence",
    "threshold": threshold,
    "shape_zyx": shape,
    "frame_count": frames.length(),
    "total_cells": total_cells,
    "frames": frames,
    "transitions": transitions,
    "tracks": track_json,
    "interpretation": "H2 tracks are one optimal persistence-diagram correspondence; they do not prove physical cavity identity; null distance means incompatible cutoff-censored counts",
  }
}

///|
/// Offline timeline for precomputed volume-sequence summaries.
pub fn volume_sequence_html(report : Json) -> String {
  let data = report.stringify().replace_all(old="<", new="\\u003c")
  let start =
    #|MoonTopoLens · 三维变化

MoonTopoLens · 三维拓扑变化

逐帧体数据在共同尺度下计算 H0/H1/H2。H2 区间数包含在截止前已消失的空腔;轨迹来自一种最优区间匹配,不能证明物理空腔身份。

逐帧摘要

帧名称细胞H0 截止H1 截止H2 截止H2 区间

相邻变化

帧对H2 瓶颈距离延续出现消失

H2 区间轨迹

轨迹观察到的帧与区间
start + data + finish }