Skip to content

Commit 272f649

Browse files
committed
feat(index): write gene_id_to_name.tsv for --probe-csv indexes
When `simpleaf index` builds from a 10x probe set CSV that carries a gene symbol column (`gene_name` or `gene_symbol`), it now emits a `gene_id_to_name.tsv` mapping into the reference and the built index. Previously this mapping was only produced on the auto-build path inside `multiplex-quant`/`quant` (via probe_utils), so a prebuilt probe index passed with `--index` carried no gene names. The GTF/roers path already wrote the file; this brings the probe-csv path to parity, letting downstream `quant` surface gene names for prebuilt probe indexes. The mapping covers every gene in the probe set independent of the `included` flag (it is a complete gene annotation, not matrix contents); conflicting names for the same gene_id are rejected. The insert-with-conflict-check and TSV-write logic are factored into two shared `probe_utils` helpers (`insert_gene_name`, `write_gene_id_to_name`) used by BOTH the auto-build path and `simpleaf index`, so the logic is not duplicated. Adds a unit test for the dedup/conflict behavior.
1 parent 52ccc73 commit 272f649

2 files changed

Lines changed: 96 additions & 27 deletions

File tree

src/simpleaf_commands/indexing.rs

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ use crate::core::{context, exec, io, runtime};
22
use crate::utils::af_utils::create_dir_if_absent;
33
use crate::utils::prog_utils;
44
use crate::utils::prog_utils::ReqProgs;
5+
use crate::utils::probe_utils;
56

67
use anyhow::{Context, anyhow, bail};
78
use roers;
89
use serde::Deserialize;
910
use serde_json::json;
10-
use std::collections::HashSet;
11+
use std::collections::{BTreeMap, HashSet};
1112
use std::fs::File;
1213
use std::io::{BufWriter, Write};
1314
use std::path::{Path, PathBuf};
@@ -175,6 +176,17 @@ struct ProbeRow {
175176
probe_id: String,
176177
included: Option<Included>,
177178
region: Option<ProbeRegion>,
179+
// optional gene symbol column (10x probe set v2 CSVs include `gene_name`;
180+
// some panels name it `gene_symbol`). Used to emit a gene_id -> name map.
181+
#[serde(default, alias = "gene_symbol")]
182+
gene_name: Option<String>,
183+
}
184+
185+
impl ProbeRow {
186+
/// The gene symbol/name for this probe's gene, if the CSV provided one.
187+
fn gene_name(&self) -> Option<&str> {
188+
self.gene_name.as_deref()
189+
}
178190
}
179191

180192
impl CsvRow<'_> for ProbeRow {
@@ -275,7 +287,6 @@ fn parse_csv_record(
275287
has_region: bool,
276288
seq_id_hs: &mut HashSet<String>,
277289
ref_seq_writer: &mut BufWriter<File>,
278-
// id_to_name_writer: &mut BufWriter<File>,
279290
t2g_writer: &mut BufWriter<File>,
280291
) -> anyhow::Result<()> {
281292
if !include {
@@ -301,9 +312,6 @@ fn parse_csv_record(
301312
writeln!(t2g_writer, "{}\t{}", seq_id, ref_id)?;
302313
};
303314

304-
// insert into gene id to name
305-
// writeln!(id_to_name_writer, "{}\t{}", ref_id, ref_id)?;
306-
307315
// insert into ref seq
308316
writeln!(ref_seq_writer, ">{}\n{}", seq_id, sequence)?;
309317
Ok(())
@@ -453,7 +461,6 @@ pub fn build_ref_and_index(af_home_path: &Path, opts: IndexOpts) -> anyhow::Resu
453461

454462
// define file names
455463
let ref_seq_path = outref.join("ref.fa");
456-
// let id_to_name_path = outref.join("gene_id_to_name.tsv");
457464
let t2g_path = if has_region {
458465
outref.join("t2g_3col.tsv")
459466
} else {
@@ -462,9 +469,11 @@ pub fn build_ref_and_index(af_home_path: &Path, opts: IndexOpts) -> anyhow::Resu
462469

463470
// define buffer writers
464471
let mut ref_seq_writer = BufWriter::new(File::create(&ref_seq_path)?);
465-
// let mut id_to_name_writer = BufWriter::new(File::create(&id_to_name_path)?);
466472
let mut t2g_writer = BufWriter::new(File::create(&t2g_path)?);
467473
let mut msl = u32::MAX;
474+
// collected gene_id -> gene_name for probe CSVs that carry a gene symbol column;
475+
// written out as gene_id_to_name.tsv so downstream `quant` can surface gene names.
476+
let mut gene_id_to_name_map: BTreeMap<String, String> = BTreeMap::new();
468477

469478
match csv_reader {
470479
CsvReader::Feature(mut rdr) => {
@@ -482,7 +491,6 @@ pub fn build_ref_and_index(af_home_path: &Path, opts: IndexOpts) -> anyhow::Resu
482491
has_region,
483492
&mut seq_id_hs,
484493
&mut ref_seq_writer,
485-
// &mut id_to_name_writer,
486494
&mut t2g_writer,
487495
)?;
488496
}
@@ -492,6 +500,19 @@ pub fn build_ref_and_index(af_home_path: &Path, opts: IndexOpts) -> anyhow::Resu
492500
for row in rdr.deserialize() {
493501
let record: ProbeRow = row?;
494502

503+
// record gene_id -> gene_name for every probe that carries a name,
504+
// independent of the `included` flag: the mapping is a complete gene
505+
// annotation, written whenever the probe set provides gene symbols.
506+
if let Some(gene_name) =
507+
record.gene_name().map(str::trim).filter(|s| !s.is_empty())
508+
{
509+
probe_utils::insert_gene_name(
510+
&mut gene_id_to_name_map,
511+
record.ref_id(),
512+
gene_name,
513+
)?;
514+
}
515+
495516
parse_csv_record(
496517
record.ref_id(),
497518
record.seq_id(),
@@ -501,20 +522,27 @@ pub fn build_ref_and_index(af_home_path: &Path, opts: IndexOpts) -> anyhow::Resu
501522
has_region,
502523
&mut seq_id_hs,
503524
&mut ref_seq_writer,
504-
// &mut id_to_name_writer,
505525
&mut t2g_writer,
506526
)?;
507527
}
508528
}
509529
}
510530

511531
index_info["t2g_file"] = json!(&t2g_path);
512-
// index_info["gene_id_to_name"] = json!(&id_to_name_path);
532+
533+
// If the (probe) CSV carried gene symbols, emit a gene_id -> gene_name map.
534+
// This parallels the GTF/roers path (above) and the multiplex-quant auto-build
535+
// path, so a prebuilt probe index also lets `quant` surface gene names.
536+
if !gene_id_to_name_map.is_empty() {
537+
let id_to_name_path = outref.join("gene_id_to_name.tsv");
538+
probe_utils::write_gene_id_to_name(&gene_id_to_name_map, &id_to_name_path)?;
539+
index_info["gene_id_to_name"] = json!(&id_to_name_path);
540+
gene_id_to_name = Some(id_to_name_path);
541+
}
513542

514543
min_seq_len = Some(msl);
515544
reference_sequence = Some(ref_seq_path);
516545
t2g = Some(t2g_path);
517-
// _gene_id_to_name = Some(id_to_name_path);
518546
}
519547

520548
io::write_json_pretty(&info_file, &index_info)?;

src/utils/probe_utils.rs

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,41 @@ fn get_required_idx(headers: &csv::StringRecord, name: &str) -> anyhow::Result<u
5252
.with_context(|| format!("probe CSV is missing required column `{}`", name))
5353
}
5454

55+
/// Record a `gene_id -> gene_name` association into `map`, erroring if a *different*
56+
/// name was already recorded for the same `gene_id` (an internally inconsistent probe
57+
/// set). Shared by the probe-set conversion (auto-build) and `simpleaf index --probe-csv`.
58+
pub fn insert_gene_name(
59+
map: &mut BTreeMap<String, String>,
60+
gene_id: &str,
61+
gene_name: &str,
62+
) -> anyhow::Result<()> {
63+
if let Some(prev) = map.insert(gene_id.to_string(), gene_name.to_string())
64+
&& prev != gene_name
65+
{
66+
bail!(
67+
"probe CSV contains inconsistent gene annotations for `{}`: saw both `{}` and `{}`.",
68+
gene_id,
69+
prev,
70+
gene_name,
71+
);
72+
}
73+
Ok(())
74+
}
75+
76+
/// Write a `gene_id -> gene_name` map as a 2-column TSV (rows sorted by `gene_id`,
77+
/// since the map is a `BTreeMap`). Shared by both probe-index build paths.
78+
pub fn write_gene_id_to_name(
79+
map: &BTreeMap<String, String>,
80+
path: &Path,
81+
) -> anyhow::Result<()> {
82+
let mut writer = BufWriter::new(std::fs::File::create(path)?);
83+
for (gene_id, gene_name) in map {
84+
writeln!(writer, "{}\t{}", gene_id, gene_name)?;
85+
}
86+
writer.flush()?;
87+
Ok(())
88+
}
89+
5590
/// Convert a 10x probe set CSV file to a FASTA file suitable for indexing.
5691
///
5792
/// Also generates a collapsed gene-level transcript-to-gene (t2g) map and, when
@@ -140,16 +175,7 @@ pub fn convert_probe_csv_to_reference_files(
140175
&& let Some(gene_name) = record.get(gene_name_i).map(str::trim)
141176
&& !gene_name.is_empty()
142177
{
143-
if let Some(prev) = gene_id_to_name.insert(gene_id.to_string(), gene_name.to_string())
144-
&& prev != gene_name
145-
{
146-
bail!(
147-
"probe CSV contains inconsistent gene annotations for `{}`: saw both `{}` and `{}`.",
148-
gene_id,
149-
prev,
150-
gene_name,
151-
);
152-
}
178+
insert_gene_name(&mut gene_id_to_name, gene_id, gene_name)?;
153179
}
154180

155181
if let Some(region_i) = region_idx {
@@ -179,11 +205,7 @@ Expected `spliced` or `unspliced`.",
179205
writer.flush()?;
180206
}
181207
if let Some(ref path) = gene_id_to_name_path {
182-
let mut writer = BufWriter::new(std::fs::File::create(path)?);
183-
for (gene_id, gene_name) in &gene_id_to_name {
184-
writeln!(writer, "{}\t{}", gene_id, gene_name)?;
185-
}
186-
writer.flush()?;
208+
write_gene_id_to_name(&gene_id_to_name, path)?;
187209
}
188210

189211
metadata.insert("num_probes".to_string(), json!(num_probes));
@@ -312,11 +334,30 @@ Provide a probe CSV with a `region` column (`spliced` / `unspliced`), or a pre-b
312334
mod tests {
313335
use super::{
314336
ProbeT2gMode, collapse_t2g_to_gene, convert_probe_csv_to_reference_files, ensure_t2g_mode,
315-
t2g_has_usa_mapping,
337+
insert_gene_name, t2g_has_usa_mapping,
316338
};
339+
use std::collections::BTreeMap;
317340
use std::fs;
318341
use tempfile::tempdir;
319342

343+
#[test]
344+
fn insert_gene_name_dedups_and_detects_conflicts() {
345+
let mut m = BTreeMap::new();
346+
insert_gene_name(&mut m, "G1", "GeneOne").expect("first insert ok");
347+
insert_gene_name(&mut m, "G1", "GeneOne").expect("identical re-insert ok");
348+
insert_gene_name(&mut m, "G2", "GeneTwo").expect("distinct gene ok");
349+
assert_eq!(m.len(), 2);
350+
assert_eq!(m.get("G1").map(String::as_str), Some("GeneOne"));
351+
352+
let err = insert_gene_name(&mut m, "G1", "Different")
353+
.expect_err("conflicting name for same gene_id should error");
354+
assert!(
355+
format!("{:#}", err).contains("inconsistent gene annotations"),
356+
"unexpected error: {:#}",
357+
err
358+
);
359+
}
360+
320361
#[test]
321362
fn convert_probe_csv_writes_gene_and_usa_t2g_files() {
322363
let td = tempdir().expect("failed to create tempdir");

0 commit comments

Comments
 (0)