///|
/// Symmetric H0/H1/H2 bottleneck distances for one bounded batch study.
/// None means no finite match because alive interval counts differ.
/// H2 is None when either case did not analyze that dimension.
pub struct BatchDistanceMatrix {
names : Array[String]
kinds : Array[String]
cutoffs : Array[Double]
h0 : Array[Array[Double?]]
h1 : Array[Array[Double?]]
h2 : Array[Array[Double?]]
h2_analyzed : Array[Bool]
conditions_aligned : Array[Array[Bool]]
} derive(@debug.Debug)
///|
pub extend BatchDistanceMatrix with @debug.Debug::{to_repr}
///|
/// Compute every unordered pair once. Agreement on kind and cutoff is only
/// a necessary metadata check; scale units and data meaning remain user choices.
pub fn batch_distance_matrix(
plan : BatchPlan,
analyses : Array[Analysis],
) -> BatchDistanceMatrix raise TopologyError {
let n = plan.cases.length()
if n < 1 || n > 8 || analyses.length() != n {
raise TopologyError(
"batch matrix requires 1..8 matching cases and analyses",
)
}
let seen : Map[String, Bool] = Map([])
for case in plan.cases {
batch_name(case.name)
if seen.contains(case.name) {
raise TopologyError("duplicate batch matrix case name: " + case.name)
}
seen[case.name] = true
}
let names = plan.cases.map(fn(case) { case.name })
let kinds = analyses.map(fn(result) { result.filtration.kind })
let cutoffs = analyses.map(fn(result) { result.filtration.cutoff })
let h0 = Array::makei(n, fn(_) { Array::make(n, None) })
let h1 = Array::makei(n, fn(_) { Array::make(n, None) })
let h2 = Array::makei(n, fn(_) { Array::make(n, None) })
let h2_analyzed = analyses.map(fn(result) { result.homology_dimension == 2 })
let conditions_aligned = Array::makei(n, fn(_) { Array::make(n, false) })
for i = 0; i < n; i = i + 1 {
h0[i][i] = Some(0.0)
h1[i][i] = Some(0.0)
if h2_analyzed[i] {
h2[i][i] = Some(0.0)
}
conditions_aligned[i][i] = true
for j = i + 1; j < n; j = j + 1 {
let d0 = compare_analyses(analyses[i], analyses[j], dimension=0)
let d1 = compare_analyses(analyses[i], analyses[j], dimension=1)
h0[i][j] = d0
h0[j][i] = d0
h1[i][j] = d1
h1[j][i] = d1
if h2_analyzed[i] && h2_analyzed[j] {
let d2 = compare_analyses(analyses[i], analyses[j], dimension=2)
h2[i][j] = d2
h2[j][i] = d2
}
let aligned = kinds[i] == kinds[j] && cutoffs[i] == cutoffs[j]
conditions_aligned[i][j] = aligned
conditions_aligned[j][i] = aligned
}
}
{ names, kinds, cutoffs, h0, h1, h2, h2_analyzed, conditions_aligned, }
}
///|
fn matrix_value(value : Double?) -> Json {
match value {
Some(number) => number.to_json()
None => Json::null()
}
}
///|
/// Portable matrix schema; null is deliberately distinct from a zero distance.
pub fn batch_matrix_json(matrix : BatchDistanceMatrix) -> Json {
let h0 : Array[Array[Json]] = []
let h1 : Array[Array[Json]] = []
let h2 : Array[Array[Json]] = []
for row in matrix.h0 {
h0.push(row.map(matrix_value))
}
for row in matrix.h1 {
h1.push(row.map(matrix_value))
}
for row in matrix.h2 {
h2.push(row.map(matrix_value))
}
{
"schema_version": 1,
"metric": "bottleneck",
"names": matrix.names,
"kinds": matrix.kinds,
"cutoffs": matrix.cutoffs,
"h0": h0,
"h1": h1,
"h2": h2,
"h2_analyzed": matrix.h2_analyzed,
"conditions_aligned": matrix.conditions_aligned,
"null_meaning": "H0/H1: unequal counts of classes alive at cutoff; H2: either not analyzed or unequal censored counts; inspect h2_analyzed to distinguish",
"comparison_note": "matching kind and cutoff is necessary but not sufficient; verify units, grayscale transforms and data meaning before interpreting distances",
}
}
///|
/// Square CSV with blanks for null distances. Batch names are CSV-safe ASCII.
pub fn batch_matrix_csv(
matrix : BatchDistanceMatrix,
dimension : Int,
) -> String raise TopologyError {
if dimension < 0 || dimension > 2 {
raise TopologyError("matrix dimension must be 0, 1 or 2")
}
let rows = if dimension == 0 {
matrix.h0
} else if dimension == 1 {
matrix.h1
} else {
matrix.h2
}
let mut csv = "case"
for name in matrix.names {
csv += "," + name
}
csv += "\n"
for i, name in matrix.names {
csv += name
for value in rows[i] {
let field = match value {
Some(number) => number.to_string()
None => ""
}
csv += "," + field
}
csv += "\n"
}
csv
}
///|
fn matrix_max(rows : Array[Array[Double?]]) -> Double {
let mut greatest = 0.0
for row in rows {
for value in row {
if value is Some(number) && number > greatest {
greatest = number
}
}
}
greatest
}
///|
fn matrix_table(
matrix : BatchDistanceMatrix,
rows : Array[Array[Double?]],
dimension : Int,
) -> String {
let greatest = matrix_max(rows)
let mut html = ""
}
///|
fn matrix_html_escape(value : String) -> String {
value
.replace_all(old="&", new="&")
.replace_all(old="<", new="<")
.replace_all(old=">", new=">")
.replace_all(old="\"", new=""")
}
///|
/// Static offline heatmap with no JavaScript or network dependency.
pub fn batch_matrix_html(matrix : BatchDistanceMatrix) -> String {
let mut html =
#|MoonTopoLens · 批量距离热图MoonTopoLens · 批量距离热图
同一批案例的 H0/H1 瓶颈距离;每个无序案例对只计算一次。点击行标题查看案例报告。
输入条件
橙框表示两侧复形类型或截止值不同。相同类型和截止值也不保证尺度单位、灰度变换和数据含义可比。
破折号表示没有有限匹配;H2 也可能是未计算。它不是距离 0。颜色只按本维度的有限距离归一化,用于浏览,数值以 CSV/JSON 为准。
查看 H0 分组树 · 查看 H1 分组树"
if matrix.h2_analyzed.contains(true) {
html += " · 查看 H2 分组树"
}
html += "(仅用有限且输入条件一致的距离)
"
html += matrix_table(matrix, matrix.h0, 0)
html += matrix_table(matrix, matrix.h1, 1)
if matrix.h2_analyzed.contains(true) {
html += "H2 空格表示至少一侧未计算 H2,或截止时存活类数量不同;请结合 JSON 中的 h2_analyzed 判断。
"
html += matrix_table(matrix, matrix.h2, 2)
}
html +
""
}