flow-gate-xml 0.1.0

Gating-ML 2.0 XML parsing and serialization for flow cytometry gate documents
Documentation
use flow_gate_core::{gate::GateKind, EventMatrix, GateId, ParameterName};
use flow_gate_xml::FlowGateDocument;

#[test]
fn parse_and_serialize_simple_rectangle_gate() {
    let xml = r#"
<gating:GatingML
  xmlns:gating="http://www.isac-net.org/std/Gating-ML/v2.0/gating"
  xmlns:transforms="http://www.isac-net.org/std/Gating-ML/v2.0/transformations"
  xmlns:data-type="http://www.isac-net.org/std/Gating-ML/v2.0/datatypes">
  <gating:Gate gating:id="G1">
    <gating:RectangleGate>
      <gating:dimension gating:min="0" gating:max="10">
        <data-type:parameter data-type:name="FSC-A"/>
      </gating:dimension>
    </gating:RectangleGate>
  </gating:Gate>
</gating:GatingML>
"#;

    let doc = FlowGateDocument::parse_str(xml).expect("parse");
    let out_xml = doc.to_xml().expect("serialize");
    let reparsed = FlowGateDocument::parse_str(&out_xml).expect("reparse");

    let gate = reparsed
        .gate_registry
        .get(&GateId::from("G1"))
        .expect("gate present");
    assert!(matches!(gate, GateKind::Rectangle(_)));
}

#[test]
fn classify_simple_document() {
    let xml = r#"
<gating:GatingML
  xmlns:gating="http://www.isac-net.org/std/Gating-ML/v2.0/gating"
  xmlns:transforms="http://www.isac-net.org/std/Gating-ML/v2.0/transformations"
  xmlns:data-type="http://www.isac-net.org/std/Gating-ML/v2.0/datatypes">
  <gating:Gate gating:id="G1">
    <gating:RectangleGate>
      <gating:dimension gating:min="0" gating:max="10">
        <data-type:parameter data-type:name="FSC-A"/>
      </gating:dimension>
    </gating:RectangleGate>
  </gating:Gate>
</gating:GatingML>
"#;
    let doc = FlowGateDocument::parse_str(xml).expect("parse");
    let matrix = EventMatrix::from_columns(
        vec![vec![-1.0, 2.0, 11.0, 5.0]],
        vec![ParameterName::from("FSC-A")],
    )
    .expect("matrix");
    let results = doc.classify(&matrix).expect("classify");
    let bits = results.get(&GateId::from("G1")).expect("gate result");
    assert_eq!(bits.len(), 4);
    assert!(!bits[0]);
    assert!(bits[1]);
    assert!(!bits[2]);
    assert!(bits[3]);
}

#[test]
fn parser_accepts_nonstandard_prefixes_by_uri() {
    let xml = r#"
<g:GatingML
  xmlns:g="http://www.isac-net.org/std/Gating-ML/v2.0/gating"
  xmlns:t="http://www.isac-net.org/std/Gating-ML/v2.0/transformations"
  xmlns:d="http://www.isac-net.org/std/Gating-ML/v2.0/datatypes">
  <g:Gate g:id="G1">
    <g:RectangleGate>
      <g:dimension g:min="0" g:max="10">
        <d:parameter d:name="FSC-A"/>
      </g:dimension>
    </g:RectangleGate>
  </g:Gate>
</g:GatingML>
"#;
    let doc = FlowGateDocument::parse_str(xml).expect("parse");
    assert!(doc.gate_registry.get(&GateId::from("G1")).is_some());
}

#[test]
fn parser_rejects_wrong_gate_namespace() {
    let xml = r#"
<gating:GatingML
  xmlns:gating="http://www.isac-net.org/std/Gating-ML/v2.0/gating"
  xmlns:bad="http://example.com/not-gating"
  xmlns:data-type="http://www.isac-net.org/std/Gating-ML/v2.0/datatypes">
  <bad:Gate bad:id="G1">
    <bad:RectangleGate>
      <bad:dimension bad:min="0" bad:max="10">
        <data-type:parameter data-type:name="FSC-A"/>
      </bad:dimension>
    </bad:RectangleGate>
  </bad:Gate>
</gating:GatingML>
"#;
    let doc = FlowGateDocument::parse_str(xml).expect("parse");
    assert!(doc.gate_registry.topological_order().is_empty());
}

#[test]
fn parser_errors_on_missing_required_attributes() {
    let xml_missing_gate_id = r#"
<gating:GatingML
  xmlns:gating="http://www.isac-net.org/std/Gating-ML/v2.0/gating"
  xmlns:data-type="http://www.isac-net.org/std/Gating-ML/v2.0/datatypes">
  <gating:Gate>
    <gating:RectangleGate>
      <gating:dimension gating:min="0" gating:max="10">
        <data-type:parameter data-type:name="FSC-A"/>
      </gating:dimension>
    </gating:RectangleGate>
  </gating:Gate>
</gating:GatingML>
"#;
    let err = match FlowGateDocument::parse_str(xml_missing_gate_id) {
        Ok(_) => panic!("expected parse error"),
        Err(err) => err,
    };
    assert!(format!("{err}").contains("Missing required XML attribute 'id'"));

    let xml_missing_param_name = r#"
<gating:GatingML
  xmlns:gating="http://www.isac-net.org/std/Gating-ML/v2.0/gating"
  xmlns:data-type="http://www.isac-net.org/std/Gating-ML/v2.0/datatypes">
  <gating:Gate gating:id="G1">
    <gating:RectangleGate>
      <gating:dimension gating:min="0" gating:max="10">
        <data-type:parameter/>
      </gating:dimension>
    </gating:RectangleGate>
  </gating:Gate>
</gating:GatingML>
"#;
    let err = match FlowGateDocument::parse_str(xml_missing_param_name) {
        Ok(_) => panic!("expected parse error"),
        Err(err) => err,
    };
    assert!(format!("{err}").contains("Missing required XML attribute 'name'"));
}

#[test]
fn roundtrip_preserves_classification_results() {
    let xml = r#"
<gating:GatingML
  xmlns:gating="http://www.isac-net.org/std/Gating-ML/v2.0/gating"
  xmlns:transforms="http://www.isac-net.org/std/Gating-ML/v2.0/transformations"
  xmlns:data-type="http://www.isac-net.org/std/Gating-ML/v2.0/datatypes">
  <gating:Gate gating:id="R1">
    <gating:RectangleGate>
      <gating:dimension gating:min="0" gating:max="10">
        <data-type:parameter data-type:name="FSC-A"/>
      </gating:dimension>
    </gating:RectangleGate>
  </gating:Gate>
  <gating:Gate gating:id="R2" gating:parent_id="R1">
    <gating:RectangleGate>
      <gating:dimension gating:min="2" gating:max="8">
        <data-type:parameter data-type:name="FSC-A"/>
      </gating:dimension>
    </gating:RectangleGate>
  </gating:Gate>
</gating:GatingML>
"#;

    let doc = FlowGateDocument::parse_str(xml).expect("parse");
    let matrix = EventMatrix::from_columns(
        vec![vec![-1.0, 1.0, 3.0, 9.0, 11.0]],
        vec![ParameterName::from("FSC-A")],
    )
    .expect("matrix");
    let before = doc.classify(&matrix).expect("classify before");

    let xml_out = doc.to_xml().expect("serialize");
    let doc_roundtrip = FlowGateDocument::parse_str(&xml_out).expect("reparse");
    let after = doc_roundtrip.classify(&matrix).expect("classify after");

    assert_eq!(
        before.get(&GateId::from("R1")).map(|b| b.clone()),
        after.get(&GateId::from("R1")).map(|b| b.clone())
    );
    assert_eq!(
        before.get(&GateId::from("R2")).map(|b| b.clone()),
        after.get(&GateId::from("R2")).map(|b| b.clone())
    );
}

#[test]
fn parser_accepts_official_ellipsoid3d_full_matrix_and_roundtrips() {
    let xml = r#"
<gating:Gating-ML
  xmlns:gating="http://www.isac-net.org/std/Gating-ML/v2.0/gating"
  xmlns:transforms="http://www.isac-net.org/std/Gating-ML/v2.0/transformations"
  xmlns:data-type="http://www.isac-net.org/std/Gating-ML/v2.0/datatypes">
  <gating:EllipsoidGate gating:id="Ellipsoid3D">
    <gating:dimension gating:compensation-ref="FCS">
      <data-type:fcs-dimension data-type:name="FL3-H" />
    </gating:dimension>
    <gating:dimension gating:compensation-ref="FCS">
      <data-type:fcs-dimension data-type:name="FL4-H" />
    </gating:dimension>
    <gating:dimension gating:compensation-ref="FCS">
      <data-type:fcs-dimension data-type:name="FL1-H" />
    </gating:dimension>
    <gating:mean>
      <gating:coordinate data-type:value="40.3" />
      <gating:coordinate data-type:value="30.6" />
      <gating:coordinate data-type:value="20.8" />
    </gating:mean>
    <gating:covarianceMatrix>
      <gating:row>
        <gating:entry data-type:value="2.5" />
        <gating:entry data-type:value="7.5" />
        <gating:entry data-type:value="17.5" />
      </gating:row>
      <gating:row>
        <gating:entry data-type:value="7.5" />
        <gating:entry data-type:value="7" />
        <gating:entry data-type:value="13.5" />
      </gating:row>
      <gating:row>
        <gating:entry data-type:value="15.5" />
        <gating:entry data-type:value="13.5" />
        <gating:entry data-type:value="4.3" />
      </gating:row>
    </gating:covarianceMatrix>
    <gating:distanceSquare data-type:value="1" />
  </gating:EllipsoidGate>
</gating:Gating-ML>
"#;

    let doc = FlowGateDocument::parse_str(xml).expect("parse");
    let matrix = EventMatrix::from_columns(
        vec![vec![40.3, 60.3], vec![30.6, 50.6], vec![20.8, 40.8]],
        vec![
            ParameterName::from("FL3-H"),
            ParameterName::from("FL4-H"),
            ParameterName::from("FL1-H"),
        ],
    )
    .expect("matrix");
    let results = doc.classify(&matrix).expect("classify");
    let bits = results
        .get(&GateId::from("Ellipsoid3D"))
        .expect("ellipsoid result");
    assert_eq!(bits.len(), 2);
    assert!(bits[0]);
    assert!(!bits[1]);

    let xml_out = doc.to_xml().expect("serialize");
    let reparsed = FlowGateDocument::parse_str(&xml_out).expect("reparse");
    let after = reparsed.classify(&matrix).expect("reclassify");
    assert_eq!(
        results.get(&GateId::from("Ellipsoid3D")).map(|b| b.clone()),
        after.get(&GateId::from("Ellipsoid3D")).map(|b| b.clone())
    );
}